mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a4b99ca43 | |||
| 9bf8c5c633 | |||
| fe3e712555 | |||
| 6e85e99f78 | |||
| 0e07d06a91 | |||
| cc38c4fedb | |||
| 749b9cc6b8 | |||
| be4ce4ba2e | |||
| 7afcce47ae | |||
| 7eb2bc9a16 | |||
| 67adb66c8b | |||
| 247a50be62 | |||
| 225449796a | |||
| 7e1adab035 | |||
| aca6f55ea0 | |||
| ce51fa9957 | |||
| 676e89a58e | |||
| c1d4a8e373 | |||
| 7801bcb8e7 | |||
| d4cdf4288e | |||
| efcea0a7f2 | |||
| 5004281077 | |||
| 2443be9329 | |||
| 6f49cb62c3 | |||
| 6f50ea1d60 | |||
| 52679db853 | |||
| 9a94e2dcf2 | |||
| c990ace2e2 | |||
| 7a7912b07e | |||
| 05a7627148 | |||
| 5ba613ac27 | |||
| c717e8e3eb | |||
| 144d83e68c | |||
| abd6518854 | |||
| 9c12498162 | |||
| aa879d8fab | |||
| 28aebc9fbf | |||
| abaeba5952 | |||
| b666d8f13d | |||
| 8e4844fd84 | |||
| 15892a9364 | |||
| 23a89c4d7b | |||
| f1c5b145a4 | |||
| 4c7b36dfca | |||
| e1d0d4717c | |||
| e3f836c22b | |||
| e9602ada24 | |||
| 0ff4bed18f | |||
| 6b49d91f2f | |||
| 77f0572445 | |||
| 5b11a0cc16 | |||
| dfe97bee50 | |||
| 500730b243 | |||
| ec6ed809a4 | |||
| 08385b8a9e | |||
| 9929020b44 | |||
| 48a330347f | |||
| 5b6bebc308 | |||
| c831c71c51 | |||
| 90350e82fe | |||
| b7bbaac2eb | |||
| 5a45b95a48 | |||
| 9deb9268b5 | |||
| 53f4c724bb | |||
| 184e56264c | |||
| ffa7043cf0 | |||
| ff3c157554 | |||
| 13f23d19fc | |||
| b527048b76 | |||
| e1b0cfb6a0 | |||
| 2205b9ee87 | |||
| 1122f030a9 | |||
| 4cc0beb90d | |||
| 16084322ca | |||
| fa70735585 | |||
| 8d694e666c | |||
| 324ce96aaf | |||
| cc7f9d1a72 | |||
| 0116441a58 | |||
| be93b4ffe9 | |||
| 11cb90b4fa | |||
| d1b7d0ee45 | |||
| 029161002b | |||
| 1e10985626 | |||
| e5fdaae09a | |||
| cfdb213cc1 | |||
| 64106979ba | |||
| 6dffa023b1 | |||
| 869b6e7394 | |||
| 73086139d2 | |||
| 92b257381b |
@@ -1,5 +1,8 @@
|
||||
URL=https://local.outline.dev:3000
|
||||
|
||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
|
||||
# Enable unsafe-inline in script-src CSP directive
|
||||
|
||||
+2
-2
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_URL=redis://redis:6379
|
||||
# or alternatively, if you would like to provide additional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
|
||||
@@ -13,3 +13,16 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
|
||||
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -7,9 +7,10 @@ export enum AvatarSize {
|
||||
Small = 16,
|
||||
Toast = 18,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
Large = 28,
|
||||
XLarge = 32,
|
||||
XXLarge = 48,
|
||||
Upload = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
@@ -20,36 +21,37 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
model?: IAvatar;
|
||||
/** The alt text for the image */
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
/** Optional click handler */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const { model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
src={src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
<Initials color={model.color} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
<Initials {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
@@ -65,15 +67,11 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -14,6 +14,8 @@ type Props = {
|
||||
isObserving: boolean;
|
||||
isCurrentUser: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
size?: AvatarSize;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function AvatarWithPresence({
|
||||
@@ -23,6 +25,8 @@ function AvatarWithPresence({
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -47,13 +51,14 @@ function AvatarWithPresence({
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper
|
||||
<AvatarPresence
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
@@ -69,7 +74,7 @@ type AvatarWrapperProps = {
|
||||
$color: string;
|
||||
};
|
||||
|
||||
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -3,9 +3,12 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
/** The color of the background, defaults to textTertiary. */
|
||||
color?: string;
|
||||
/** Content is only used to calculate font size, use children to render. */
|
||||
content?: string;
|
||||
/** The size of the avatar */
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -13,15 +16,14 @@ const Initials = styled(Flex)<{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${s("white75")};
|
||||
background-color: ${(props) => props.color};
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,49 +78,56 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const renderAvatar = React.useCallback(
|
||||
({ model: collaborator, ...rest }) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== currentUserId;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, ui, currentUserId, editingIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * 32}
|
||||
height={32}
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={collaborators.length - limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderAvatar={renderAvatar}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -8,23 +7,14 @@ import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
|
||||
const extensions = [
|
||||
...richExtensions,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -33,33 +23,8 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
@@ -67,7 +32,6 @@ function CollectionDescription({ collection }: Props) {
|
||||
await collection.save({
|
||||
data: getValue(false),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
||||
throw err;
|
||||
@@ -76,162 +40,44 @@ function CollectionDescription({ collection }: Props) {
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = React.useMemo(
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</Placeholder>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.data}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && (
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</MaxHeight>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("divider")};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
const Placeholder = styled(Text)`
|
||||
color: ${s("placeholder")};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: 8px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
@@ -30,12 +32,29 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -105,14 +105,15 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
@@ -121,7 +122,7 @@ function DocumentBreadcrumb(
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -134,7 +135,7 @@ function DocumentBreadcrumb(
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{node.title}
|
||||
{node.title || t("Untitled")}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -71,6 +72,10 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -143,8 +148,14 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -165,6 +176,21 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -14,12 +14,12 @@ import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
|
||||
@@ -9,13 +9,13 @@ import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -23,7 +23,6 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -71,7 +71,13 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
image={
|
||||
<Avatar
|
||||
key={model.id}
|
||||
model={model}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -50,82 +41,23 @@ export type Props = Optional<
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
const { comments } = useStores();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : undefined,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[locale, documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
});
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
? await uploadFile(file, options)
|
||||
: await uploadFileFromUrl(file, options);
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
@@ -263,7 +195,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
|
||||
@@ -13,16 +13,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -154,7 +153,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
|
||||
+62
-38
@@ -1,17 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Initials from "./Avatar/Initials";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
users: User[];
|
||||
/** The size of the avatars, defaults to AvatarSize.Large */
|
||||
size?: number;
|
||||
/** A number to show as the number of additional users */
|
||||
overflow?: number;
|
||||
/** The maximum number of users to display, defaults to 8 */
|
||||
limit?: number;
|
||||
renderAvatar?: (user: User) => React.ReactNode;
|
||||
/** A component to render the avatar, defaults to Avatar. */
|
||||
renderAvatar?: React.ComponentType<
|
||||
React.ComponentProps<typeof Avatar> & {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -19,55 +28,70 @@ function Facepile({
|
||||
overflow = 0,
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = DefaultAvatar,
|
||||
renderAvatar = Avatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
const filtered = users.filter(Boolean).slice(-limit);
|
||||
const Component = renderAvatar;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</span>
|
||||
</More>
|
||||
<Initials size={size} content={String(overflow)}>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</Initials>
|
||||
)}
|
||||
{users
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
{filtered.map((model, index) => {
|
||||
const lastChild = index === 0 && overflow <= 0;
|
||||
return (
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
marginRight: lastChild ? 0 : -4,
|
||||
...(lastChild || filtered.length === 1
|
||||
? {}
|
||||
: { clipPath: `url(#${clipPathId(size)})` }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<FacepileClip size={size} />
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar model={user} size={AvatarSize.Large} />;
|
||||
function FacepileClip({ size }: { size: number }) {
|
||||
return (
|
||||
<SVG
|
||||
width="25"
|
||||
height="28"
|
||||
viewBox="0 0 25 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<clipPath id={clipPathId(size)}>
|
||||
<path
|
||||
transform={size !== 28 ? `scale(${size / 28})` : ""}
|
||||
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</SVG>
|
||||
);
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
margin-right: -8px;
|
||||
function clipPathId(size: number) {
|
||||
return `facepile-${size}`;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const More = styled.div<{ size: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("white")};
|
||||
border: 2px solid ${s("background")};
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
const SVG = styled.svg`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s } from "@shared/styles";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
enum Panel {
|
||||
Builtin,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
||||
&: ${hover},
|
||||
|
||||
@@ -2,13 +2,12 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
|
||||
@@ -10,19 +10,18 @@ import {
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hover } from "~/styles";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -6,10 +6,9 @@ import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
/** An icon or image to display to the left of the list item */
|
||||
|
||||
@@ -4,11 +4,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
|
||||
@@ -3,13 +3,12 @@ import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type { ReactionSummary } from "@shared/types";
|
||||
import { getEmojiId } from "@shared/utils/emoji";
|
||||
import User from "~/models/User";
|
||||
@@ -13,7 +13,6 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
|
||||
@@ -7,10 +7,9 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -201,11 +201,7 @@ export const AccessControlList = observer(
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
<Avatar model={membership.user} size={AvatarSize.Medium} />
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
image={<Avatar model={user} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
@@ -160,9 +160,7 @@ export const AccessControlList = observer(
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
|
||||
@@ -73,9 +73,7 @@ const DocumentMemberListItem = ({
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={
|
||||
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
|
||||
}
|
||||
image={<Avatar model={user} size={AvatarSize.Medium} />}
|
||||
subtitle={
|
||||
membership?.sourceId ? (
|
||||
<Trans>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import BaseListItem from "~/components/List/Item";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const InviteIcon = styled(PlusIcon)`
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -20,7 +20,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||
import { hover } from "~/styles";
|
||||
import { InviteIcon, ListItem } from "./ListItem";
|
||||
|
||||
type Suggestion = IAvatar & {
|
||||
@@ -159,13 +158,7 @@ export const Suggestions = observer(
|
||||
: suggestion.isViewer
|
||||
? t("Viewer")
|
||||
: t("Editor"),
|
||||
image: (
|
||||
<Avatar
|
||||
model={suggestion}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
),
|
||||
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { darken } from "polished";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
// TODO: Temp until Button/NudeButton styles are normalized
|
||||
export const Wrapper = styled.div`
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -11,9 +12,9 @@ import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -40,7 +41,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
@@ -67,6 +70,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
|
||||
@@ -228,7 +228,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -2,26 +2,29 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -41,12 +44,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const { documents } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -58,119 +63,127 @@ const CollectionLink: React.FC<Props> = ({
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const handleExpand = React.useCallback(() => {
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}, [expanded, onDisclosureClick]);
|
||||
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
handleExpand,
|
||||
parentRef
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.replace(documentEditPath(newDocument));
|
||||
},
|
||||
[user, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
@@ -36,55 +34,17 @@ function CollectionLinkChildren({
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
const dummyRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
noop,
|
||||
dummyRef
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
@@ -100,12 +60,8 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
{canDrop && collection.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
|
||||
)}
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{!childDocuments && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -17,7 +18,6 @@ import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
|
||||
@@ -3,22 +3,24 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
@@ -58,6 +60,7 @@ function InnerDocumentLink(
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
@@ -67,6 +70,7 @@ function InnerDocumentLink(
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -216,6 +220,31 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.replace(documentEditPath(newDocument));
|
||||
},
|
||||
[documents, collection, user, node, doc, history, closeAddingNewChild]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -282,8 +311,11 @@ function InnerDocumentLink(
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newNestedDocumentPath(document.id)}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
@@ -308,8 +340,25 @@ function InnerDocumentLink(
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, index) => (
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -318,7 +367,7 @@ function InnerDocumentLink(
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
|
||||
@@ -3,23 +3,33 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (title: string) => Promise<void>;
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
onSubmit: (title: string) => Promise<void> | void;
|
||||
/** A callback when the editing status changes. */
|
||||
onEditing?: (isEditing: boolean) => void;
|
||||
/** A callback when editing is canceled. */
|
||||
onCancel?: () => void;
|
||||
/** The default title. */
|
||||
title: string;
|
||||
/** Whether the user can update the title. */
|
||||
canUpdate: boolean;
|
||||
/** The maximum length of the title. */
|
||||
maxLength?: number;
|
||||
/** The default editing state. */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export type RefHandle = {
|
||||
/** A function to set the editing state. */
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
};
|
||||
|
||||
function EditableTitle(
|
||||
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
|
||||
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
|
||||
@@ -59,6 +69,7 @@ function EditableTitle(
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +84,7 @@ function EditableTitle(
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, value, onSubmit]
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -83,13 +94,14 @@ function EditableTitle(
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, originalValue]
|
||||
[handleSave, onCancel, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "~/utils/tree";
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
collection?: Collection;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -29,6 +30,7 @@ function DocumentLink(
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentId,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
shareId,
|
||||
@@ -97,6 +99,10 @@ function DocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
}, [prefetchDocument, node]);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
@@ -114,6 +120,7 @@ function DocumentLink(
|
||||
}}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={title}
|
||||
depth={depth}
|
||||
@@ -132,6 +139,7 @@ function DocumentLink(
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
|
||||
@@ -4,7 +4,6 @@ import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
@@ -12,11 +11,6 @@ import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "~/styles";
|
||||
import { hover } from "@shared/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { trashPath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function TrashLink() {
|
||||
const { policies, dialogs, documents } = useStores();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -13,12 +14,50 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
elementRef: React.RefObject<HTMLDivElement>,
|
||||
callback: () => void
|
||||
) {
|
||||
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const startHover = React.useCallback(() => {
|
||||
if (!hoverTimeoutRef.current) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
hoverTimeoutRef.current = undefined;
|
||||
callback();
|
||||
}, 500);
|
||||
}
|
||||
}, [callback]);
|
||||
|
||||
const unsetHover = React.useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
element?.addEventListener("dragleave", unsetHover);
|
||||
return () => element?.removeEventListener("dragleave", unsetHover);
|
||||
}, [elementRef, unsetHover]);
|
||||
|
||||
return startHover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging a Starred item
|
||||
*
|
||||
@@ -162,6 +201,84 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
export function useDropToChangeCollection(
|
||||
collection: Collection,
|
||||
expandNode: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const startHover = useHover(parentRef, expandNode);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, collectionId } = item;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
const document = documents.get(id);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
expandNode();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
hover: (_, monitor) => {
|
||||
if (
|
||||
collection.hasDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
@@ -175,7 +292,7 @@ export function useDropToReparentDocument(
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
@@ -183,25 +300,7 @@ export function useDropToReparentDocument(
|
||||
[document]
|
||||
);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
const startHover = useHover(parentRef, setExpanded);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -214,7 +313,9 @@ export function useDropToReparentDocument(
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const collection = node.collectionId
|
||||
? collections.get(node.collectionId)
|
||||
: undefined;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
@@ -233,22 +334,40 @@ export function useDropToReparentDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
|
||||
{
|
||||
documentName: item.title,
|
||||
parentDocumentName: node.title,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
if (!node || item.id === node.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setExpanded();
|
||||
if (!document) {
|
||||
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
|
||||
}
|
||||
|
||||
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
@@ -259,15 +378,7 @@ export function useDropToReparentDocument(
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -297,7 +408,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
@@ -308,22 +419,9 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
if (item.id === node.id || (document && !document.isActive)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
@@ -357,7 +455,19 @@ export function useDropToReorderDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move(params);
|
||||
try {
|
||||
await documents.move(params);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t("The {{ documentName }} cannot be moved here", {
|
||||
documentName: item.title,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from "~/actions/definitions/collections";
|
||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -4,9 +4,8 @@ import isEqual from "lodash/isEqual";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
/**
|
||||
|
||||
@@ -190,7 +190,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
if (!collection?.documents?.length && !event.fetchIfMissing) {
|
||||
if (!collection?.documents && !event.fetchIfMissing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import SuggestionsMenu, {
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "trigger"
|
||||
> &
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
|
||||
@@ -17,7 +17,7 @@ type Emoji = {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=":"
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
|
||||
@@ -6,10 +6,10 @@ import styled, { css } from "styled-components";
|
||||
import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
@@ -184,10 +184,10 @@ function usePosition({
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
left: Math.max(margin, Math.round(left - offsetParent.left)),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
import {
|
||||
ArrowIcon,
|
||||
DocumentIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
|
||||
import { Mark } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, hideScrollbars } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Input from "./Input";
|
||||
import LinkSearchResult from "./LinkSearchResult";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mark?: Mark;
|
||||
from: number;
|
||||
to: number;
|
||||
dictionary: Dictionary;
|
||||
onRemoveLink?: () => void;
|
||||
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onSelectLink: (options: {
|
||||
href: string;
|
||||
title?: string;
|
||||
@@ -52,46 +33,25 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
results: {
|
||||
[keyword: string]: SearchResult[];
|
||||
};
|
||||
value: string;
|
||||
previousValue: string;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
class LinkEditor extends React.Component<Props, State> {
|
||||
discardInputValue = false;
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
resultsRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
state: State = {
|
||||
selectedIndex: -1,
|
||||
value: this.href,
|
||||
previousValue: "",
|
||||
results: {},
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
get selectedText(): string {
|
||||
const { state } = this.props.view;
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
return selectionText.trim();
|
||||
}
|
||||
|
||||
get suggestedLinkTitle(): string {
|
||||
return this.state.value.trim() || this.selectedText;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
const results = this.results;
|
||||
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
const { selectedIndex, value } = this.state;
|
||||
const { onCreateLink } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
const result = results[selectedIndex];
|
||||
if (result) {
|
||||
this.save(result.url, result.title);
|
||||
} else if (onCreateLink && selectedIndex === results.length) {
|
||||
void this.handleCreateLink(this.suggestedLinkTitle);
|
||||
}
|
||||
} else {
|
||||
// saves the raw input as href
|
||||
this.save(value, value);
|
||||
}
|
||||
this.save(value, value);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const prevIndex = this.state.selectedIndex - 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.max(-1, prevIndex),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowDown":
|
||||
case "Tab": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { selectedIndex } = this.state;
|
||||
const total = results.length + 1;
|
||||
const nextIndex = selectedIndex + 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.min(nextIndex, total),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleFocusLink = (selectedIndex: number) => {
|
||||
this.setState({ selectedIndex });
|
||||
};
|
||||
|
||||
handleSearch = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): Promise<void> => {
|
||||
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
|
||||
const trimmedValue = value.trim() || this.selectedText;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue && this.props.onSearchLink) {
|
||||
if (trimmedValue) {
|
||||
try {
|
||||
const results = await this.props.onSearchLink(trimmedValue);
|
||||
this.setState((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
[trimmedValue]: results,
|
||||
},
|
||||
this.setState({
|
||||
previousValue: trimmedValue,
|
||||
}));
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Error searching for link", err);
|
||||
}
|
||||
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateLink = async (title: string, nested?: boolean) => {
|
||||
this.discardInputValue = true;
|
||||
const { onCreateLink } = this.props;
|
||||
|
||||
title = title.trim();
|
||||
if (title.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateLink) {
|
||||
return onCreateLink(title, nested);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveLink = (): void => {
|
||||
this.discardInputValue = true;
|
||||
|
||||
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
handleSelectLink =
|
||||
(url: string, title: string) => (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.save(url, title);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
moveSelectionToEnd = () => {
|
||||
const { to, view } = this.props;
|
||||
const { state, dispatch } = view;
|
||||
@@ -305,47 +186,23 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
get results() {
|
||||
const { value } = this.state;
|
||||
return (
|
||||
this.state.results[value.trim()] ||
|
||||
this.state.results[this.state.previousValue] ||
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dictionary } = this.props;
|
||||
const { value, selectedIndex } = this.state;
|
||||
const results = this.results;
|
||||
const looksLikeUrl = value.match(/^https?:\/\//i);
|
||||
const suggestedLinkTitle = this.suggestedLinkTitle;
|
||||
const { view, dictionary } = this.props;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
const showCreateLink =
|
||||
!!this.props.onCreateLink &&
|
||||
!(suggestedLinkTitle === this.initialValue) &&
|
||||
suggestedLinkTitle.length > 0 &&
|
||||
!looksLikeUrl;
|
||||
|
||||
const hasResults =
|
||||
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
showCreateLink
|
||||
? dictionary.findOrCreateDoc
|
||||
: dictionary.searchOrPasteLink
|
||||
}
|
||||
placeholder={dictionary.enterLink}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
onChange={this.handleSearch}
|
||||
onFocus={this.handleSearch}
|
||||
autoFocus={this.href === ""}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -355,75 +212,13 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
{isInternal ? <ArrowIcon /> : <OpenIcon />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
<SearchResults
|
||||
ref={this.resultsRef}
|
||||
$hasResults={hasResults}
|
||||
role="menu"
|
||||
>
|
||||
<ResizingHeightContainer>
|
||||
{hasResults && (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<LinkSearchResult
|
||||
key={result.url}
|
||||
title={result.title}
|
||||
subtitle={result.subtitle}
|
||||
icon={result.icon ?? <DocumentIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(index)}
|
||||
onClick={this.handleSelectLink(result.url, result.title)}
|
||||
selected={index === selectedIndex}
|
||||
containerRef={this.resultsRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showCreateLink && (
|
||||
<>
|
||||
<LinkSearchResult
|
||||
key="create"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(results.length)}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length === selectedIndex}
|
||||
/>
|
||||
<LinkSearchResult
|
||||
key="create-nested"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewChildDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() =>
|
||||
this.handleFocusLink(results.length + 1)
|
||||
}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle, true);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length + 1 === selectedIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</SearchResults>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -434,29 +229,4 @@ const Wrapper = styled(Flex)`
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
|
||||
clip-path: inset(0px -100px -100px -100px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
|
||||
max-height: 240px;
|
||||
${hideScrollbars()}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 40px;
|
||||
border-radius: 0;
|
||||
max-height: 50vh;
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default LinkEditor;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
icon: React.ReactNode;
|
||||
selected: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
function LinkSearchResult({
|
||||
title,
|
||||
subtitle,
|
||||
containerRef,
|
||||
selected,
|
||||
icon,
|
||||
...rest
|
||||
}: Props) {
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (selected && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent !== containerRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[containerRef, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={ref}
|
||||
compact={!subtitle}
|
||||
selected={selected}
|
||||
role="menuitem"
|
||||
{...rest}
|
||||
>
|
||||
<IconWrapper selected={selected}>{icon}</IconWrapper>
|
||||
<Content>
|
||||
<Title title={title}>{title}</Title>
|
||||
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
|
||||
</Content>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span<{ selected: boolean }>`
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
svg {
|
||||
fill: ${s("accentText")};
|
||||
color: ${s("accentText")};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const ListItem = styled.div<{
|
||||
selected: boolean;
|
||||
compact: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 6px;
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
background: ${(props) => (props.selected ? s("accent") : "transparent")};
|
||||
font-family: ${s("fontFamily")};
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
|
||||
height: ${(props) => (props.compact ? "28px" : "auto")};
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.div<{
|
||||
selected: boolean;
|
||||
}>`
|
||||
${ellipsis()}
|
||||
font-size: 13px;
|
||||
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
|
||||
`;
|
||||
|
||||
export default LinkSearchResult;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function isActive(view: EditorView, active: boolean): boolean {
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const paragraph = view.domAtPos(selection.from);
|
||||
return active && !!paragraph.node;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function LinkToolbar({
|
||||
onCreateLink,
|
||||
onSearchLink,
|
||||
onClickLink,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { view } = useEditor();
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("mousedown", (event: Event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
menuRef.current &&
|
||||
menuRef.current.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
const handleOnCreateLink = React.useCallback(
|
||||
async (title: string, nested?: boolean) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}#${title}…`;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
},
|
||||
[onCreateLink, onClose, view, dictionary]
|
||||
);
|
||||
|
||||
const handleOnSelectLink = React.useCallback(
|
||||
({
|
||||
href,
|
||||
title,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
},
|
||||
[onClose, view]
|
||||
);
|
||||
|
||||
const { selection } = view.state;
|
||||
const active = isActive(view, rest.isActive);
|
||||
|
||||
return (
|
||||
<FloatingToolbar ref={menuRef} active={active} width={336}>
|
||||
{active && (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
from={selection.from}
|
||||
to={selection.to}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
onRemoveLink={onClose}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={onSearchLink}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { DocumentsSection, UserSection } from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -19,9 +24,6 @@ import SuggestionsMenu, {
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
name: string;
|
||||
user: User;
|
||||
appendSpace: boolean;
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
@@ -33,24 +35,31 @@ interface MentionItem extends MenuItem {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<MentionItem>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, users } = useStores();
|
||||
const { auth, documents, users } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const { data, loading, request } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
documentId
|
||||
? users.fetchPage({ id: documentId, query: search })
|
||||
: Promise.resolve([]),
|
||||
[users, documentId, search]
|
||||
)
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
const { loading, request } = useRequest<{
|
||||
documents: Document[];
|
||||
users: User[];
|
||||
}>(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
return {
|
||||
documents: res.data.documents.map(documents.add),
|
||||
users: res.data.users.map(users.add),
|
||||
};
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -60,28 +69,97 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
}, [request, isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && !loading) {
|
||||
const items = data.map((user) => ({
|
||||
name: "mention",
|
||||
user,
|
||||
title: user.name,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId: auth.currentUserId ?? undefined,
|
||||
label: user.name,
|
||||
},
|
||||
}));
|
||||
if (actorId && !loading) {
|
||||
const items = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon value={doc.icon} color={doc.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: v4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [auth.currentUserId, loading, data]);
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -118,28 +196,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
{...rest}
|
||||
isActive={isActive}
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
icon={
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={item.user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
> & {
|
||||
pastedText: string;
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
@@ -1,7 +1,6 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
|
||||
type Props = {
|
||||
@@ -37,12 +35,10 @@ type Props = {
|
||||
canUpdate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function useIsActive(state: EditorState) {
|
||||
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
};
|
||||
}, [isActive, previousIsActive, readOnly, view]);
|
||||
|
||||
const handleOnCreateLink = async (
|
||||
title: string,
|
||||
nested?: boolean
|
||||
): Promise<void> => {
|
||||
const { onCreateLink } = props;
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from === to) {
|
||||
// Do not display a selection toolbar for collapsed selections
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}${title}…`;
|
||||
const markType = state.schema.marks.link;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.removeMark(from, to, markType)
|
||||
.addMark(from, to, markType.create({ href }))
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnSelectLink = ({
|
||||
href,
|
||||
from,
|
||||
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
|
||||
props;
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import commandScore from "command-score";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
};
|
||||
|
||||
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { view, commands } = useEditor();
|
||||
const { view, commands, props: editorProps } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
@@ -230,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (command) {
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
commands[`create${capitalize(item.name)}`](attrs);
|
||||
@@ -250,6 +255,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
case "link":
|
||||
insertNode({
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
@@ -264,7 +279,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
insertNode(item);
|
||||
}
|
||||
},
|
||||
[insertNode]
|
||||
[editorProps, props, insertNode]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
@@ -414,11 +429,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding menu items
|
||||
if (
|
||||
item.name &&
|
||||
!commands[item.name] &&
|
||||
!commands[`create${capitalize(item.name)}`]
|
||||
!commands[`create${capitalize(item.name)}`] &&
|
||||
item.name !== "noop"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -445,16 +465,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
filtered
|
||||
.map((item) => ({
|
||||
orderBy(
|
||||
filtered.map((item) => ({
|
||||
item,
|
||||
section:
|
||||
"section" in item && item.section && "priority" in item.section
|
||||
? (item.section.priority as number) ?? 0
|
||||
: 0,
|
||||
priority: "priority" in item ? item.priority : 0,
|
||||
score:
|
||||
searchInput && item.title
|
||||
? commandScore(item.title, searchInput)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item)
|
||||
})),
|
||||
["section", "priority", "score"],
|
||||
["desc", "desc", "desc"]
|
||||
).map(({ item }) => item)
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
@@ -555,6 +581,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
let previousHeading: string | undefined;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -614,18 +641,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
return response;
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
|
||||
@@ -12,7 +12,7 @@ export type Props = {
|
||||
/** Callback when the item is clicked */
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** An optional subtitle for the item */
|
||||
@@ -56,7 +56,7 @@ function SuggestionsMenuItem({
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>{subtitle}</Subtitle>}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,10 @@ import BlockMenu from "../components/BlockMenu";
|
||||
export default class BlockMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
trigger: "/",
|
||||
allowSpaces: false,
|
||||
requireSearchTerm: false,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
];
|
||||
}
|
||||
|
||||
private handleClose = action((insertNewLine: boolean) => {
|
||||
const { view } = this.editor;
|
||||
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => {
|
||||
const { props, view } = this.editor;
|
||||
const { props } = this.editor;
|
||||
|
||||
return (
|
||||
<BlockMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action((insertNewLine) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
})}
|
||||
onClose={this.handleClose}
|
||||
uploadFile={props.uploadFile}
|
||||
onFileUploadStart={props.onFileUploadStart}
|
||||
onFileUploadStop={props.onFileUploadStop}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
* copying text for nodes that do not inherently have text children by defining
|
||||
* a `toPlainText` method in the node spec.
|
||||
* copying text including the markdown formatting.
|
||||
*/
|
||||
export default class ClipboardTextSerializer extends Extension {
|
||||
get name() {
|
||||
@@ -14,20 +11,16 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const textSerializers = getTextSerializers(this.editor.schema);
|
||||
const serializer = this.editor.extensions.serializer();
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: () => {
|
||||
const { doc, selection } = this.editor.view.state;
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
|
||||
return textBetween(doc, from, to, textSerializers);
|
||||
},
|
||||
clipboardTextSerializer: (slice) =>
|
||||
serializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -19,11 +19,10 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
: false;
|
||||
|
||||
return {
|
||||
openRegex: new RegExp(
|
||||
`(?:^|\\s|\\():([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
|
||||
),
|
||||
closeRegex:
|
||||
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
|
||||
trigger: ":",
|
||||
allowSpaces: false,
|
||||
requireSearchTerm: languageIsUsingColon,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<EmojiMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -7,9 +7,10 @@ import MentionMenu from "../components/MentionMenu";
|
||||
export default class MentionMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
trigger: "@",
|
||||
allowSpaces: true,
|
||||
requireSearchTerm: false,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<MentionMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -103,6 +105,11 @@ export default class Multiplayer extends Extension {
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
EditorState,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { IconType } from "@shared/types";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
@@ -60,13 +72,26 @@ function parseSingleIframeSrc(html: string) {
|
||||
}
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
pastedText: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
pastedText: "",
|
||||
});
|
||||
|
||||
get name() {
|
||||
return "paste-handler";
|
||||
}
|
||||
|
||||
private key = new PluginKey(this.name);
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: this.key,
|
||||
props: {
|
||||
transformPastedHTML(html: string) {
|
||||
if (isDropboxPaper(html)) {
|
||||
@@ -106,23 +131,6 @@ export default class PasteHandler extends Extension {
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
|
||||
function insertLink(href: string, title?: string) {
|
||||
// If it's not an embed and there is no text selected – just go ahead and insert the
|
||||
// link directly
|
||||
const transaction = view.state.tr
|
||||
.insertText(
|
||||
title ?? href,
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + (title ?? href).length,
|
||||
state.schema.marks.link.create({ href })
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
@@ -140,6 +148,13 @@ export default class PasteHandler extends Extension {
|
||||
const supportsCodeMark = !!state.schema.marks.code_inline;
|
||||
|
||||
if (!this.shiftKey) {
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
@@ -151,28 +166,6 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
if (
|
||||
embeds &&
|
||||
this.editor.commands.embed &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
@@ -185,26 +178,42 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
if (state.schema.nodes.mention) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state.schema.nodes.mention.create({
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: v4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) ===
|
||||
IconType.Emoji;
|
||||
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
this.insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -247,13 +256,6 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
@@ -306,10 +308,170 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, set) => {
|
||||
let mapping = tr.mapping;
|
||||
|
||||
// See if the transaction adds or removes any placeholders
|
||||
const meta = tr.getMeta(this.key);
|
||||
const hasDecorations = set.find().length;
|
||||
|
||||
// We only want a single paste placeholder at a time, so if we're adding a new
|
||||
// placeholder we can just return a new DecorationSet and avoid mapping logic.
|
||||
if (meta?.add) {
|
||||
const { from, to, id } = meta.add;
|
||||
const decorations = [
|
||||
Decoration.inline(
|
||||
from,
|
||||
to,
|
||||
{
|
||||
class: "paste-placeholder",
|
||||
},
|
||||
{
|
||||
id,
|
||||
}
|
||||
),
|
||||
];
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
|
||||
try {
|
||||
mapping = recreateTransform(tr.before, tr.doc, {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
set = set.map(mapping, tr.doc);
|
||||
|
||||
if (meta?.remove) {
|
||||
const { id } = meta.remove;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return set.remove(decorations);
|
||||
}
|
||||
|
||||
return set;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Tracks whether the Shift key is currently held down */
|
||||
private shiftKey = false;
|
||||
|
||||
private showPasteMenu = action((text: string) => {
|
||||
this.state.pastedText = text;
|
||||
this.state.open = true;
|
||||
});
|
||||
|
||||
private hidePasteMenu = action(() => {
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
private insertLink(href: string, title?: string) {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const { from } = state.selection;
|
||||
const to = from + (title ?? href).length;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.insertText(title ?? href, state.selection.from, state.selection.to)
|
||||
.addMark(from, to, state.schema.marks.link.create({ href }))
|
||||
.setMeta(this.key, { add: { from, to, id: href } });
|
||||
view.dispatch(transaction);
|
||||
this.showPasteMenu(href);
|
||||
}
|
||||
|
||||
private insertEmbed = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
|
||||
this.editor.commands.embed({
|
||||
href: this.state.pastedText,
|
||||
});
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
view.dispatch(
|
||||
state.tr.setMeta(this.key, {
|
||||
remove: { id: this.state.pastedText },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private findPlaceholder = (
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null => {
|
||||
const decos = this.key.getState(state) as DecorationSet;
|
||||
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found?.length ? [found[0].from, found[0].to] : null;
|
||||
};
|
||||
|
||||
private handleSelect = (item: MenuItem) => {
|
||||
switch (item.name) {
|
||||
case "noop": {
|
||||
this.hidePasteMenu();
|
||||
this.removePlaceholder();
|
||||
break;
|
||||
}
|
||||
case "embed": {
|
||||
this.hidePasteMenu();
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
"Mod-z": () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<PasteMenu
|
||||
rtl={rtl}
|
||||
embeds={this.editor.props.embeds}
|
||||
pastedText={this.state.pastedText}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={this.hidePasteMenu}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { action, observable } from "mobx";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, Schema } from "prosemirror-model";
|
||||
@@ -6,35 +7,37 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
type Options = {
|
||||
enabledInCode: boolean;
|
||||
trigger: string;
|
||||
allowSpaces: boolean;
|
||||
requireSearchTerm: boolean;
|
||||
};
|
||||
|
||||
export default class Suggestion extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
});
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [new SuggestionsMenuPlugin(this.options, this.state)];
|
||||
return [
|
||||
new SuggestionsMenuPlugin(this.options, this.state, this.openRegex),
|
||||
];
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: action((state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const textBefore = $from.parent.textBetween(
|
||||
Math.max(0, $from.parentOffset - 500), // 500 = max match
|
||||
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
|
||||
null,
|
||||
"\ufffc"
|
||||
);
|
||||
|
||||
if (this.options.openRegex.test(textBefore)) {
|
||||
return false;
|
||||
Space: action(() => {
|
||||
if (this.state.open && !this.options.allowSpaces) {
|
||||
this.state.open = false;
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
@@ -42,7 +45,7 @@ export default class Suggestion extends Extension {
|
||||
|
||||
inputRules = (_options: { type: NodeType; schema: Schema }) => [
|
||||
new InputRule(
|
||||
this.options.openRegex,
|
||||
this.openRegex,
|
||||
action((state: EditorState, match: RegExpMatchArray) => {
|
||||
const { parent } = state.selection.$from;
|
||||
if (
|
||||
@@ -51,21 +54,23 @@ export default class Suggestion extends Extension {
|
||||
parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode)
|
||||
) {
|
||||
this.state.open = true;
|
||||
if (match[0].length <= 2) {
|
||||
this.state.open = true;
|
||||
}
|
||||
this.state.query = match[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
),
|
||||
new InputRule(
|
||||
this.options.closeRegex,
|
||||
action((_: EditorState, match: RegExpMatchArray) => {
|
||||
if (match) {
|
||||
this.state.open = false;
|
||||
this.state.query = "";
|
||||
}
|
||||
return null;
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
protected openRegex: RegExp;
|
||||
|
||||
protected state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
|
||||
export const withUIExtensions = (nodes: Nodes) => [
|
||||
...nodes,
|
||||
SmartText,
|
||||
PasteHandler,
|
||||
ClipboardTextSerializer,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
];
|
||||
+8
-48
@@ -1,5 +1,6 @@
|
||||
/* global File Promise */
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { observable } from "mobx";
|
||||
import { Observer } from "mobx-react";
|
||||
import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
@@ -39,18 +40,18 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { ComponentProps, EventType } from "@shared/editor/types";
|
||||
import { ComponentProps } from "@shared/editor/types";
|
||||
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
@@ -117,13 +118,11 @@ export type Props = {
|
||||
/** Callback when a file upload ends */
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
/** Callback when user searches for documents from link insert interface */
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
/** Callback when user presses any key with document focused */
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
@@ -147,8 +146,6 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** If the insert link toolbar is visible */
|
||||
linkToolbarOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -178,7 +175,6 @@ export class Editor extends React.PureComponent<
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
linkToolbarOpen: false,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -199,7 +195,7 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -207,11 +203,6 @@ export class Editor extends React.PureComponent<
|
||||
events = new EventEmitter();
|
||||
mutationObserver?: MutationObserver;
|
||||
|
||||
public constructor(props: Props & ThemeProps<DefaultTheme>) {
|
||||
super(props);
|
||||
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
|
||||
}
|
||||
|
||||
/**
|
||||
* We use componentDidMount instead of constructor as the init method requires
|
||||
* that the dom is already mounted.
|
||||
@@ -273,7 +264,6 @@ export class Editor extends React.PureComponent<
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.linkToolbarOpen &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
this.isBlurred = true;
|
||||
@@ -282,9 +272,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused ||
|
||||
this.state.linkToolbarOpen ||
|
||||
this.state.selectionToolbarOpen)
|
||||
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
|
||||
) {
|
||||
this.isBlurred = false;
|
||||
this.props.onFocus?.();
|
||||
@@ -783,23 +771,6 @@ export class Editor extends React.PureComponent<
|
||||
}));
|
||||
};
|
||||
|
||||
private handleOpenLinkToolbar = () => {
|
||||
if (this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: true,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseLinkToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
@@ -837,18 +808,7 @@ export class Editor extends React.PureComponent<
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && this.view && this.marks.link && (
|
||||
<LinkToolbar
|
||||
isActive={this.state.linkToolbarOpen}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onClose={this.handleCloseLinkToolbar}
|
||||
/>
|
||||
)}
|
||||
{this.widgets &&
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState, useLayoutEffect } from "react";
|
||||
|
||||
export default function useComponentSize(
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach(({ target }) => {
|
||||
if (
|
||||
size.width !== target.clientWidth ||
|
||||
size.height !== target.clientHeight
|
||||
) {
|
||||
setSize({ width: target.clientWidth, height: target.clientHeight });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [ref, size.height, size.width]);
|
||||
|
||||
return size;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export default function useDictionary() {
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
file: t("File attachment"),
|
||||
findOrCreateDoc: `${t("Paste a link, search, or create")}…`,
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import useStores from "./useStores";
|
||||
|
||||
type Params = {
|
||||
/** The share ID of the document being viewed, if any */
|
||||
@@ -12,8 +13,9 @@ type Params = {
|
||||
|
||||
export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
const history = useHistory();
|
||||
const { documents } = useStores();
|
||||
const handleClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
(href: string, event?: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
window.location.href = href;
|
||||
@@ -49,13 +51,20 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
navigateTo = sharedDocumentPath(shareId, navigateTo);
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're navigating to a share link from a non-share link then open it in a new tab
|
||||
if (!shareId && navigateTo.startsWith("/s/")) {
|
||||
window.open(href, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModKey(event) && !event.shiftKey) {
|
||||
if (!event || (!isModKey(event) && !event.shiftKey)) {
|
||||
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
|
||||
} else {
|
||||
window.open(navigateTo, "_blank");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
/**
|
||||
* Hook to calculate text statistics
|
||||
* @param text The string to calculate statistics for
|
||||
* @param selectedText A substring of the text to calculate statistics for
|
||||
* @returns An object containing total and selected statistics
|
||||
*/
|
||||
export function useTextStats(text: string, selectedText: string = "") {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
@@ -3,14 +3,13 @@ import { MoreIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { toggleViewerInsights } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { hover } from "~/styles";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
const InsightsMenu: React.FC = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MoreIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -12,7 +12,6 @@ import { navigateToNotificationSettings } from "~/actions/definitions/navigation
|
||||
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { hover } from "~/styles";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
const NotificationMenu: React.FC = () => {
|
||||
|
||||
@@ -3,12 +3,12 @@ import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
/** The user chosen name of the API key. */
|
||||
/** The human-readable name of this API key */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A list of scopes that this API key has access to. If empty, the key has full access. */
|
||||
@Field
|
||||
@observable
|
||||
scope?: string[];
|
||||
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -181,6 +181,11 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.isArchived && !this.isDeleted;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasDocuments() {
|
||||
return !!this.documents?.length;
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -258,6 +263,36 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the document identified by the given id to the collection in
|
||||
* memory. Does not add the document to the database or store.
|
||||
*
|
||||
* @param document The document to add.
|
||||
* @param parentDocumentId The id of the document to add the new document to.
|
||||
*/
|
||||
@action
|
||||
addDocument(document: Document, parentDocumentId?: string) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentDocumentId) {
|
||||
this.documents.unshift(document.asNavigationNode);
|
||||
return;
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === parentDocumentId) {
|
||||
node.children = [document.asNavigationNode, ...(node.children ?? [])];
|
||||
} else {
|
||||
travelNodes(node.children);
|
||||
}
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
|
||||
@@ -31,6 +31,7 @@ import View from "./View";
|
||||
import ArchivableModel from "./base/ArchivableModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
type SaveOptions = JSONObject & {
|
||||
publish?: boolean;
|
||||
@@ -38,7 +39,7 @@ type SaveOptions = JSONObject & {
|
||||
autosave?: boolean;
|
||||
};
|
||||
|
||||
export default class Document extends ArchivableModel {
|
||||
export default class Document extends ArchivableModel implements Searchable {
|
||||
static modelName = "Document";
|
||||
|
||||
constructor(fields: Record<string, any>, store: DocumentsStore) {
|
||||
@@ -85,6 +86,11 @@ export default class Document extends ArchivableModel {
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the original data source, if imported.
|
||||
*/
|
||||
|
||||
+7
-1
@@ -18,8 +18,9 @@ import Group from "./Group";
|
||||
import UserMembership from "./UserMembership";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
class User extends ParanoidModel {
|
||||
class User extends ParanoidModel implements Searchable {
|
||||
static modelName = "User";
|
||||
|
||||
@Field
|
||||
@@ -62,6 +63,11 @@ class User extends ParanoidModel {
|
||||
@observable
|
||||
isSuspended: boolean;
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name, this.email].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get initial(): string {
|
||||
return (this.name ? this.name[0] : "?").toUpperCase();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* An interface for objects that can be searched.
|
||||
*/
|
||||
export interface Searchable {
|
||||
/** The content to be used for search */
|
||||
get searchContent(): string | string[];
|
||||
}
|
||||
@@ -72,8 +72,7 @@ function AuthenticatedRoutes() {
|
||||
<Redirect exact from="/templates" to={settingsPath("templates")} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path="/collection/:id/:tab?" component={Collection} />
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
|
||||
function ApiKeyNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState("");
|
||||
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
|
||||
ExpiryType.Week
|
||||
);
|
||||
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = React.useCallback((event) => {
|
||||
setScope(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleExpiryTypeChange = React.useCallback((value: string) => {
|
||||
const expiry = value as ExpiryType;
|
||||
setExpiryType(expiry);
|
||||
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
await apiKeys.create({
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
scope: scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
toast.success(
|
||||
t(
|
||||
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, name, expiresAt, onSubmit, apiKeys]
|
||||
[t, name, scope, expiresAt, onSubmit, apiKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Development")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
minLength={ApiKeyValidation.minNameLength}
|
||||
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Scopes")}
|
||||
placeholder="documents.info"
|
||||
onChange={handleScopeChange}
|
||||
value={scope}
|
||||
flex
|
||||
/>
|
||||
<Text type="secondary" size="small" as="p">
|
||||
{t(
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<StyledExpirySelect
|
||||
ariaLabel={t("Expiration")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -44,10 +44,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
groupMemberships.fetchPage(options),
|
||||
]);
|
||||
if (users[PAGINATION_SYMBOL]) {
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total);
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total ?? 0);
|
||||
}
|
||||
if (groups[PAGINATION_SYMBOL]) {
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total);
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total ?? 0);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -101,12 +101,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
>
|
||||
<Fade>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
users={sortBy(collectionUsers, "lastActiveAt")}
|
||||
overflow={overflow}
|
||||
limit={limit}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar model={item} size={AvatarSize.Large} />
|
||||
)}
|
||||
/>
|
||||
</Fade>
|
||||
</NudeButton>
|
||||
|
||||
+189
-103
@@ -3,15 +3,16 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -22,7 +23,6 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
@@ -30,12 +30,13 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -49,7 +50,16 @@ import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
function CollectionScene() {
|
||||
enum CollectionPath {
|
||||
Overview = "overview",
|
||||
Recent = "recent",
|
||||
Updated = "updated",
|
||||
Published = "published",
|
||||
Old = "old",
|
||||
Alphabetical = "alphabetical",
|
||||
}
|
||||
|
||||
const CollectionScene = observer(function _CollectionScene() {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
@@ -60,17 +70,26 @@ function CollectionScene() {
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const { pins, count } = usePinnedDocuments(id, collection?.id);
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription
|
||||
? CollectionPath.Overview
|
||||
: CollectionPath.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
);
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
async (icon: string | null, color: string | null) => {
|
||||
await collection?.save({ icon, color });
|
||||
},
|
||||
(icon: string | null, color: string | null) =>
|
||||
collection?.save({ icon, color }),
|
||||
[collection]
|
||||
);
|
||||
|
||||
@@ -120,6 +139,8 @@ function CollectionScene() {
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
|
||||
const fallbackIcon = collection ? (
|
||||
<Icon
|
||||
value={collection.icon ?? "collection"}
|
||||
@@ -128,11 +149,17 @@ function CollectionScene() {
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const tabProps = (path: CollectionPath) => ({
|
||||
exact: true,
|
||||
onClick: () => setCollectionTab(path),
|
||||
to: {
|
||||
pathname: collectionPath(collection!.path, path),
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
|
||||
return collection ? (
|
||||
<Scene
|
||||
// Forced mount prevents animation of pinned documents when navigating
|
||||
// _between_ collections, speeds up perceived performance.
|
||||
key={collection.id}
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
left={
|
||||
@@ -196,105 +223,156 @@ function CollectionScene() {
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
{!collection.isArchived && (
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
<Tabs>
|
||||
{hasOverview && (
|
||||
<Tab {...tabProps(CollectionPath.Overview)}>
|
||||
{t("Overview")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : !collection.isArchived ? (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
|
||||
{!collection.isArchived && (
|
||||
<>
|
||||
<Tab {...tabProps(CollectionPath.Updated)}>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Published)}>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Old)}>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Alphabetical)}>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(collection!.path, collectionTab),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<CollectionDescription collection={collection} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : !collection.isArchived ? (
|
||||
<>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Alphabetical
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Old)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Published
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect to={collectionPath(collection.path, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Updated
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
exact
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
) : (
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Recent)}
|
||||
exact
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.archivedInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
heading={<Subheading sticky>{t("Documents")}</Subheading>}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
@@ -305,8 +383,8 @@ function CollectionScene() {
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
)}
|
||||
</Switch>
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
</DropToImport>
|
||||
@@ -319,7 +397,15 @@ function CollectionScene() {
|
||||
<PlaceholderList count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const KeyedCollection = () => {
|
||||
const params = useParams<{ id?: string }>();
|
||||
|
||||
// Forced mount prevents animation of pinned documents when navigating
|
||||
// _between_ collections, speeds up perceived performance.
|
||||
return <CollectionScene key={params.id} />;
|
||||
};
|
||||
|
||||
const Documents = styled.div`
|
||||
position: relative;
|
||||
@@ -337,4 +423,4 @@ const CollectionHeading = styled(Heading)`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(CollectionScene);
|
||||
export default KeyedCollection;
|
||||
|
||||
@@ -25,6 +25,7 @@ import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||
@@ -109,6 +110,16 @@ function SharedDocumentScene(props: Props) {
|
||||
: undefined;
|
||||
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shareId) {
|
||||
client.setShareId(shareId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
client.setShareId(undefined);
|
||||
};
|
||||
}, [shareId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user) {
|
||||
void changeLanguage(detectLanguage(), i18n);
|
||||
@@ -206,33 +217,31 @@ function SharedDocumentScene(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SharedDocument = ({
|
||||
shareId,
|
||||
response,
|
||||
}: {
|
||||
shareId?: string;
|
||||
response: Response;
|
||||
}) => {
|
||||
const { setDocument } = useDocumentContext();
|
||||
const SharedDocument = observer(
|
||||
({ shareId, response }: { shareId?: string; response: Response }) => {
|
||||
const { hasHeadings, setDocument } = useDocumentContext();
|
||||
|
||||
if (!response.document) {
|
||||
return null;
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tocPosition = hasHeadings
|
||||
? response.team?.tocPosition ?? TOCPosition.Left
|
||||
: false;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
const Content = styled(Text)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -21,7 +21,6 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThreadItem from "./CommentThreadItem";
|
||||
@@ -55,7 +54,7 @@ function CommentThread({
|
||||
collapseThreshold = 5,
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -150,9 +149,6 @@ function CommentThread({
|
||||
limit={limit}
|
||||
overflow={overflow}
|
||||
size={AvatarSize.Medium}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar size={AvatarSize.Medium} model={item} />
|
||||
)}
|
||||
/>
|
||||
</ShowMore>
|
||||
);
|
||||
@@ -166,7 +162,7 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
if (focusedOnMount) {
|
||||
if (scrollOnMount) {
|
||||
setTimeout(() => {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
@@ -210,7 +206,7 @@ function CommentThread({
|
||||
isMarkVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
}, [focused, scrollOnMount, thread.id]);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
|
||||
@@ -9,7 +9,7 @@ import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
@@ -28,7 +28,6 @@ import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
|
||||
@@ -244,7 +243,7 @@ function CommentThreadItem({
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
$rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -265,7 +264,7 @@ function CommentThreadItem({
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
$rounded
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -303,7 +302,7 @@ const ResolveButton = ({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
$rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
@@ -341,10 +340,10 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
const Action = styled.span<{ $rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
props.$rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -46,7 +47,10 @@ type Children = (options: {
|
||||
revision: Revision | undefined;
|
||||
abilities: Record<string, boolean>;
|
||||
readOnly: boolean;
|
||||
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
|
||||
onCreateLink: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
}) => React.ReactNode;
|
||||
|
||||
@@ -143,7 +147,7 @@ function DataLoader({ match, children }: Props) {
|
||||
}, [document?.id, document?.isDeleted, revisionId, views]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string, nested?: boolean) => {
|
||||
async (params: Properties<Document>, nested?: boolean) => {
|
||||
if (!document) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
@@ -152,8 +156,8 @@ function DataLoader({ match, children }: Props) {
|
||||
{
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
publish: document.isDraft ? undefined : true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user