From df6d8c12cc16cd98d5976636978af6c365cb3de3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 31 Oct 2023 21:55:55 -0400 Subject: [PATCH] Refactor Editor components to be injected by associated extension (#6093) --- app/components/CollectionDescription.tsx | 12 ++ app/components/Editor.tsx | 24 --- app/components/HoverPreview/HoverPreview.tsx | 6 +- .../Notifications/NotificationListItem.tsx | 1 - app/editor/components/BlockMenu.tsx | 2 +- app/editor/components/EmojiMenu.tsx | 2 +- app/editor/components/MentionMenu.tsx | 2 +- app/editor/components/SuggestionsMenu.tsx | 9 +- .../editor/extensions/BlockMenu.tsx | 50 +++-- app/editor/extensions/EmojiMenu.tsx | 45 +++++ .../editor/extensions/FindAndReplace.tsx | 10 +- .../editor/extensions/HoverPreviews.tsx | 40 ++-- app/editor/extensions/MentionMenu.tsx | 31 ++++ app/editor/extensions/Suggestion.ts | 73 ++++++++ app/editor/index.tsx | 174 ++++-------------- app/editor/menus/block.tsx | 8 - .../Document/components/CommentEditor.tsx | 8 +- app/scenes/Document/components/Editor.tsx | 18 +- shared/editor/extensions/Suggestion.tsx | 65 ------- shared/editor/lib/Extension.ts | 30 +++ shared/editor/lib/ExtensionManager.ts | 28 ++- shared/editor/nodes/Emoji.tsx | 29 +-- shared/editor/nodes/Mention.ts | 15 +- shared/editor/nodes/index.ts | 6 - shared/editor/plugins/Suggestions.ts | 37 ++-- 25 files changed, 371 insertions(+), 354 deletions(-) rename {shared => app}/editor/extensions/BlockMenu.tsx (66%) create mode 100644 app/editor/extensions/EmojiMenu.tsx rename shared/editor/extensions/FindAndReplace.ts => app/editor/extensions/FindAndReplace.tsx (96%) rename shared/editor/extensions/HoverPreviews.ts => app/editor/extensions/HoverPreviews.tsx (62%) create mode 100644 app/editor/extensions/MentionMenu.tsx create mode 100644 app/editor/extensions/Suggestion.ts delete mode 100644 shared/editor/extensions/Suggestion.tsx diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx index 37f953fe4..58cb4c4c0 100644 --- a/app/components/CollectionDescription.tsx +++ b/app/components/CollectionDescription.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled from "styled-components"; +import { richExtensions } from "@shared/editor/nodes"; import { s } from "@shared/styles"; import Collection from "~/models/Collection"; import Arrow from "~/components/Arrow"; @@ -12,9 +13,19 @@ import ButtonLink from "~/components/ButtonLink"; import Editor from "~/components/Editor"; import LoadingIndicator from "~/components/LoadingIndicator"; import NudeButton from "~/components/NudeButton"; +import BlockMenuExtension from "~/editor/extensions/BlockMenu"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; +const extensions = [ + ...richExtensions, + BlockMenuExtension, + EmojiMenuExtension, + HoverPreviewsExtension, +]; + type Props = { collection: Collection; }; @@ -104,6 +115,7 @@ function CollectionDescription({ collection }: Props) { readOnly={!isEditing} autoFocus={isEditing} onBlur={handleStopEditing} + extensions={extensions} maxLength={1000} embedsDisabled canUpdate diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 2fb0c45f0..193faf042 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -19,7 +19,6 @@ import { AttachmentValidation } from "@shared/validations"; import Document from "~/models/Document"; import ClickablePadding from "~/components/ClickablePadding"; import ErrorBoundary from "~/components/ErrorBoundary"; -import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useCurrentUser from "~/hooks/useCurrentUser"; import useDictionary from "~/hooks/useDictionary"; @@ -47,7 +46,6 @@ export type Props = Optional< > & { shareId?: string | undefined; embedsDisabled?: boolean; - previewsDisabled?: boolean; onHeadingsChange?: (headings: Heading[]) => void; onSynced?: () => Promise; onPublish?: (event: React.MouseEvent) => void; @@ -62,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject | null) { onHeadingsChange, onCreateCommentMark, onDeleteCommentMark, - previewsDisabled, } = props; const userLocale = useUserLocale(); const locale = dateLocale(userLocale); @@ -73,22 +70,8 @@ function Editor(props: Props, ref: React.RefObject | null) { const localRef = React.useRef(); const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; const previousHeadings = React.useRef(null); - const [activeLinkElement, setActiveLink] = - React.useState(null); const previousCommentIds = React.useRef(); - const handleLinkActive = React.useCallback( - (element: HTMLAnchorElement | null) => { - setActiveLink(element); - return false; - }, - [] - ); - - const handleLinkInactive = React.useCallback(() => { - setActiveLink(null); - }, []); - const handleSearchLink = React.useCallback( async (term: string) => { if (isInternalUrl(term)) { @@ -339,7 +322,6 @@ function Editor(props: Props, ref: React.RefObject | null) { userPreferences={preferences} dictionary={dictionary} {...props} - onHoverLink={previewsDisabled ? undefined : handleLinkActive} onClickLink={handleClickLink} onSearchLink={handleSearchLink} onChange={handleChange} @@ -354,12 +336,6 @@ function Editor(props: Props, ref: React.RefObject | null) { minHeight={props.editorStyle.paddingBottom} /> )} - {!shareId && ( - - )} ); diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx index 785425289..1e908daf8 100644 --- a/app/components/HoverPreview/HoverPreview.tsx +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -24,7 +24,7 @@ const POINTER_WIDTH = 22; type Props = { /** 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. */ onClose: () => void; }; @@ -35,7 +35,7 @@ enum Direction { } 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 [isVisible, setVisible] = React.useState(false); const timerClose = React.useRef>(); @@ -200,7 +200,7 @@ function useHoverPosition({ isVisible, }: { cardRef: React.RefObject; - element: HTMLAnchorElement | null; + element: HTMLElement | null; isVisible: boolean; }) { const [cardLeft, setCardLeft] = React.useState(0); diff --git a/app/components/Notifications/NotificationListItem.tsx b/app/components/Notifications/NotificationListItem.tsx index 3f7f8e4aa..bab0a78ea 100644 --- a/app/components/Notifications/NotificationListItem.tsx +++ b/app/components/Notifications/NotificationListItem.tsx @@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) { {notification.comment && ( )} diff --git a/app/editor/components/BlockMenu.tsx b/app/editor/components/BlockMenu.tsx index 06d600d3b..0e697a11b 100644 --- a/app/editor/components/BlockMenu.tsx +++ b/app/editor/components/BlockMenu.tsx @@ -10,7 +10,7 @@ type Props = Omit< SuggestionsMenuProps, "renderMenuItem" | "items" | "trigger" > & - Required>; + Required>; function BlockMenu(props: Props) { const dictionary = useDictionary(); diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 5c88588b6..0cdb603c2 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -28,7 +28,7 @@ let searcher: FuzzySearch; type Props = Omit< SuggestionsMenuProps, - "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" + "renderMenuItem" | "items" | "embeds" | "trigger" >; const EmojiMenu = (props: Props) => { diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index af0edc10f..d656d7464 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -33,7 +33,7 @@ interface MentionItem extends MenuItem { type Props = Omit< SuggestionsMenuProps, - "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger" + "renderMenuItem" | "items" | "embeds" | "trigger" >; function MentionMenu({ search, isActive, ...rest }: Props) { diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 1866a65a0..407a75209 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -60,7 +60,6 @@ export type Props = { uploadFile?: (file: File) => Promise; onFileUploadStart?: () => void; onFileUploadStop?: () => void; - onLinkToolbarOpen?: () => void; onClose: (insertNewLine?: boolean) => void; embeds?: EmbedDescriptor[]; renderMenuItem: ( @@ -252,17 +251,11 @@ function SuggestionsMenu(props: Props) { return triggerFilePick("*"); case "embed": return triggerLinkInput(item); - case "link": { - handleClearSearch(); - props.onClose(); - props.onLinkToolbarOpen?.(); - return; - } default: insertNode(item); } }, - [insertNode, handleClearSearch, props] + [insertNode] ); const close = React.useCallback(() => { diff --git a/shared/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx similarity index 66% rename from shared/editor/extensions/BlockMenu.tsx rename to app/editor/extensions/BlockMenu.tsx index f2173239a..8413b1a7a 100644 --- a/shared/editor/extensions/BlockMenu.tsx +++ b/app/editor/extensions/BlockMenu.tsx @@ -1,24 +1,24 @@ +import { action } from "mobx"; import { PlusIcon } from "outline-icons"; import { Plugin } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import * as React from "react"; import ReactDOM from "react-dom"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; -import { findParentNode } from "../queries/findParentNode"; -import { EventType } from "../types"; -import Suggestion from "./Suggestion"; +import { WidgetProps } from "@shared/editor/lib/Extension"; +import { findParentNode } from "@shared/editor/queries/findParentNode"; +import Suggestion from "~/editor/extensions/Suggestion"; +import BlockMenu from "../components/BlockMenu"; -export default class BlockMenu extends Suggestion { +export default class BlockMenuExtension extends Suggestion { get defaultOptions() { return { - type: SuggestionsMenuType.Block, openRegex: /^\/(\w+)?$/, closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/, }; } get name() { - return "blockmenu"; + return "block-menu"; } get plugins() { @@ -54,12 +54,12 @@ export default class BlockMenu extends Suggestion { Decoration.widget( parent.pos, () => { - button.addEventListener("click", () => { - this.editor.events.emit(EventType.SuggestionsMenuOpen, { - type: SuggestionsMenuType.Block, - query: "", - }); - }); + button.addEventListener( + "click", + action(() => { + this.state.open = true; + }) + ); return button; }, { @@ -96,4 +96,28 @@ export default class BlockMenu extends Suggestion { }), ]; } + + widget = ({ rtl }: WidgetProps) => { + const { props, view } = this.editor; + return ( + { + 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} + /> + ); + }; } diff --git a/app/editor/extensions/EmojiMenu.tsx b/app/editor/extensions/EmojiMenu.tsx new file mode 100644 index 000000000..01312f216 --- /dev/null +++ b/app/editor/extensions/EmojiMenu.tsx @@ -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) => ( + { + this.state.open = false; + })} + /> + ); +} diff --git a/shared/editor/extensions/FindAndReplace.ts b/app/editor/extensions/FindAndReplace.tsx similarity index 96% rename from shared/editor/extensions/FindAndReplace.ts rename to app/editor/extensions/FindAndReplace.tsx index f70215989..81e32f778 100644 --- a/shared/editor/extensions/FindAndReplace.ts +++ b/app/editor/extensions/FindAndReplace.tsx @@ -2,12 +2,14 @@ import escapeRegExp from "lodash/escapeRegExp"; import { Node } from "prosemirror-model"; import { Command, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +import * as React from "react"; 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"); -export default class FindAndReplace extends Extension { +export default class FindAndReplaceExtension extends Extension { public get name() { return "find-and-replace"; } @@ -292,6 +294,10 @@ export default class FindAndReplace extends Extension { ]; } + public widget = () => ( + + ); + private results: { from: number; to: number }[] = []; private currentResultIndex = 0; private searchTerm = ""; diff --git a/shared/editor/extensions/HoverPreviews.ts b/app/editor/extensions/HoverPreviews.tsx similarity index 62% rename from shared/editor/extensions/HoverPreviews.ts rename to app/editor/extensions/HoverPreviews.tsx index c3ba34ed2..6ddca9607 100644 --- a/shared/editor/extensions/HoverPreviews.ts +++ b/app/editor/extensions/HoverPreviews.tsx @@ -1,16 +1,22 @@ +import { action, observable } from "mobx"; import { Plugin } from "prosemirror-state"; 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 { - /** 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: number; } export default class HoverPreviews extends Extension { + state: { + activeLinkElement: HTMLElement | null; + } = observable({ + activeLinkElement: null, + }); + get defaultOptions(): HoverPreviewsOptions { return { delay: 500, @@ -38,27 +44,37 @@ export default class HoverPreviews extends Extension { ".use-hover-preview" ); if (isHoverTarget(target, view)) { - if (this.options.onHoverLink) { - hoveringTimeout = setTimeout(() => { - this.options.onHoverLink?.(target); - }, this.options.delay); - } + hoveringTimeout = setTimeout( + action(() => { + this.state.activeLinkElement = target as HTMLElement; + }), + this.options.delay + ); } return false; }, - mouseout: (view: EditorView, event: MouseEvent) => { + mouseout: action((view: EditorView, event: MouseEvent) => { const target = (event.target as HTMLElement)?.closest( ".use-hover-preview" ); if (isHoverTarget(target, view)) { clearTimeout(hoveringTimeout); - this.options.onHoverLink?.(null); + this.state.activeLinkElement = null; } return false; - }, + }), }, }, }), ]; } + + widget = () => ( + { + this.state.activeLinkElement = null; + })} + /> + ); } diff --git a/app/editor/extensions/MentionMenu.tsx b/app/editor/extensions/MentionMenu.tsx new file mode 100644 index 000000000..a6e7f1cdb --- /dev/null +++ b/app/editor/extensions/MentionMenu.tsx @@ -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) => ( + { + this.state.open = false; + })} + /> + ); +} diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts new file mode 100644 index 000000000..e6c328dc2 --- /dev/null +++ b/app/editor/extensions/Suggestion.ts @@ -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; + }) + ), + ]; +} diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 9d98cb318..b58b79fb9 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -26,15 +26,17 @@ import styled, { css, DefaultTheme, ThemeProps } from "styled-components"; import insertFiles from "@shared/editor/commands/insertFiles"; import Styles from "@shared/editor/components/Styles"; 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 { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import textBetween from "@shared/editor/lib/textBetween"; 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 ReactNode from "@shared/editor/nodes/ReactNode"; -import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions"; import { EventType } from "@shared/editor/types"; import { UserPreferences } from "@shared/types"; import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper"; @@ -43,21 +45,13 @@ import Flex from "~/components/Flex"; import { PortalContext } from "~/components/Portal"; import { Dictionary } from "~/hooks/useDictionary"; import Logger from "~/utils/Logger"; -import BlockMenu from "./components/BlockMenu"; import ComponentView from "./components/ComponentView"; import EditorContext from "./components/EditorContext"; -import EmojiMenu from "./components/EmojiMenu"; -import FindAndReplace from "./components/FindAndReplace"; import { SearchResult } from "./components/LinkEditor"; import LinkToolbar from "./components/LinkToolbar"; -import MentionMenu from "./components/MentionMenu"; import SelectionToolbar from "./components/SelectionToolbar"; import WithTheme from "./components/WithTheme"; -const extensions = withComments(richExtensions); - -export { default as Extension } from "@shared/editor/lib/Extension"; - export type Props = { /** An optional identifier for the editor context. It is used to persist local settings */ id?: string; @@ -124,8 +118,6 @@ export type Props = { href: string, event: MouseEvent | React.MouseEvent ) => 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 */ onKeyDown?: (event: React.KeyboardEvent) => void; /** Collection of embed types to render in the document */ @@ -148,12 +140,8 @@ type State = { isEditorFocused: boolean; /** If the toolbar for a text selection is visible */ selectionToolbarOpen: boolean; - /** If a suggestions menu is visible */ - suggestionsMenuOpen: SuggestionsMenuType | false; /** If the insert link toolbar is visible */ linkToolbarOpen: boolean; - /** The query for the suggestion menu */ - query: string; }; /** @@ -182,10 +170,8 @@ export class Editor extends React.PureComponent< state: State = { isRTL: false, isEditorFocused: false, - suggestionsMenuOpen: false, selectionToolbarOpen: false, linkToolbarOpen: false, - query: "", }; isBlurred = true; @@ -204,6 +190,7 @@ export class Editor extends React.PureComponent< [name: string]: NodeViewConstructor; }; + widgets: { [name: string]: (props: WidgetProps) => React.ReactElement }; nodes: { [name: string]: NodeSpec }; marks: { [name: string]: MarkSpec }; commands: Record; @@ -214,14 +201,6 @@ export class Editor extends React.PureComponent< public constructor(props: Props & ThemeProps) { super(props); 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 ( !this.isBlurred && !this.state.isEditorFocused && - !this.state.suggestionsMenuOpen && !this.state.linkToolbarOpen && !this.state.selectionToolbarOpen ) { @@ -290,7 +268,6 @@ export class Editor extends React.PureComponent< if ( this.isBlurred && (this.state.isEditorFocused || - this.state.suggestionsMenuOpen || this.state.linkToolbarOpen || this.state.selectionToolbarOpen) ) { @@ -310,6 +287,7 @@ export class Editor extends React.PureComponent< this.nodes = this.createNodes(); this.marks = this.createMarks(); this.schema = this.createSchema(); + this.widgets = this.createWidgets(); this.plugins = this.createPlugins(); this.rulePlugins = this.createRulePlugins(); this.keymaps = this.createKeymaps(); @@ -378,6 +356,10 @@ export class Editor extends React.PureComponent< }); } + private createWidgets() { + return this.extensions.widgets; + } + private createNodes() { return this.extensions.nodes; } @@ -702,8 +684,6 @@ export class Editor extends React.PureComponent< this.setState((state) => ({ ...state, selectionToolbarOpen: true, - suggestionsMenuOpen: false, - query: "", })); }; @@ -720,9 +700,7 @@ export class Editor extends React.PureComponent< private handleOpenLinkToolbar = () => { this.setState((state) => ({ ...state, - suggestionsMenuOpen: false, 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() { const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } = this.props; @@ -792,84 +739,31 @@ export class Editor extends React.PureComponent< ref={this.elementRef} /> {this.view && ( - <> - - {this.commands.find && } - + )} - {!readOnly && this.view && ( - <> - {this.marks.link && ( - - )} - {this.nodes.emoji && ( - - this.handleCloseSuggestionsMenu( - SuggestionsMenuType.Emoji, - insertNewLine - ) - } - /> - )} - {this.nodes.mention && ( - - this.handleCloseSuggestionsMenu( - SuggestionsMenuType.Mention, - 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} - /> - + {!readOnly && this.view && this.marks.link && ( + )} + {this.widgets && + Object.values(this.widgets).map((Widget, index) => ( + + ))} diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx index f795fc56d..301c2610d 100644 --- a/app/editor/menus/block.tsx +++ b/app/editor/menus/block.tsx @@ -14,7 +14,6 @@ import { StarredIcon, WarningIcon, InfoIcon, - LinkIcon, AttachmentIcon, ClockIcon, CalendarIcon, @@ -95,13 +94,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] { icon: , keywords: "picture photo", }, - { - name: "link", - title: dictionary.link, - icon: , - shortcut: `${metaDisplay} k`, - keywords: "link url uri href", - }, { name: "video", title: dictionary.video, diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx index de9e8e70c..4b74b7821 100644 --- a/app/scenes/Document/components/CommentEditor.tsx +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -2,8 +2,14 @@ import * as React from "react"; import { basicExtensions, withComments } from "@shared/editor/nodes"; import Editor, { Props as EditorProps } from "~/components/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 = ( props: EditorProps, diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 1c638ac74..4a24fcfc9 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -8,8 +8,14 @@ import { TeamPreference } from "@shared/types"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; import { RefHandle } from "~/components/ContentEditable"; +import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; 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 useCurrentUser from "~/hooks/useCurrentUser"; import useFocusedComment from "~/hooks/useFocusedComment"; @@ -20,14 +26,20 @@ import { documentPath, matchDocumentHistory, } from "~/utils/routeHelpers"; -import { useDocumentContext } from "../../../components/DocumentContext"; import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; import DocumentTitle from "./DocumentTitle"; -const extensions = withComments(richExtensions); +const extensions = [ + ...withComments(richExtensions), + BlockMenuExtension, + EmojiMenuExtension, + MentionMenuExtension, + FindAndReplaceExtension, + HoverPreviewsExtension, +]; -type Props = Omit & { +type Props = Omit & { onChangeTitle: (title: string) => void; onChangeEmoji: (emoji: string | null) => void; id: string; diff --git a/shared/editor/extensions/Suggestion.tsx b/shared/editor/extensions/Suggestion.tsx deleted file mode 100644 index 916b0ae59..000000000 --- a/shared/editor/extensions/Suggestion.tsx +++ /dev/null @@ -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; - }), - ]; -} diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index 03f7ee01d..90552d832 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -7,6 +7,8 @@ import { Editor } from "../../../app/editor"; export type CommandFactory = (attrs?: Record) => Command; +export type WidgetProps = { rtl: boolean }; + export default class Extension { options: any; editor: Editor; @@ -50,6 +52,22 @@ export default class Extension { 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: { type?: NodeType | MarkType; schema: Schema; @@ -57,6 +75,12 @@ export default class Extension { 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: { type?: NodeType | MarkType; schema: Schema; @@ -64,6 +88,12 @@ export default class Extension { 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: { type?: NodeType | MarkType; schema: Schema; diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 27d083e22..b0d418766 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -1,4 +1,5 @@ import { PluginSimple } from "markdown-it"; +import { observer } from "mobx-react"; import { keymap } from "prosemirror-keymap"; import { MarkdownParser } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; @@ -41,8 +42,20 @@ export default class ExtensionManager { }); } - get nodes() { + get widgets() { 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") .reduce( (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() { diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index 3f5845c10..5ba8bb9e8 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -7,41 +7,16 @@ import { } from "prosemirror-model"; import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; -import Suggestion from "../extensions/Suggestion"; +import Extension from "../lib/Extension"; import { getEmojiFromName } from "../lib/emoji"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; import emojiRule from "../rules/emoji"; -/** - * 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 { +export default class Emoji extends Extension { get type() { 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() { return "emoji"; } diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts index 18fef134f..761fdabb5 100644 --- a/shared/editor/nodes/Mention.ts +++ b/shared/editor/nodes/Mention.ts @@ -7,26 +7,15 @@ import { } from "prosemirror-model"; import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; -import Suggestion from "../extensions/Suggestion"; +import Extension from "../lib/Extension"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import { SuggestionsMenuType } from "../plugins/Suggestions"; import mentionRule from "../rules/mention"; -export default class Mention extends Suggestion { +export default class Mention extends Extension { get type() { 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() { return "mention"; } diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 917aa4deb..797c5f99b 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -1,9 +1,6 @@ -import BlockMenu from "../extensions/BlockMenu"; import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import DateTime from "../extensions/DateTime"; -import FindAndReplace from "../extensions/FindAndReplace"; import History from "../extensions/History"; -import HoverPreviews from "../extensions/HoverPreviews"; import Keys from "../extensions/Keys"; import MaxLength from "../extensions/MaxLength"; import PasteHandler from "../extensions/PasteHandler"; @@ -109,12 +106,9 @@ export const richExtensions: Nodes = [ TableRow, Highlight, TemplatePlaceholder, - BlockMenu, Math, MathBlock, PreventTab, - FindAndReplace, - HoverPreviews, ]; /** diff --git a/shared/editor/plugins/Suggestions.ts b/shared/editor/plugins/Suggestions.ts index 5d04ae99f..dbfd8d1ef 100644 --- a/shared/editor/plugins/Suggestions.ts +++ b/shared/editor/plugins/Suggestions.ts @@ -1,32 +1,25 @@ +import { action } from "mobx"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import type { Editor } from "../../../app/editor"; -import { EventType } from "../types"; const MAX_MATCH = 500; -export enum SuggestionsMenuType { - Emoji = "emoji", - Block = "block", - Mention = "mention", -} - type Options = { - type: SuggestionsMenuType; openRegex: RegExp; closeRegex: RegExp; enabledInCode: true; enabledInTable: true; }; +type ExtensionState = { + open: boolean; + query: string; +}; + export class SuggestionsMenuPlugin extends Plugin { - constructor(editor: Editor, options: Options) { + constructor(options: Options, extensionState: ExtensionState) { super({ props: { - handleClick: () => { - editor.events.emit(options.type); - return false; - }, handleKeyDown: (view, event) => { // Prosemirror input rules are not triggered on backspace, however // we need them to be evaluted for the filter trigger to work @@ -41,20 +34,16 @@ export class SuggestionsMenuPlugin extends Plugin { pos, pos, options.openRegex, - (state, match) => { + action((_, match) => { if (match) { - editor.events.emit(EventType.SuggestionsMenuOpen, { - type: options.type, - query: match[1], - }); + extensionState.open = true; + extensionState.query = match[1]; } else { - editor.events.emit( - EventType.SuggestionsMenuClose, - options.type - ); + extensionState.open = false; + extensionState.query = ""; } return null; - } + }) ); }); }