mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a5deb6a5 | |||
| f2ebac066a | |||
| c33fad966a | |||
| 5e5a6ec189 | |||
| acae1aecd7 | |||
| d40a3a77e0 | |||
| dbcf23d3cd | |||
| 2ee8167cdc | |||
| 8eb73b4079 | |||
| 8dfe635427 | |||
| ec8521461d | |||
| d76cb3bd17 | |||
| 05b3a39f4d | |||
| 8f3139da7a | |||
| 741f9aa796 | |||
| 338b10658b | |||
| 97284780f9 | |||
| d3683413af | |||
| 47a067cd19 | |||
| 11f744e7d6 | |||
| 1d2292d2a7 | |||
| 5eaab0e14a | |||
| 22ff9d394b | |||
| a2a84102a6 | |||
| 908c147e3d | |||
| 6d0270ae37 | |||
| abf49bc04d | |||
| 58732ddd9b | |||
| 684d622754 | |||
| d11e15b360 | |||
| 8429c68b7a | |||
| c4e3786291 | |||
| e6626e1b1a | |||
| d3964875ff | |||
| 5156b92d6a | |||
| 4648a405bb | |||
| 5f8a754cd9 | |||
| ad7d808704 | |||
| 59a8d801a4 | |||
| a815f0a12c | |||
| 5ed5c24498 | |||
| a77b24cf01 | |||
| 08efb34c29 | |||
| df3dde951a | |||
| d10149ccb3 |
@@ -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) =>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { IconType } from "@shared/types";
|
||||
@@ -14,7 +15,6 @@ 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";
|
||||
|
||||
@@ -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 Icon from "@shared/components/Icon";
|
||||
import { s } 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";
|
||||
|
||||
@@ -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 lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -50,76 +41,13 @@ 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, {
|
||||
@@ -263,7 +191,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 || ""}
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s } 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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -5,13 +5,13 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,7 +14,6 @@ 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 useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,42 +186,17 @@ 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 { 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}
|
||||
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
<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>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -434,29 +226,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;
|
||||
@@ -40,17 +42,22 @@ 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 { data, 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 +67,92 @@ 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 (data && actorId && !loading) {
|
||||
const items = data.users
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
showBorder={false}
|
||||
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(
|
||||
data.documents.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, data]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -125,21 +196,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
<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}
|
||||
|
||||
@@ -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,
|
||||
@@ -250,6 +253,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 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
insertNode(item);
|
||||
}
|
||||
},
|
||||
[insertNode]
|
||||
[editorProps, props, insertNode]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
@@ -414,6 +427,10 @@ 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 &&
|
||||
@@ -445,16 +462,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 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
let previousHeading: string | undefined;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -614,18 +638,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { IconType } from "@shared/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";
|
||||
@@ -185,15 +186,31 @@ 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);
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
+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 &&
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} 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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,7 +45,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
|
||||
@@ -90,8 +90,10 @@ type Props = WithTranslation &
|
||||
readOnly: boolean;
|
||||
shareId?: string;
|
||||
tocPosition?: TOCPosition;
|
||||
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -571,7 +573,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
onSynced={this.onSynced}
|
||||
onFileUploadStart={this.onFileUploadStart}
|
||||
onFileUploadStop={this.onFileUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.handleChangeTitle}
|
||||
onChangeIcon={this.handleChangeIcon}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
@@ -19,7 +20,6 @@ import { isModKey } from "@shared/utils/keyboard";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
@@ -24,7 +25,6 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import Icon from "~/components/Icon";
|
||||
import Star from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import useComponentSize from "@shared/editor/components/hooks/useComponentSize";
|
||||
import useComponentSize from "@shared/hooks/useComponentSize";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { IconType, NavigationNode } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -825,7 +825,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined =>
|
||||
find(this.orderedData, (doc) => url.endsWith(doc.urlId));
|
||||
find(
|
||||
this.orderedData,
|
||||
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
|
||||
);
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return document.collectionId
|
||||
|
||||
+35
-17
@@ -54,6 +54,8 @@ export default abstract class Store<T extends Model> {
|
||||
@observable
|
||||
isLoaded = false;
|
||||
|
||||
requests: Map<string, Promise<any>> = new Map();
|
||||
|
||||
model: typeof Model;
|
||||
|
||||
modelName: string;
|
||||
@@ -302,27 +304,43 @@ export default abstract class Store<T extends Model> {
|
||||
if (item && !options.force) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (this.requests.has(id)) {
|
||||
return this.requests.get(id);
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, {
|
||||
id,
|
||||
});
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
client
|
||||
.post(`/${this.apiEndpoint}.info`, {
|
||||
id,
|
||||
})
|
||||
.then((res) =>
|
||||
runInAction(`info#${this.modelName}`, () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.addPolicies(res.policies);
|
||||
resolve(this.add(accessor(res)));
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
return runInAction(`info#${this.modelName}`, () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(accessor(res));
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
this.remove(id);
|
||||
}
|
||||
reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.requests.delete(id);
|
||||
this.isFetching = false;
|
||||
});
|
||||
});
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
this.requests.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -36,10 +36,16 @@ const fetchWithRetry = retry(fetch);
|
||||
class ApiClient {
|
||||
baseUrl: string;
|
||||
|
||||
shareId?: string;
|
||||
|
||||
constructor(options: Options = {}) {
|
||||
this.baseUrl = options.baseUrl || "/api";
|
||||
}
|
||||
|
||||
setShareId = (shareId: string | undefined) => {
|
||||
this.shareId = shareId;
|
||||
};
|
||||
|
||||
fetch = async <T = any>(
|
||||
path: string,
|
||||
method: string,
|
||||
@@ -51,6 +57,14 @@ class ApiClient {
|
||||
let urlToFetch;
|
||||
let isJson;
|
||||
|
||||
if (this.shareId) {
|
||||
// add to data
|
||||
data = {
|
||||
...(data || {}),
|
||||
shareId: this.shareId,
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
if (data) {
|
||||
modifiedPath = `${path}?${data && queryString.stringify(data)}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import * as React from "react";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { Document, Revision } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
@@ -63,12 +64,12 @@ export default class DocumentMentionedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
const currMentions = DocumentHelper.parseMentions(currDoc, {
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
modelId: userId,
|
||||
});
|
||||
const prevMentions = prevDoc
|
||||
? DocumentHelper.parseMentions(prevDoc, {
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
modelId: userId,
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -234,6 +234,17 @@ export class DocumentHelper {
|
||||
return ProsemirrorHelper.parseMentions(node, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a list of document IDs contained in a document or revision
|
||||
*
|
||||
* @param document Document or Revision
|
||||
* @returns An array of identifiers in passed document or revision
|
||||
*/
|
||||
static parseDocumentIds(document: Document | Revision) {
|
||||
const node = DocumentHelper.toProsemirror(document);
|
||||
return ProsemirrorHelper.parseDocumentIds(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a HTML diff between documents or revisions.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { Op } from "sequelize";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { NotificationEventType, MentionType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
User,
|
||||
@@ -82,7 +82,8 @@ export default class NotificationHelper {
|
||||
const mentionedUserIdsInThread = contextComments
|
||||
.flatMap((c) =>
|
||||
ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(c.data)
|
||||
ProsemirrorHelper.toProsemirror(c.data),
|
||||
{ type: MentionType.User }
|
||||
)
|
||||
)
|
||||
.map((mention) => mention.modelId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeepPartial } from "utility-types";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { buildProseMirrorDoc } from "@server/test/factories";
|
||||
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("ProseMirrorHelper", () => {
|
||||
it("should return the paragraph node", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
|
||||
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
@@ -58,7 +58,7 @@ describe("ProseMirrorHelper", () => {
|
||||
it("should return the heading node", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
|
||||
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
@@ -111,7 +111,7 @@ describe("ProseMirrorHelper", () => {
|
||||
it("should return the table node with the mentioned row only", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
|
||||
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
@@ -195,7 +195,7 @@ describe("ProseMirrorHelper", () => {
|
||||
it("should return the checkbox list with the mentioned item only", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
|
||||
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
@@ -275,7 +275,7 @@ describe("ProseMirrorHelper", () => {
|
||||
it("should not return anything when the mention attrs could not be found", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
|
||||
type: "user",
|
||||
type: MentionType.User,
|
||||
label: "test.user",
|
||||
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
|
||||
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
|
||||
@@ -13,8 +13,9 @@ import EditorContainer from "@shared/editor/components/Styles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { schema, parser } from "@server/editor";
|
||||
@@ -37,7 +38,7 @@ export type HTMLOptions = {
|
||||
};
|
||||
|
||||
export type MentionAttrs = {
|
||||
type: string;
|
||||
type: MentionType;
|
||||
label: string;
|
||||
modelId: string;
|
||||
actorId: string | undefined;
|
||||
@@ -165,6 +166,50 @@ export class ProsemirrorHelper {
|
||||
return mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of document IDs referenced through links or mentions in the node.
|
||||
*
|
||||
* @param node The node to parse document IDs from
|
||||
* @returns An array of document IDs
|
||||
*/
|
||||
static parseDocumentIds(doc: Node) {
|
||||
const identifiers: string[] = [];
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
if (
|
||||
node.type.name === "mention" &&
|
||||
node.attrs.type === MentionType.Document &&
|
||||
!identifiers.includes(node.attrs.modelId)
|
||||
) {
|
||||
identifiers.push(node.attrs.modelId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type.name === "text") {
|
||||
// get marks for text nodes
|
||||
node.marks.forEach((mark) => {
|
||||
// any of the marks identifiers?
|
||||
if (mark.type.name === "link") {
|
||||
const slug = parseDocumentSlug(mark.attrs.href);
|
||||
|
||||
// don't return the same link more than once
|
||||
if (slug && !identifiers.includes(slug)) {
|
||||
identifiers.push(slug);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!node.content.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest ancestor block node which contains the mention.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parser } from "@server/editor";
|
||||
import { Backlink } from "@server/models";
|
||||
import { buildDocument } from "@server/test/factories";
|
||||
import BacklinksProcessor from "./BacklinksProcessor";
|
||||
@@ -5,7 +6,7 @@ import BacklinksProcessor from "./BacklinksProcessor";
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
describe("documents.publish", () => {
|
||||
test("should create new backlink records", async () => {
|
||||
it("should create new backlink records", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const document = await buildDocument({
|
||||
text: `[this is a link](${otherDocument.url})`,
|
||||
@@ -29,14 +30,16 @@ describe("documents.publish", () => {
|
||||
expect(backlinks.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should not fail when linked document is destroyed", async () => {
|
||||
it("should not fail when linked document is destroyed", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
await otherDocument.destroy();
|
||||
const document = await buildDocument({
|
||||
version: 0,
|
||||
text: `[ ] checklist item`,
|
||||
});
|
||||
document.text = `[this is a link](${otherDocument.url})`;
|
||||
document.content = parser
|
||||
.parse(`[this is a link](${otherDocument.url})`)
|
||||
?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const processor = new BacklinksProcessor();
|
||||
@@ -59,7 +62,7 @@ describe("documents.publish", () => {
|
||||
});
|
||||
|
||||
describe("documents.update", () => {
|
||||
test("should not fail on a document with no previous revisions", async () => {
|
||||
it("should not fail on a document with no previous revisions", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const document = await buildDocument({
|
||||
text: `[this is a link](${otherDocument.url})`,
|
||||
@@ -84,13 +87,15 @@ describe("documents.update", () => {
|
||||
expect(backlinks.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should not fail when previous revision is different document version", async () => {
|
||||
it("should not fail when previous revision is different document version", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const document = await buildDocument({
|
||||
version: undefined,
|
||||
text: `[ ] checklist item`,
|
||||
});
|
||||
document.text = `[this is a link](${otherDocument.url})`;
|
||||
document.content = parser
|
||||
.parse(`[this is a link](${otherDocument.url})`)
|
||||
?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const processor = new BacklinksProcessor();
|
||||
@@ -112,10 +117,12 @@ describe("documents.update", () => {
|
||||
expect(backlinks.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should create new backlink records", async () => {
|
||||
it("should create new backlink records", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
document.text = `[this is a link](${otherDocument.url})`;
|
||||
document.content = parser
|
||||
.parse(`[this is a link](${otherDocument.url})`)
|
||||
?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const processor = new BacklinksProcessor();
|
||||
@@ -137,7 +144,7 @@ describe("documents.update", () => {
|
||||
expect(backlinks.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should destroy removed backlink records", async () => {
|
||||
it("should destroy removed backlink records", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const yetAnotherDocument = await buildDocument();
|
||||
const document = await buildDocument({
|
||||
@@ -156,9 +163,13 @@ describe("documents.update", () => {
|
||||
data: { title: document.title },
|
||||
ip,
|
||||
});
|
||||
document.text = `First link is gone
|
||||
document.content = parser
|
||||
.parse(
|
||||
`First link is gone
|
||||
|
||||
[this is a another link](${yetAnotherDocument.url})`;
|
||||
[this is a another link](${yetAnotherDocument.url})`
|
||||
)
|
||||
?.toJSON();
|
||||
await document.save();
|
||||
|
||||
await processor.perform({
|
||||
@@ -182,10 +193,12 @@ describe("documents.update", () => {
|
||||
});
|
||||
|
||||
describe("documents.delete", () => {
|
||||
test("should destroy related backlinks", async () => {
|
||||
it("should destroy related backlinks", async () => {
|
||||
const otherDocument = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
document.text = `[this is a link](${otherDocument.url})`;
|
||||
document.content = parser
|
||||
.parse(`[this is a link](${otherDocument.url})`)
|
||||
?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const processor = new BacklinksProcessor();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Op } from "sequelize";
|
||||
import { Document, Backlink } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
|
||||
import parseDocumentIds from "@server/utils/parseDocumentIds";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class BacklinksProcessor extends BaseProcessor {
|
||||
@@ -18,7 +18,7 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const linkIds = parseDocumentIds(document.text);
|
||||
const linkIds = DocumentHelper.parseDocumentIds(document);
|
||||
await Promise.all(
|
||||
linkIds.map(async (linkId) => {
|
||||
const linkedDocument = await Document.findByPk(linkId);
|
||||
@@ -52,7 +52,7 @@ export default class BacklinksProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkIds = parseDocumentIds(document.text);
|
||||
const linkIds = DocumentHelper.parseDocumentIds(document);
|
||||
const linkedDocumentIds: string[] = [];
|
||||
|
||||
// create or find existing backlink records for referenced docs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
@@ -40,7 +40,8 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
});
|
||||
|
||||
const mentions = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data)
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{ type: MentionType.User }
|
||||
);
|
||||
const userIdsMentioned: string[] = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { Op } from "sequelize";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { CommentEvent, CommentUpdateEvent } from "@server/types";
|
||||
@@ -37,7 +37,8 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
}
|
||||
|
||||
const mentions = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data)
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{ type: MentionType.User }
|
||||
).filter((mention) => newMentionIds.includes(mention.id));
|
||||
const userIdsMentioned: string[] = [];
|
||||
|
||||
@@ -99,7 +100,9 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
for (const item of commentsAndReplies) {
|
||||
// Mentions:
|
||||
const proseCommentData = ProsemirrorHelper.toProsemirror(item.data);
|
||||
const mentions = ProsemirrorHelper.parseMentions(proseCommentData);
|
||||
const mentions = ProsemirrorHelper.parseMentions(proseCommentData, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
const userIds = mentions.map((mention) => mention.modelId);
|
||||
|
||||
// Comment author:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
|
||||
import { Document, Notification, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
@@ -19,7 +19,9 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
await createSubscriptionsForDocument(document, event);
|
||||
|
||||
// Send notifications to mentioned users first
|
||||
const mentions = DocumentHelper.parseMentions(document);
|
||||
const mentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
const userIdsMentioned: string[] = [];
|
||||
|
||||
for (const mention of mentions) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { subHours } from "date-fns";
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import { Op } from "sequelize";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -37,8 +37,12 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
}
|
||||
|
||||
// Send notifications to mentioned users first
|
||||
const oldMentions = before ? DocumentHelper.parseMentions(before) : [];
|
||||
const newMentions = DocumentHelper.parseMentions(document);
|
||||
const oldMentions = before
|
||||
? DocumentHelper.parseMentions(before, { type: MentionType.User })
|
||||
: [];
|
||||
const newMentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
const mentions = differenceBy(newMentions, oldMentions, "id");
|
||||
const userIdsMentioned: string[] = [];
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Router from "koa-router";
|
||||
import difference from "lodash/difference";
|
||||
import { FindOptions, Op, WhereOptions } from "sequelize";
|
||||
import { CommentStatusFilter, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
CommentStatusFilter,
|
||||
TeamPreference,
|
||||
MentionType,
|
||||
} from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { feature } from "@server/middlewares/feature";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
@@ -225,10 +229,12 @@ router.post(
|
||||
|
||||
if (data !== undefined) {
|
||||
const existingMentionIds = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data)
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{ type: MentionType.User }
|
||||
).map((mention) => mention.id);
|
||||
const updatedMentionIds = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(data)
|
||||
ProsemirrorHelper.toProsemirror(data),
|
||||
{ type: MentionType.User }
|
||||
).map((mention) => mention.id);
|
||||
|
||||
newMentionIds = difference(updatedMentionIds, existingMentionIds);
|
||||
|
||||
@@ -1574,6 +1574,7 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.DocumentsCreateReq>) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
icon,
|
||||
@@ -1641,6 +1642,7 @@ router.post(
|
||||
}
|
||||
|
||||
const document = await documentCreator({
|
||||
id,
|
||||
title,
|
||||
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
|
||||
icon,
|
||||
|
||||
@@ -327,6 +327,9 @@ export type DocumentsImportReq = z.infer<typeof DocumentsImportSchema>;
|
||||
|
||||
export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Id of the document to be created */
|
||||
id: z.string().uuid().optional(),
|
||||
|
||||
/** Document title */
|
||||
title: z.string().default(""),
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import searches from "./searches";
|
||||
import shares from "./shares";
|
||||
import stars from "./stars";
|
||||
import subscriptions from "./subscriptions";
|
||||
import suggestions from "./suggestions";
|
||||
import teams from "./teams";
|
||||
import urls from "./urls";
|
||||
import userMemberships from "./userMemberships";
|
||||
@@ -82,6 +83,7 @@ router.use("/", searches.routes());
|
||||
router.use("/", shares.routes());
|
||||
router.use("/", stars.routes());
|
||||
router.use("/", subscriptions.routes());
|
||||
router.use("/", suggestions.routes());
|
||||
router.use("/", teams.routes());
|
||||
router.use("/", integrations.routes());
|
||||
router.use("/", notifications.routes());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./suggestions";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const SuggestionsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SuggestionsListReq = z.infer<typeof SuggestionsListSchema>;
|
||||
@@ -0,0 +1,75 @@
|
||||
import Router from "koa-router";
|
||||
import { Op } from "sequelize";
|
||||
import { Sequelize } from "sequelize-typescript";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { User } from "@server/models";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import { can } from "@server/policies";
|
||||
import { presentDocument, presentUser } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"suggestions.mention",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.SuggestionsListSchema),
|
||||
async (ctx: APIContext<T.SuggestionsListReq>) => {
|
||||
const { query } = ctx.input.body;
|
||||
const { offset, limit } = ctx.state.pagination;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
const [documents, users] = await Promise.all([
|
||||
SearchHelper.searchTitlesForUser(actor, {
|
||||
query,
|
||||
offset,
|
||||
limit,
|
||||
}),
|
||||
User.findAll({
|
||||
where: {
|
||||
teamId: actor.teamId,
|
||||
suspendedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
[Op.and]: query
|
||||
? {
|
||||
[Op.or]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(email)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
}
|
||||
: {},
|
||||
},
|
||||
order: [["name", "ASC"]],
|
||||
replacements: { query: `%${query}%` },
|
||||
offset,
|
||||
limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
documents: await Promise.all(
|
||||
documents.map((document) => presentDocument(ctx, document))
|
||||
),
|
||||
users: users.map((user) =>
|
||||
presentUser(user, {
|
||||
includeEmail: !!can(actor, "readEmail", user),
|
||||
includeDetails: !!can(actor, "readDetails", user),
|
||||
})
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
import dns from "dns";
|
||||
import Router from "koa-router";
|
||||
import { UnfurlResourceType } from "@shared/types";
|
||||
import { MentionType, UnfurlResourceType } from "@shared/types";
|
||||
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
||||
@@ -36,31 +36,34 @@ router.post(
|
||||
if (!documentId) {
|
||||
throw ValidationError("Document ID is required to unfurl a mention");
|
||||
}
|
||||
const { modelId: userId } = parseMentionUrl(url);
|
||||
const { modelId, mentionType } = parseMentionUrl(url);
|
||||
|
||||
const [user, document] = await Promise.all([
|
||||
User.findByPk(userId),
|
||||
Document.findByPk(documentId, {
|
||||
userId: actor.id,
|
||||
}),
|
||||
]);
|
||||
if (!user) {
|
||||
throw NotFoundError("Mentioned user does not exist");
|
||||
}
|
||||
if (!document) {
|
||||
throw NotFoundError("Document does not exist");
|
||||
}
|
||||
authorize(actor, "read", user);
|
||||
authorize(actor, "read", document);
|
||||
// TODO: Add support for other mention types
|
||||
if (mentionType === MentionType.User) {
|
||||
const [user, document] = await Promise.all([
|
||||
User.findByPk(modelId),
|
||||
Document.findByPk(documentId, {
|
||||
userId: actor.id,
|
||||
}),
|
||||
]);
|
||||
if (!user) {
|
||||
throw NotFoundError("Mentioned user does not exist");
|
||||
}
|
||||
if (!document) {
|
||||
throw NotFoundError("Document does not exist");
|
||||
}
|
||||
authorize(actor, "read", user);
|
||||
authorize(actor, "read", document);
|
||||
|
||||
ctx.body = await presentUnfurl(
|
||||
{
|
||||
type: UnfurlResourceType.Mention,
|
||||
user,
|
||||
document,
|
||||
},
|
||||
{ includeEmail: !!can(actor, "readEmail", user) }
|
||||
);
|
||||
ctx.body = await presentUnfurl(
|
||||
{
|
||||
type: UnfurlResourceType.Mention,
|
||||
user,
|
||||
document,
|
||||
},
|
||||
{ includeEmail: !!can(actor, "readEmail", user) }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import parseDocumentIds from "./parseDocumentIds";
|
||||
|
||||
it("should not return non links", () => {
|
||||
expect(parseDocumentIds(`# Header`).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should return an array of document ids", () => {
|
||||
const result = parseDocumentIds(`# Header
|
||||
|
||||
[internal](http://app.getoutline.com/doc/test-456733)
|
||||
|
||||
More text
|
||||
|
||||
[internal](/doc/test-123456#heading-anchor)
|
||||
`);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toBe("test-456733");
|
||||
expect(result[1]).toBe("test-123456");
|
||||
});
|
||||
|
||||
it("should return deeply nested link document ids", () => {
|
||||
const result = parseDocumentIds(`# Header
|
||||
|
||||
[internal](http://app.getoutline.com/doc/test-456733)
|
||||
|
||||
More text
|
||||
|
||||
- one
|
||||
- two
|
||||
- three [internal](/doc/test-123456#heading-anchor)
|
||||
`);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toBe("test-456733");
|
||||
expect(result[1]).toBe("test-123456");
|
||||
});
|
||||
|
||||
it("should not return duplicate document ids", () => {
|
||||
expect(parseDocumentIds(`# Header`).length).toBe(0);
|
||||
const result = parseDocumentIds(`# Header
|
||||
|
||||
[internal](/doc/test-456733)
|
||||
|
||||
[another link to the same doc](/doc/test-456733)
|
||||
`);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe("test-456733");
|
||||
});
|
||||
|
||||
it("should not return non document links", () => {
|
||||
expect(parseDocumentIds(`[google](http://www.google.com)`).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not return non document relative links", () => {
|
||||
expect(parseDocumentIds(`[relative](/developers)`).length).toBe(0);
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parser } from "@server/editor";
|
||||
|
||||
/**
|
||||
* Parse a list of unique document identifiers contained in links in markdown
|
||||
* text.
|
||||
*
|
||||
* @param text The text to parse in Markdown format
|
||||
* @returns An array of document identifiers
|
||||
*/
|
||||
export default function parseDocumentIds(text: string): string[] {
|
||||
const doc = parser.parse(text);
|
||||
const identifiers: string[] = [];
|
||||
if (!doc) {
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
// get text nodes
|
||||
if (node.type.name === "text") {
|
||||
// get marks for text nodes
|
||||
node.marks.forEach((mark) => {
|
||||
// any of the marks identifiers?
|
||||
if (mark.type.name === "link") {
|
||||
const slug = parseDocumentSlug(mark.attrs.href);
|
||||
|
||||
// don't return the same link more than once
|
||||
if (slug && !identifiers.includes(slug)) {
|
||||
identifiers.push(slug);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return identifiers;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Primitive } from "utility-types";
|
||||
import validator from "validator";
|
||||
import isIn from "validator/lib/isIn";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, MentionType } from "@shared/types";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import { validateIndexCharacters } from "@shared/utils/indexCharacters";
|
||||
@@ -247,7 +247,12 @@ export class ValidateURL {
|
||||
}
|
||||
|
||||
const { id, mentionType, modelId } = parseMentionUrl(url);
|
||||
return id && isUUID(id) && mentionType === "user" && isUUID(modelId);
|
||||
return (
|
||||
id &&
|
||||
isUUID(id) &&
|
||||
Object.values(MentionType).includes(mentionType as MentionType) &&
|
||||
isUUID(modelId)
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s } from "../styles";
|
||||
|
||||
type Props = {
|
||||
/** The emoji to render */
|
||||
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useStores from "../hooks/useStores";
|
||||
import { IconType } from "../types";
|
||||
import { IconLibrary } from "../utils/IconLibrary";
|
||||
import { colorPalette } from "../utils/collections";
|
||||
import { determineIconType } from "../utils/icon";
|
||||
import EmojiIcon from "./EmojiIcon";
|
||||
// import Logger from "~/utils/Logger";
|
||||
import Flex from "./Flex";
|
||||
|
||||
export type Props = {
|
||||
@@ -40,9 +40,9 @@ const Icon = ({
|
||||
const iconType = determineIconType(icon);
|
||||
|
||||
if (!iconType) {
|
||||
Logger.warn("Failed to determine icon type", {
|
||||
icon,
|
||||
});
|
||||
// Logger.warn("Failed to determine icon type", {
|
||||
// icon,
|
||||
// });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ const Icon = ({
|
||||
|
||||
return <EmojiIcon emoji={icon} size={size} className={className} />;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to render icon", {
|
||||
icon,
|
||||
});
|
||||
// Logger.warn("Failed to render icon", {
|
||||
// icon,
|
||||
// });
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -80,7 +80,6 @@ const SVGIcon = observer(
|
||||
forceColor,
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
|
||||
let color = inputColor ?? colorPalette[0];
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
@@ -0,0 +1,69 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, EmailIcon } from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "../../components/Icon";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import { cn } from "../styles/utils";
|
||||
import { ComponentProps } from "../types";
|
||||
|
||||
const getAttributesFromNode = (node: Node) => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
|
||||
const { class: className, ...attrs } = spec[1];
|
||||
return { className, ...attrs };
|
||||
};
|
||||
|
||||
export const MentionUser = observer(function MentionUser_(
|
||||
props: ComponentProps
|
||||
) {
|
||||
const { isSelected, node } = props;
|
||||
const { users } = useStores();
|
||||
const user = users.get(node.attrs.modelId);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
>
|
||||
<EmailIcon size={18} />
|
||||
{user?.name || node.attrs.label}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionDocument = observer(function MentionDocument_(
|
||||
props: ComponentProps
|
||||
) {
|
||||
const { isSelected, node } = props;
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
void documents.prefetchDocument(modelId);
|
||||
}
|
||||
}, [modelId, documents]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
to={doc?.path ?? `/doc/${node.attrs.modelId}`}
|
||||
>
|
||||
{doc?.icon ? (
|
||||
<Icon value={doc?.icon} color={doc?.color} size={18} />
|
||||
) : (
|
||||
<DocumentIcon size={18} />
|
||||
)}
|
||||
{doc?.title || node.attrs.label}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -289,17 +289,31 @@ width: 100%;
|
||||
.mention {
|
||||
background: ${props.theme.mentionBackground};
|
||||
border-radius: 8px;
|
||||
padding-bottom: 2px;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-right: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
cursor: default;
|
||||
text-decoration: none !important;
|
||||
|
||||
&::before {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
vertical-align: bottom;
|
||||
|
||||
&:hover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.mention-user::before {
|
||||
content: "@";
|
||||
}
|
||||
|
||||
&.mention-document::before {
|
||||
content: "+";
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import useComponentSize from "../../hooks/useComponentSize";
|
||||
import Frame from "../components/Frame";
|
||||
import useComponentSize from "../components/hooks/useComponentSize";
|
||||
import { EmbedProps as Props } from ".";
|
||||
|
||||
export default function Berrycast({ matches, ...props }: Props) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { toast } from "sonner";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import { getMarkRange } from "../queries/getMarkRange";
|
||||
import { isMarkActive } from "../queries/isMarkActive";
|
||||
import { EventType } from "../types";
|
||||
import Mark from "./Mark";
|
||||
|
||||
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
|
||||
@@ -109,8 +108,7 @@ export default class Link extends Mark {
|
||||
return {
|
||||
"Mod-k": (state, dispatch) => {
|
||||
if (state.selection.empty) {
|
||||
this.editor.events.emit(EventType.LinkToolbarOpen);
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return toggleMark(type, { href: "" })(state, dispatch);
|
||||
|
||||
@@ -5,17 +5,24 @@ import {
|
||||
NodeType,
|
||||
Schema,
|
||||
} from "prosemirror-model";
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
import {
|
||||
Command,
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Extension from "../lib/Extension";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType } from "../../types";
|
||||
import { MentionDocument, MentionUser } from "../components/Mentions";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import mentionRule from "../rules/mention";
|
||||
import { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class Mention extends Extension {
|
||||
get type() {
|
||||
return "node";
|
||||
}
|
||||
|
||||
export default class Mention extends Node {
|
||||
get name() {
|
||||
return "mention";
|
||||
}
|
||||
@@ -39,8 +46,9 @@ export default class Mention extends Extension {
|
||||
atom: true,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `span.${this.name}`,
|
||||
tag: `.${this.name}`,
|
||||
preserveWhitespace: "full",
|
||||
priority: 100,
|
||||
getAttrs: (dom: HTMLElement) => {
|
||||
const type = dom.dataset.type;
|
||||
const modelId = dom.dataset.id;
|
||||
@@ -51,7 +59,7 @@ export default class Mention extends Extension {
|
||||
return {
|
||||
type,
|
||||
modelId,
|
||||
actorId: dom.dataset.actorId,
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
id: dom.id,
|
||||
};
|
||||
@@ -59,25 +67,97 @@ export default class Mention extends Extension {
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"span",
|
||||
node.attrs.type === MentionType.User ? "span" : "a",
|
||||
{
|
||||
class: `${node.type.name} use-hover-preview`,
|
||||
id: node.attrs.id,
|
||||
href:
|
||||
node.attrs.type === MentionType.User
|
||||
? undefined
|
||||
: `${env.URL}/doc/${node.attrs.modelId}`,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorId": node.attrs.actorId,
|
||||
"data-actorid": node.attrs.actorId,
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
},
|
||||
String(node.attrs.label),
|
||||
],
|
||||
toPlainText: (node) => `@${node.attrs.label}`,
|
||||
toPlainText: (node) =>
|
||||
node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label,
|
||||
};
|
||||
}
|
||||
|
||||
component = (props: ComponentProps) => {
|
||||
switch (props.node.attrs.type) {
|
||||
case MentionType.User:
|
||||
return <MentionUser {...props} />;
|
||||
case MentionType.Document:
|
||||
return <MentionDocument {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
get rulePlugins() {
|
||||
return [mentionRule];
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
// Ensure mentions have unique IDs
|
||||
new Plugin({
|
||||
appendTransaction: (_transactions, _oldState, newState) => {
|
||||
const tr = newState.tr;
|
||||
const existingIds = new Set();
|
||||
let modified = false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
let nodeId = node.attrs.id;
|
||||
if (
|
||||
node.type.name === this.name &&
|
||||
(!nodeId || existingIds.has(nodeId))
|
||||
) {
|
||||
nodeId = uuidv4();
|
||||
modified = true;
|
||||
tr.setNodeAttribute(pos, "id", nodeId);
|
||||
}
|
||||
existingIds.add(nodeId);
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
return {
|
||||
Enter: (state) => {
|
||||
const { selection } = state;
|
||||
if (selection instanceof NodeSelection) {
|
||||
const { from } = selection;
|
||||
const node = state.doc.nodeAt(from);
|
||||
if (
|
||||
node &&
|
||||
node.type.name === "mention" &&
|
||||
node.attrs.type === MentionType.Document
|
||||
) {
|
||||
const { modelId } = node.attrs;
|
||||
this.editor.props.onClickLink?.(`/doc/${modelId}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
commands({ type }: { type: NodeType; schema: Schema }) {
|
||||
return (attrs: Record<string, Primitive>): Command =>
|
||||
(state, dispatch) => {
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
@@ -7,24 +8,25 @@ import { Primitive } from "utility-types";
|
||||
|
||||
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
|
||||
|
||||
export enum EventType {
|
||||
LinkToolbarOpen = "linkMenuOpen",
|
||||
}
|
||||
|
||||
export enum TableLayout {
|
||||
fullWidth = "full-width",
|
||||
}
|
||||
|
||||
type Section = ({ t }: { t: TFunction }) => string;
|
||||
|
||||
export type MenuItem = {
|
||||
icon?: React.ReactElement;
|
||||
name?: string;
|
||||
title?: string;
|
||||
section?: Section;
|
||||
subtitle?: string;
|
||||
shortcut?: string;
|
||||
keywords?: string;
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
dangerous?: boolean;
|
||||
/** Higher number is higher in results, default is 0. */
|
||||
priority?: number;
|
||||
children?: MenuItem[];
|
||||
defaultHidden?: boolean;
|
||||
attrs?:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext);
|
||||
}
|
||||
@@ -138,6 +138,7 @@
|
||||
"Collection": "Collection",
|
||||
"Debug": "Debug",
|
||||
"Document": "Document",
|
||||
"Documents": "Documents",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Revision": "Revision",
|
||||
"Navigation": "Navigation",
|
||||
@@ -302,7 +303,6 @@
|
||||
"Unknown": "Unknown",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"You're all caught up": "You're all caught up",
|
||||
"Documents": "Documents",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
@@ -416,8 +416,9 @@
|
||||
"Replacement": "Replacement",
|
||||
"Replace": "Replace",
|
||||
"Replace all": "Replace all",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"Profile picture": "Profile picture",
|
||||
"Create a new doc": "Create a new doc",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"Add column after": "Add column after",
|
||||
"Add column before": "Add column before",
|
||||
"Add row after": "Add row after",
|
||||
@@ -435,7 +436,6 @@
|
||||
"Comment": "Comment",
|
||||
"Create link": "Create link",
|
||||
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
|
||||
"Create a new doc": "Create a new doc",
|
||||
"Create a new child doc": "Create a new child doc",
|
||||
"Delete table": "Delete table",
|
||||
"Delete file": "Delete file",
|
||||
@@ -447,7 +447,7 @@
|
||||
"Italic": "Italic",
|
||||
"Sorry, that link won’t work for this embed type": "Sorry, that link won’t work for this embed type",
|
||||
"File attachment": "File attachment",
|
||||
"Paste a link, search, or create": "Paste a link, search, or create",
|
||||
"Enter a link": "Enter a link",
|
||||
"Big heading": "Big heading",
|
||||
"Medium heading": "Medium heading",
|
||||
"Small heading": "Small heading",
|
||||
|
||||
@@ -56,6 +56,7 @@ export enum FileOperationState {
|
||||
|
||||
export enum MentionType {
|
||||
User = "user",
|
||||
Document = "document",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function isInternalUrl(href: string) {
|
||||
*/
|
||||
export function isDocumentUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const parsed = new URL(url, env.URL);
|
||||
return (
|
||||
isInternalUrl(url) &&
|
||||
(parsed.pathname.startsWith("/doc/") || parsed.pathname.startsWith("/d/"))
|
||||
|
||||
Reference in New Issue
Block a user