Refactor Editor components to be injected by associated extension (#6093)

This commit is contained in:
Tom Moor
2023-10-31 21:55:55 -04:00
committed by GitHub
parent 44198732d3
commit df6d8c12cc
25 changed files with 371 additions and 354 deletions

View File

@@ -5,6 +5,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import styled from "styled-components"; import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow"; import Arrow from "~/components/Arrow";
@@ -12,9 +13,19 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator"; import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
type Props = { type Props = {
collection: Collection; collection: Collection;
}; };
@@ -104,6 +115,7 @@ function CollectionDescription({ collection }: Props) {
readOnly={!isEditing} readOnly={!isEditing}
autoFocus={isEditing} autoFocus={isEditing}
onBlur={handleStopEditing} onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000} maxLength={1000}
embedsDisabled embedsDisabled
canUpdate canUpdate

View File

@@ -19,7 +19,6 @@ import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document"; import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding"; import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary"; import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary"; import useDictionary from "~/hooks/useDictionary";
@@ -47,7 +46,6 @@ export type Props = Optional<
> & { > & {
shareId?: string | undefined; shareId?: string | undefined;
embedsDisabled?: boolean; embedsDisabled?: boolean;
previewsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void; onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>; onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void; onPublish?: (event: React.MouseEvent) => void;
@@ -62,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onHeadingsChange, onHeadingsChange,
onCreateCommentMark, onCreateCommentMark,
onDeleteCommentMark, onDeleteCommentMark,
previewsDisabled,
} = props; } = props;
const userLocale = useUserLocale(); const userLocale = useUserLocale();
const locale = dateLocale(userLocale); const locale = dateLocale(userLocale);
@@ -73,22 +70,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const localRef = React.useRef<SharedEditor>(); const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null); const previousHeadings = React.useRef<Heading[] | null>(null);
const [activeLinkElement, setActiveLink] =
React.useState<HTMLAnchorElement | null>(null);
const previousCommentIds = React.useRef<string[]>(); const previousCommentIds = React.useRef<string[]>();
const handleLinkActive = React.useCallback(
(element: HTMLAnchorElement | null) => {
setActiveLink(element);
return false;
},
[]
);
const handleLinkInactive = React.useCallback(() => {
setActiveLink(null);
}, []);
const handleSearchLink = React.useCallback( const handleSearchLink = React.useCallback(
async (term: string) => { async (term: string) => {
if (isInternalUrl(term)) { if (isInternalUrl(term)) {
@@ -339,7 +322,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
userPreferences={preferences} userPreferences={preferences}
dictionary={dictionary} dictionary={dictionary}
{...props} {...props}
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
onClickLink={handleClickLink} onClickLink={handleClickLink}
onSearchLink={handleSearchLink} onSearchLink={handleSearchLink}
onChange={handleChange} onChange={handleChange}
@@ -354,12 +336,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
minHeight={props.editorStyle.paddingBottom} minHeight={props.editorStyle.paddingBottom}
/> />
)} )}
{!shareId && (
<HoverPreview
element={activeLinkElement}
onClose={handleLinkInactive}
/>
)}
</> </>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@@ -24,7 +24,7 @@ const POINTER_WIDTH = 22;
type Props = { type Props = {
/** The HTML element that is being hovered over, or null if none. */ /** The HTML element that is being hovered over, or null if none. */
element: HTMLAnchorElement | null; element: HTMLElement | null;
/** A callback on close of the hover preview. */ /** A callback on close of the hover preview. */
onClose: () => void; onClose: () => void;
}; };
@@ -35,7 +35,7 @@ enum Direction {
} }
function HoverPreviewDesktop({ element, onClose }: Props) { function HoverPreviewDesktop({ element, onClose }: Props) {
const url = element?.href || element?.dataset.url; const url = element?.getAttribute("href") || element?.dataset.url;
const previousUrl = usePrevious(url, true); const previousUrl = usePrevious(url, true);
const [isVisible, setVisible] = React.useState(false); const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>(); const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
@@ -200,7 +200,7 @@ function useHoverPosition({
isVisible, isVisible,
}: { }: {
cardRef: React.RefObject<HTMLDivElement>; cardRef: React.RefObject<HTMLDivElement>;
element: HTMLAnchorElement | null; element: HTMLElement | null;
isVisible: boolean; isVisible: boolean;
}) { }) {
const [cardLeft, setCardLeft] = React.useState(0); const [cardLeft, setCardLeft] = React.useState(0);

View File

@@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
{notification.comment && ( {notification.comment && (
<StyledCommentEditor <StyledCommentEditor
defaultValue={toJS(notification.comment.data)} defaultValue={toJS(notification.comment.data)}
previewsDisabled
/> />
)} )}
</Flex> </Flex>

View File

@@ -10,7 +10,7 @@ type Props = Omit<
SuggestionsMenuProps, SuggestionsMenuProps,
"renderMenuItem" | "items" | "trigger" "renderMenuItem" | "items" | "trigger"
> & > &
Required<Pick<SuggestionsMenuProps, "onLinkToolbarOpen" | "embeds">>; Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) { function BlockMenu(props: Props) {
const dictionary = useDictionary(); const dictionary = useDictionary();

View File

@@ -28,7 +28,7 @@ let searcher: FuzzySearch<TEmoji>;
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<Emoji>, SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" "renderMenuItem" | "items" | "embeds" | "trigger"
>; >;
const EmojiMenu = (props: Props) => { const EmojiMenu = (props: Props) => {

View File

@@ -33,7 +33,7 @@ interface MentionItem extends MenuItem {
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<MentionItem>, SuggestionsMenuProps<MentionItem>,
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" "renderMenuItem" | "items" | "embeds" | "trigger"
>; >;
function MentionMenu({ search, isActive, ...rest }: Props) { function MentionMenu({ search, isActive, ...rest }: Props) {

View File

@@ -60,7 +60,6 @@ export type Props<T extends MenuItem = MenuItem> = {
uploadFile?: (file: File) => Promise<string>; uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void; onFileUploadStart?: () => void;
onFileUploadStop?: () => void; onFileUploadStop?: () => void;
onLinkToolbarOpen?: () => void;
onClose: (insertNewLine?: boolean) => void; onClose: (insertNewLine?: boolean) => void;
embeds?: EmbedDescriptor[]; embeds?: EmbedDescriptor[];
renderMenuItem: ( renderMenuItem: (
@@ -252,17 +251,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return triggerFilePick("*"); return triggerFilePick("*");
case "embed": case "embed":
return triggerLinkInput(item); return triggerLinkInput(item);
case "link": {
handleClearSearch();
props.onClose();
props.onLinkToolbarOpen?.();
return;
}
default: default:
insertNode(item); insertNode(item);
} }
}, },
[insertNode, handleClearSearch, props] [insertNode]
); );
const close = React.useCallback(() => { const close = React.useCallback(() => {

View File

@@ -1,24 +1,24 @@
import { action } from "mobx";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react"; import * as React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { SuggestionsMenuType } from "../plugins/Suggestions"; import { WidgetProps } from "@shared/editor/lib/Extension";
import { findParentNode } from "../queries/findParentNode"; import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EventType } from "../types"; import Suggestion from "~/editor/extensions/Suggestion";
import Suggestion from "./Suggestion"; import BlockMenu from "../components/BlockMenu";
export default class BlockMenu extends Suggestion { export default class BlockMenuExtension extends Suggestion {
get defaultOptions() { get defaultOptions() {
return { return {
type: SuggestionsMenuType.Block,
openRegex: /^\/(\w+)?$/, openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/, closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
}; };
} }
get name() { get name() {
return "blockmenu"; return "block-menu";
} }
get plugins() { get plugins() {
@@ -54,12 +54,12 @@ export default class BlockMenu extends Suggestion {
Decoration.widget( Decoration.widget(
parent.pos, parent.pos,
() => { () => {
button.addEventListener("click", () => { button.addEventListener(
this.editor.events.emit(EventType.SuggestionsMenuOpen, { "click",
type: SuggestionsMenuType.Block, action(() => {
query: "", this.state.open = true;
}); })
}); );
return button; return button;
}, },
{ {
@@ -96,4 +96,28 @@ export default class BlockMenu extends Suggestion {
}), }),
]; ];
} }
widget = ({ rtl }: WidgetProps) => {
const { props, view } = this.editor;
return (
<BlockMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action((insertNewLine) => {
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
})}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
embeds={props.embeds}
/>
);
};
} }

View File

@@ -0,0 +1,45 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import Suggestion from "~/editor/extensions/Suggestion";
import EmojiMenu from "../components/EmojiMenu";
/**
* Languages using the colon character with a space in front in standard
* punctuation. In this case the trigger is only matched once there is additional
* text after the colon.
*/
const languagesUsingColon = ["fr"];
export default class EmojiMenuExtension extends Suggestion {
get defaultOptions() {
const languageIsUsingColon =
typeof window === "undefined"
? false
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
return {
openRegex: new RegExp(
`(?:^|\\s):([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
),
closeRegex:
/(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
};
}
get name() {
return "emoji-menu";
}
widget = ({ rtl }: WidgetProps) => (
<EmojiMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
this.state.open = false;
})}
/>
);
}

View File

@@ -2,12 +2,14 @@ import escapeRegExp from "lodash/escapeRegExp";
import { Node } from "prosemirror-model"; import { Node } from "prosemirror-model";
import { Command, Plugin, PluginKey } from "prosemirror-state"; import { Command, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace"); const pluginKey = new PluginKey("find-and-replace");
export default class FindAndReplace extends Extension { export default class FindAndReplaceExtension extends Extension {
public get name() { public get name() {
return "find-and-replace"; return "find-and-replace";
} }
@@ -292,6 +294,10 @@ export default class FindAndReplace extends Extension {
]; ];
} }
public widget = () => (
<FindAndReplace readOnly={this.editor.props.readOnly} />
);
private results: { from: number; to: number }[] = []; private results: { from: number; to: number }[] = [];
private currentResultIndex = 0; private currentResultIndex = 0;
private searchTerm = ""; private searchTerm = "";

View File

@@ -1,16 +1,22 @@
import { action, observable } from "mobx";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import Extension from "../lib/Extension"; import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import HoverPreview from "~/components/HoverPreview";
interface HoverPreviewsOptions { interface HoverPreviewsOptions {
/** Callback when a hover target is found or lost. */
onHoverLink?: (target: Element | null) => void;
/** Delay before the target is considered "hovered" and callback is triggered. */ /** Delay before the target is considered "hovered" and callback is triggered. */
delay: number; delay: number;
} }
export default class HoverPreviews extends Extension { export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
} = observable({
activeLinkElement: null,
});
get defaultOptions(): HoverPreviewsOptions { get defaultOptions(): HoverPreviewsOptions {
return { return {
delay: 500, delay: 500,
@@ -38,27 +44,37 @@ export default class HoverPreviews extends Extension {
".use-hover-preview" ".use-hover-preview"
); );
if (isHoverTarget(target, view)) { if (isHoverTarget(target, view)) {
if (this.options.onHoverLink) { hoveringTimeout = setTimeout(
hoveringTimeout = setTimeout(() => { action(() => {
this.options.onHoverLink?.(target); this.state.activeLinkElement = target as HTMLElement;
}, this.options.delay); }),
} this.options.delay
);
} }
return false; return false;
}, },
mouseout: (view: EditorView, event: MouseEvent) => { mouseout: action((view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest( const target = (event.target as HTMLElement)?.closest(
".use-hover-preview" ".use-hover-preview"
); );
if (isHoverTarget(target, view)) { if (isHoverTarget(target, view)) {
clearTimeout(hoveringTimeout); clearTimeout(hoveringTimeout);
this.options.onHoverLink?.(null); this.state.activeLinkElement = null;
} }
return false; return false;
}, }),
}, },
}, },
}), }),
]; ];
} }
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
onClose={action(() => {
this.state.activeLinkElement = null;
})}
/>
);
} }

View File

@@ -0,0 +1,31 @@
import { action } from "mobx";
import * as React from "react";
import { WidgetProps } from "@shared/editor/lib/Extension";
import Suggestion from "~/editor/extensions/Suggestion";
import MentionMenu from "../components/MentionMenu";
export default class MentionMenuExtension extends Suggestion {
get defaultOptions() {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
};
}
get name() {
return "mention-menu";
}
widget = ({ rtl }: WidgetProps) => (
<MentionMenu
rtl={rtl}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
this.state.open = false;
})}
/>
);
}

View File

@@ -0,0 +1,73 @@
import { action, observable } from "mobx";
import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import isInCode from "@shared/editor/queries/isInCode";
export default class Suggestion extends Extension {
state: {
open: boolean;
query: string;
} = observable({
open: false,
query: "",
});
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.options, this.state)];
}
keys() {
return {
Backspace: action((state: EditorState) => {
const { $from } = state.selection;
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - 500), // 500 = max match
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
null,
"\ufffc"
);
if (this.options.openRegex.test(textBefore)) {
return false;
}
this.state.open = false;
return false;
}),
};
}
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(
this.options.openRegex,
action((state: EditorState, match: RegExpMatchArray) => {
const { parent } = state.selection.$from;
if (
match &&
(parent.type.name === "paragraph" ||
parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) &&
(!isInTable(state) || this.options.enabledInTable)
) {
this.state.open = true;
this.state.query = match[1];
}
return null;
})
),
new InputRule(
this.options.closeRegex,
action((_: EditorState, match: RegExpMatchArray) => {
if (match) {
this.state.open = false;
this.state.query = "";
}
return null;
})
),
];
}

View File

@@ -26,15 +26,17 @@ import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import Styles from "@shared/editor/components/Styles"; import Styles from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds"; import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; import Extension, {
CommandFactory,
WidgetProps,
} from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween"; import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark"; import Mark from "@shared/editor/marks/Mark";
import { richExtensions, withComments } from "@shared/editor/nodes"; import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node"; import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode"; import ReactNode from "@shared/editor/nodes/ReactNode";
import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions";
import { EventType } from "@shared/editor/types"; import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types"; import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper"; import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
@@ -43,21 +45,13 @@ import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal"; import { PortalContext } from "~/components/Portal";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView"; import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext"; import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu";
import FindAndReplace from "./components/FindAndReplace";
import { SearchResult } from "./components/LinkEditor"; import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar"; import LinkToolbar from "./components/LinkToolbar";
import MentionMenu from "./components/MentionMenu";
import SelectionToolbar from "./components/SelectionToolbar"; import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme"; import WithTheme from "./components/WithTheme";
const extensions = withComments(richExtensions);
export { default as Extension } from "@shared/editor/lib/Extension";
export type Props = { export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */ /** An optional identifier for the editor context. It is used to persist local settings */
id?: string; id?: string;
@@ -124,8 +118,6 @@ export type Props = {
href: string, href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement> event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void; ) => void;
/** Callback when user hovers on any link in the document */
onHoverLink?: (element: HTMLAnchorElement | null) => boolean;
/** Callback when user presses any key with document focused */ /** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
/** Collection of embed types to render in the document */ /** Collection of embed types to render in the document */
@@ -148,12 +140,8 @@ type State = {
isEditorFocused: boolean; isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */ /** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean; selectionToolbarOpen: boolean;
/** If a suggestions menu is visible */
suggestionsMenuOpen: SuggestionsMenuType | false;
/** If the insert link toolbar is visible */ /** If the insert link toolbar is visible */
linkToolbarOpen: boolean; linkToolbarOpen: boolean;
/** The query for the suggestion menu */
query: string;
}; };
/** /**
@@ -182,10 +170,8 @@ export class Editor extends React.PureComponent<
state: State = { state: State = {
isRTL: false, isRTL: false,
isEditorFocused: false, isEditorFocused: false,
suggestionsMenuOpen: false,
selectionToolbarOpen: false, selectionToolbarOpen: false,
linkToolbarOpen: false, linkToolbarOpen: false,
query: "",
}; };
isBlurred = true; isBlurred = true;
@@ -204,6 +190,7 @@ export class Editor extends React.PureComponent<
[name: string]: NodeViewConstructor; [name: string]: NodeViewConstructor;
}; };
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
nodes: { [name: string]: NodeSpec }; nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec }; marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>; commands: Record<string, CommandFactory>;
@@ -214,14 +201,6 @@ export class Editor extends React.PureComponent<
public constructor(props: Props & ThemeProps<DefaultTheme>) { public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props); super(props);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar); this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
this.events.on(
EventType.SuggestionsMenuOpen,
this.handleOpenSuggestionsMenu
);
this.events.on(
EventType.SuggestionsMenuClose,
this.handleCloseSuggestionsMenu
);
} }
/** /**
@@ -279,7 +258,6 @@ export class Editor extends React.PureComponent<
if ( if (
!this.isBlurred && !this.isBlurred &&
!this.state.isEditorFocused && !this.state.isEditorFocused &&
!this.state.suggestionsMenuOpen &&
!this.state.linkToolbarOpen && !this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen !this.state.selectionToolbarOpen
) { ) {
@@ -290,7 +268,6 @@ export class Editor extends React.PureComponent<
if ( if (
this.isBlurred && this.isBlurred &&
(this.state.isEditorFocused || (this.state.isEditorFocused ||
this.state.suggestionsMenuOpen ||
this.state.linkToolbarOpen || this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen) this.state.selectionToolbarOpen)
) { ) {
@@ -310,6 +287,7 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes(); this.nodes = this.createNodes();
this.marks = this.createMarks(); this.marks = this.createMarks();
this.schema = this.createSchema(); this.schema = this.createSchema();
this.widgets = this.createWidgets();
this.plugins = this.createPlugins(); this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins(); this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps(); this.keymaps = this.createKeymaps();
@@ -378,6 +356,10 @@ export class Editor extends React.PureComponent<
}); });
} }
private createWidgets() {
return this.extensions.widgets;
}
private createNodes() { private createNodes() {
return this.extensions.nodes; return this.extensions.nodes;
} }
@@ -702,8 +684,6 @@ export class Editor extends React.PureComponent<
this.setState((state) => ({ this.setState((state) => ({
...state, ...state,
selectionToolbarOpen: true, selectionToolbarOpen: true,
suggestionsMenuOpen: false,
query: "",
})); }));
}; };
@@ -720,9 +700,7 @@ export class Editor extends React.PureComponent<
private handleOpenLinkToolbar = () => { private handleOpenLinkToolbar = () => {
this.setState((state) => ({ this.setState((state) => ({
...state, ...state,
suggestionsMenuOpen: false,
linkToolbarOpen: true, linkToolbarOpen: true,
query: "",
})); }));
}; };
@@ -733,37 +711,6 @@ export class Editor extends React.PureComponent<
})); }));
}; };
private handleOpenSuggestionsMenu = (data: {
type: SuggestionsMenuType;
query: string;
}) => {
this.setState((state) => ({
...state,
suggestionsMenuOpen: data.type,
query: data.query,
}));
};
private handleCloseSuggestionsMenu = (
type: SuggestionsMenuType,
insertNewLine?: boolean
) => {
if (insertNewLine) {
const transaction = this.view.state.tr.split(
this.view.state.selection.to
);
this.view.dispatch(transaction);
this.view.focus();
}
if (type && this.state.suggestionsMenuOpen !== type) {
return;
}
this.setState((state) => ({
...state,
suggestionsMenuOpen: false,
}));
};
public render() { public render() {
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } = const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props; this.props;
@@ -792,84 +739,31 @@ export class Editor extends React.PureComponent<
ref={this.elementRef} ref={this.elementRef}
/> />
{this.view && ( {this.view && (
<> <SelectionToolbar
<SelectionToolbar rtl={isRTL}
rtl={isRTL} readOnly={readOnly}
readOnly={readOnly} canComment={this.props.canComment}
canComment={this.props.canComment} isTemplate={this.props.template === true}
isTemplate={this.props.template === true} onOpen={this.handleOpenSelectionToolbar}
onOpen={this.handleOpenSelectionToolbar} onClose={this.handleCloseSelectionToolbar}
onClose={this.handleCloseSelectionToolbar} onSearchLink={this.props.onSearchLink}
onSearchLink={this.props.onSearchLink} onClickLink={this.props.onClickLink}
onClickLink={this.props.onClickLink} onCreateLink={this.props.onCreateLink}
onCreateLink={this.props.onCreateLink} />
/>
{this.commands.find && <FindAndReplace readOnly={readOnly} />}
</>
)} )}
{!readOnly && this.view && ( {!readOnly && this.view && this.marks.link && (
<> <LinkToolbar
{this.marks.link && ( isActive={this.state.linkToolbarOpen}
<LinkToolbar onCreateLink={this.props.onCreateLink}
isActive={this.state.linkToolbarOpen} onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink} onClickLink={this.props.onClickLink}
onSearchLink={this.props.onSearchLink} onClose={this.handleCloseLinkToolbar}
onClickLink={this.props.onClickLink} />
onClose={this.handleCloseLinkToolbar}
/>
)}
{this.nodes.emoji && (
<EmojiMenu
rtl={isRTL}
isActive={
this.state.suggestionsMenuOpen ===
SuggestionsMenuType.Emoji
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Emoji,
insertNewLine
)
}
/>
)}
{this.nodes.mention && (
<MentionMenu
rtl={isRTL}
isActive={
this.state.suggestionsMenuOpen ===
SuggestionsMenuType.Mention
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Mention,
insertNewLine
)
}
/>
)}
<BlockMenu
rtl={isRTL}
isActive={
this.state.suggestionsMenuOpen === SuggestionsMenuType.Block
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Block,
insertNewLine
)
}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkToolbar}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
embeds={this.props.embeds}
/>
</>
)} )}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} />
))}
</Flex> </Flex>
</EditorContext.Provider> </EditorContext.Provider>
</PortalContext.Provider> </PortalContext.Provider>

View File

@@ -14,7 +14,6 @@ import {
StarredIcon, StarredIcon,
WarningIcon, WarningIcon,
InfoIcon, InfoIcon,
LinkIcon,
AttachmentIcon, AttachmentIcon,
ClockIcon, ClockIcon,
CalendarIcon, CalendarIcon,
@@ -95,13 +94,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
icon: <ImageIcon />, icon: <ImageIcon />,
keywords: "picture photo", keywords: "picture photo",
}, },
{
name: "link",
title: dictionary.link,
icon: <LinkIcon />,
shortcut: `${metaDisplay} k`,
keywords: "link url uri href",
},
{ {
name: "video", name: "video",
title: dictionary.video, title: dictionary.video,

View File

@@ -2,8 +2,14 @@ import * as React from "react";
import { basicExtensions, withComments } from "@shared/editor/nodes"; import { basicExtensions, withComments } from "@shared/editor/nodes";
import Editor, { Props as EditorProps } from "~/components/Editor"; import Editor, { Props as EditorProps } from "~/components/Editor";
import type { Editor as SharedEditor } from "~/editor"; import type { Editor as SharedEditor } from "~/editor";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
const extensions = withComments(basicExtensions); const extensions = [
...withComments(basicExtensions),
EmojiMenuExtension,
MentionMenuExtension,
];
const CommentEditor = ( const CommentEditor = (
props: EditorProps, props: EditorProps,

View File

@@ -8,8 +8,14 @@ import { TeamPreference } from "@shared/types";
import Comment from "~/models/Comment"; import Comment from "~/models/Comment";
import Document from "~/models/Document"; import Document from "~/models/Document";
import { RefHandle } from "~/components/ContentEditable"; import { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor"; import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment"; import useFocusedComment from "~/hooks/useFocusedComment";
@@ -20,14 +26,20 @@ import {
documentPath, documentPath,
matchDocumentHistory, matchDocumentHistory,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import { useDocumentContext } from "../../../components/DocumentContext";
import MultiplayerEditor from "./AsyncMultiplayerEditor"; import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta"; import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle"; import DocumentTitle from "./DocumentTitle";
const extensions = withComments(richExtensions); const extensions = [
...withComments(richExtensions),
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
];
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & { type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void; onChangeTitle: (title: string) => void;
onChangeEmoji: (emoji: string | null) => void; onChangeEmoji: (emoji: string | null) => void;
id: string; id: string;

View File

@@ -1,65 +0,0 @@
import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "../lib/Extension";
import { SuggestionsMenuPlugin } from "../plugins/Suggestions";
import isInCode from "../queries/isInCode";
import { EventType } from "../types";
export default class Suggestion extends Extension {
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.editor, this.options)];
}
keys() {
return {
Backspace: (state: EditorState) => {
const { $from } = state.selection;
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - 500), // 500 = max match
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
null,
"\ufffc"
);
if (this.options.openRegex.test(textBefore)) {
return false;
}
this.editor.events.emit(
EventType.SuggestionsMenuClose,
this.options.type
);
return false;
},
};
}
inputRules = (_options: { type: NodeType; schema: Schema }) => [
new InputRule(this.options.openRegex, (state, match) => {
const { parent } = state.selection.$from;
if (
match &&
(parent.type.name === "paragraph" || parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) &&
(!isInTable(state) || this.options.enabledInTable)
) {
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
type: this.options.type,
query: match[1],
});
}
return null;
}),
new InputRule(this.options.closeRegex, (state, match) => {
if (match) {
this.editor.events.emit(
EventType.SuggestionsMenuClose,
this.options.type
);
}
return null;
}),
];
}

View File

@@ -7,6 +7,8 @@ import { Editor } from "../../../app/editor";
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command; export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
export type WidgetProps = { rtl: boolean };
export default class Extension { export default class Extension {
options: any; options: any;
editor: Editor; editor: Editor;
@@ -50,6 +52,22 @@ export default class Extension {
return true; return true;
} }
/**
* A widget is a React component to be rendered in the editor's context, independent of any
* specific node or mark. It can be used to render things like toolbars, menus, etc. Note that
* all widgets are observed automatically, so you can use observable values.
*
* @returns A React component
*/
widget(_props: WidgetProps): React.ReactElement | undefined {
return undefined;
}
/**
* A map of ProseMirror keymap bindings. It can be used to bind keyboard shortcuts to commands.
*
* @returns An object mapping key bindings to commands
*/
keys(_options: { keys(_options: {
type?: NodeType | MarkType; type?: NodeType | MarkType;
schema: Schema; schema: Schema;
@@ -57,6 +75,12 @@ export default class Extension {
return {}; return {};
} }
/**
* A map of ProseMirror input rules. It can be used to automatically replace certain patterns
* while typing.
*
* @returns An array of input rules
*/
inputRules(_options: { inputRules(_options: {
type?: NodeType | MarkType; type?: NodeType | MarkType;
schema: Schema; schema: Schema;
@@ -64,6 +88,12 @@ export default class Extension {
return []; return [];
} }
/**
* A map of ProseMirror commands. It can be used to expose commands to the editor. If a single
* command is returned, it will be available under the extension's name.
*
* @returns An object mapping command names to command factories, or a command factory
*/
commands(_options: { commands(_options: {
type?: NodeType | MarkType; type?: NodeType | MarkType;
schema: Schema; schema: Schema;

View File

@@ -1,4 +1,5 @@
import { PluginSimple } from "markdown-it"; import { PluginSimple } from "markdown-it";
import { observer } from "mobx-react";
import { keymap } from "prosemirror-keymap"; import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown"; import { MarkdownParser } from "prosemirror-markdown";
import { Schema } from "prosemirror-model"; import { Schema } from "prosemirror-model";
@@ -41,8 +42,20 @@ export default class ExtensionManager {
}); });
} }
get nodes() { get widgets() {
return this.extensions return this.extensions
.filter((extension) => extension.widget({ rtl: false }))
.reduce(
(nodes, node: Node) => ({
...nodes,
[node.name]: observer(node.widget as any),
}),
{}
);
}
get nodes() {
const nodes = this.extensions
.filter((extension) => extension.type === "node") .filter((extension) => extension.type === "node")
.reduce( .reduce(
(nodes, node: Node) => ({ (nodes, node: Node) => ({
@@ -51,6 +64,19 @@ export default class ExtensionManager {
}), }),
{} {}
); );
for (const i in nodes) {
if (nodes[i].marks) {
// We must filter marks from the marks list that are not defined
// in the schema for the current editor.
nodes[i].marks = nodes[i].marks
.split(" ")
.filter((m: string) => Object.keys(nodes).includes(m))
.join(" ");
}
}
return nodes;
} }
get marks() { get marks() {

View File

@@ -7,41 +7,16 @@ import {
} from "prosemirror-model"; } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state"; import { Command, TextSelection } from "prosemirror-state";
import { Primitive } from "utility-types"; import { Primitive } from "utility-types";
import Suggestion from "../extensions/Suggestion"; import Extension from "../lib/Extension";
import { getEmojiFromName } from "../lib/emoji"; import { getEmojiFromName } from "../lib/emoji";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { SuggestionsMenuType } from "../plugins/Suggestions";
import emojiRule from "../rules/emoji"; import emojiRule from "../rules/emoji";
/** export default class Emoji extends Extension {
* Languages using the colon character with a space in front in standard
* punctuation. In this case the trigger is only matched once there is additional
* text after the colon.
*/
const languagesUsingColon = ["fr"];
export default class Emoji extends Suggestion {
get type() { get type() {
return "node"; return "node";
} }
get defaultOptions() {
const languageIsUsingColon =
typeof window === "undefined"
? false
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
return {
type: SuggestionsMenuType.Emoji,
openRegex: new RegExp(
`(?:^|\\s):([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
),
closeRegex:
/(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
};
}
get name() { get name() {
return "emoji"; return "emoji";
} }

View File

@@ -7,26 +7,15 @@ import {
} from "prosemirror-model"; } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state"; import { Command, TextSelection } from "prosemirror-state";
import { Primitive } from "utility-types"; import { Primitive } from "utility-types";
import Suggestion from "../extensions/Suggestion"; import Extension from "../lib/Extension";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { SuggestionsMenuType } from "../plugins/Suggestions";
import mentionRule from "../rules/mention"; import mentionRule from "../rules/mention";
export default class Mention extends Suggestion { export default class Mention extends Extension {
get type() { get type() {
return "node"; return "node";
} }
get defaultOptions() {
return {
type: SuggestionsMenuType.Mention,
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
};
}
get name() { get name() {
return "mention"; return "mention";
} }

View File

@@ -1,9 +1,6 @@
import BlockMenu from "../extensions/BlockMenu";
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
import DateTime from "../extensions/DateTime"; import DateTime from "../extensions/DateTime";
import FindAndReplace from "../extensions/FindAndReplace";
import History from "../extensions/History"; import History from "../extensions/History";
import HoverPreviews from "../extensions/HoverPreviews";
import Keys from "../extensions/Keys"; import Keys from "../extensions/Keys";
import MaxLength from "../extensions/MaxLength"; import MaxLength from "../extensions/MaxLength";
import PasteHandler from "../extensions/PasteHandler"; import PasteHandler from "../extensions/PasteHandler";
@@ -109,12 +106,9 @@ export const richExtensions: Nodes = [
TableRow, TableRow,
Highlight, Highlight,
TemplatePlaceholder, TemplatePlaceholder,
BlockMenu,
Math, Math,
MathBlock, MathBlock,
PreventTab, PreventTab,
FindAndReplace,
HoverPreviews,
]; ];
/** /**

View File

@@ -1,32 +1,25 @@
import { action } from "mobx";
import { EditorState, Plugin } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import type { Editor } from "../../../app/editor";
import { EventType } from "../types";
const MAX_MATCH = 500; const MAX_MATCH = 500;
export enum SuggestionsMenuType {
Emoji = "emoji",
Block = "block",
Mention = "mention",
}
type Options = { type Options = {
type: SuggestionsMenuType;
openRegex: RegExp; openRegex: RegExp;
closeRegex: RegExp; closeRegex: RegExp;
enabledInCode: true; enabledInCode: true;
enabledInTable: true; enabledInTable: true;
}; };
type ExtensionState = {
open: boolean;
query: string;
};
export class SuggestionsMenuPlugin extends Plugin { export class SuggestionsMenuPlugin extends Plugin {
constructor(editor: Editor, options: Options) { constructor(options: Options, extensionState: ExtensionState) {
super({ super({
props: { props: {
handleClick: () => {
editor.events.emit(options.type);
return false;
},
handleKeyDown: (view, event) => { handleKeyDown: (view, event) => {
// Prosemirror input rules are not triggered on backspace, however // Prosemirror input rules are not triggered on backspace, however
// we need them to be evaluted for the filter trigger to work // we need them to be evaluted for the filter trigger to work
@@ -41,20 +34,16 @@ export class SuggestionsMenuPlugin extends Plugin {
pos, pos,
pos, pos,
options.openRegex, options.openRegex,
(state, match) => { action((_, match) => {
if (match) { if (match) {
editor.events.emit(EventType.SuggestionsMenuOpen, { extensionState.open = true;
type: options.type, extensionState.query = match[1];
query: match[1],
});
} else { } else {
editor.events.emit( extensionState.open = false;
EventType.SuggestionsMenuClose, extensionState.query = "";
options.type
);
} }
return null; return null;
} })
); );
}); });
} }