From 6f2a4488e85d27a9f4f6f4887f2e596513002f85 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 30 Mar 2022 19:10:34 -0700 Subject: [PATCH] chore: Editor refactor (#3286) * cleanup * add context * EventEmitter allows removal of toolbar props from extensions * Move to 'packages' of extensions Remove EmojiTrigger extension * types * iteration * fix render flashing * fix: Missing nodes in collection descriptions --- app/components/Editor.tsx | 18 +- app/editor/components/BlockMenu.tsx | 20 +- app/editor/components/CommandMenu.tsx | 9 +- app/editor/components/EditorContext.tsx | 8 + app/editor/components/EmojiMenu.tsx | 23 +- app/editor/components/LinkEditor.tsx | 3 +- app/editor/components/LinkToolbar.tsx | 2 +- app/editor/components/SelectionToolbar.tsx | 2 +- app/editor/components/ToolbarMenu.tsx | 10 +- app/editor/index.tsx | 486 +++++++----------- app/scenes/Document/components/Editor.tsx | 4 +- .../Document/components/MultiplayerEditor.tsx | 15 +- server/editor/index.ts | 70 +-- shared/editor/commands/createAndInsertLink.ts | 8 +- shared/editor/commands/insertFiles.ts | 8 +- shared/editor/lib/ExtensionManager.ts | 31 +- shared/editor/marks/Link.tsx | 4 +- shared/editor/nodes/CodeFence.ts | 15 +- shared/editor/nodes/Emoji.tsx | 83 ++- shared/editor/nodes/Heading.ts | 6 +- shared/editor/nodes/TableCell.ts | 6 +- shared/editor/nodes/TableHeadCell.ts | 8 +- shared/editor/packages/README.md | 2 + shared/editor/packages/basic.ts | 44 ++ shared/editor/packages/full.ts | 52 ++ shared/editor/plugins/BlockMenuTrigger.tsx | 13 +- shared/editor/plugins/EmojiTrigger.tsx | 93 ---- shared/editor/plugins/Keys.ts | 16 +- shared/editor/types/index.ts | 10 +- shared/utils/events.ts | 29 ++ 30 files changed, 517 insertions(+), 581 deletions(-) create mode 100644 app/editor/components/EditorContext.tsx create mode 100644 shared/editor/packages/README.md create mode 100644 shared/editor/packages/basic.ts create mode 100644 shared/editor/packages/full.ts delete mode 100644 shared/editor/plugins/EmojiTrigger.tsx create mode 100644 shared/utils/events.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 63d2fe9e0..de61e1d8b 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -3,7 +3,7 @@ import { Optional } from "utility-types"; import embeds from "@shared/editor/embeds"; import { isInternalUrl } from "@shared/utils/urls"; import ErrorBoundary from "~/components/ErrorBoundary"; -import { Props as EditorProps } from "~/editor"; +import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; import useToasts from "~/hooks/useToasts"; import { uploadFile } from "~/utils/files"; @@ -11,7 +11,7 @@ import history from "~/utils/history"; import { isModKey } from "~/utils/keyboard"; import { isHash } from "~/utils/urls"; -const SharedEditor = React.lazy( +const LazyLoadedEditor = React.lazy( () => import( /* webpackChunkName: "shared-editor" */ @@ -27,6 +27,7 @@ export type Props = Optional< | "embeds" | "dictionary" | "onShowToast" + | "extensions" > & { shareId?: string | undefined; embedsDisabled?: boolean; @@ -35,7 +36,7 @@ export type Props = Optional< onPublish?: (event: React.MouseEvent) => any; }; -function Editor(props: Props, ref: React.Ref) { +function Editor(props: Props, ref: React.Ref) { const { id, shareId } = props; const { showToast } = useToasts(); const dictionary = useDictionary(); @@ -84,19 +85,12 @@ function Editor(props: Props, ref: React.Ref) { [shareId] ); - const onShowToast = React.useCallback( - (message: string) => { - showToast(message); - }, - [showToast] - ); - return ( - { - return ( - - ); - }} + renderMenuItem={(item, _index, options) => ( + + )} items={getMenuItems(props.dictionary)} /> ); diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index 2ace5b25e..34b7da502 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -9,7 +9,7 @@ import styled from "styled-components"; import insertFiles from "@shared/editor/commands/insertFiles"; import { CommandFactory } from "@shared/editor/lib/Extension"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; -import { EmbedDescriptor, MenuItem, ToastType } from "@shared/editor/types"; +import { EmbedDescriptor, MenuItem } from "@shared/editor/types"; import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import { Dictionary } from "~/hooks/useDictionary"; import Input from "./Input"; @@ -31,7 +31,7 @@ export type Props = { uploadFile?: (file: File) => Promise; onFileUploadStart?: () => void; onFileUploadStop?: () => void; - onShowToast: (message: string, id: string) => void; + onShowToast: (message: string) => void; onLinkToolbarOpen?: () => void; onClose: () => void; onClearSearch: () => void; @@ -216,10 +216,7 @@ class CommandMenu extends React.Component, State> { const matches = this.state.insertItem.matcher(href); if (!matches) { - this.props.onShowToast( - this.props.dictionary.embedInvalidLink, - ToastType.Error - ); + this.props.onShowToast(this.props.dictionary.embedInvalidLink); return; } diff --git a/app/editor/components/EditorContext.tsx b/app/editor/components/EditorContext.tsx new file mode 100644 index 000000000..2a7b9a58c --- /dev/null +++ b/app/editor/components/EditorContext.tsx @@ -0,0 +1,8 @@ +import * as React from "react"; +import { Editor } from "../"; + +const EditorContext = React.createContext({} as Editor); + +export const useEditor = () => React.useContext(EditorContext); + +export default EditorContext; diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 5e58c5ca6..9e14c561e 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -64,23 +64,22 @@ class EmojiMenu extends React.Component< }; render() { + const containerId = "emoji-menu-container"; return ( { - return ( - - ); - }} + renderMenuItem={(item, _index, options) => ( + + )} items={this.items} /> ); diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index 28b0aea58..97a02228e 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -15,6 +15,7 @@ import isUrl from "@shared/editor/lib/isUrl"; import { isInternalUrl } from "@shared/utils/urls"; import Flex from "~/components/Flex"; import { Dictionary } from "~/hooks/useDictionary"; +import { ToastOptions } from "~/types"; import Input from "./Input"; import LinkSearchResult from "./LinkSearchResult"; import ToolbarButton from "./ToolbarButton"; @@ -44,7 +45,7 @@ type Props = { href: string, event: React.MouseEvent ) => void; - onShowToast: (message: string, code: string) => void; + onShowToast: (message: string, options: ToastOptions) => void; view: EditorView; }; diff --git a/app/editor/components/LinkToolbar.tsx b/app/editor/components/LinkToolbar.tsx index 49dd3dcb8..d877a4015 100644 --- a/app/editor/components/LinkToolbar.tsx +++ b/app/editor/components/LinkToolbar.tsx @@ -15,7 +15,7 @@ type Props = { href: string, event: React.MouseEvent ) => void; - onShowToast: (msg: string, code: string) => void; + onShowToast: (message: string) => void; onClose: () => void; }; diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 55e1ec598..be14f1da8 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -36,7 +36,7 @@ type Props = { event: MouseEvent | React.MouseEvent ) => void; onCreateLink?: (title: string) => Promise; - onShowToast: (msg: string, code: string) => void; + onShowToast: (message: string) => void; view: EditorView; }; diff --git a/app/editor/components/ToolbarMenu.tsx b/app/editor/components/ToolbarMenu.tsx index 670a9dd2a..8826e34da 100644 --- a/app/editor/components/ToolbarMenu.tsx +++ b/app/editor/components/ToolbarMenu.tsx @@ -1,15 +1,12 @@ -import { EditorView } from "prosemirror-view"; import * as React from "react"; import styled from "styled-components"; -import { CommandFactory } from "@shared/editor/lib/Extension"; import { MenuItem } from "@shared/editor/types"; +import { useEditor } from "./EditorContext"; import ToolbarButton from "./ToolbarButton"; import ToolbarSeparator from "./ToolbarSeparator"; import Tooltip from "./Tooltip"; type Props = { - commands: Record; - view: EditorView; items: MenuItem[]; }; @@ -20,7 +17,8 @@ const FlexibleWrapper = styled.div` `; function ToolbarMenu(props: Props) { - const { view, items } = props; + const { commands, view } = useEditor(); + const { items } = props; const { state } = view; return ( @@ -38,7 +36,7 @@ function ToolbarMenu(props: Props) { return ( item.name && props.commands[item.name](item.attrs)} + onClick={() => item.name && commands[item.name](item.attrs)} active={isActive} > diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 69d4bfe8a..21cfc9d73 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -6,9 +6,13 @@ import { gapCursor } from "prosemirror-gapcursor"; import { inputRules, InputRule } from "prosemirror-inputrules"; import { keymap } from "prosemirror-keymap"; import { MarkdownParser } from "prosemirror-markdown"; -import { Schema, NodeSpec, MarkSpec, Node } from "prosemirror-model"; +import { + Schema, + NodeSpec, + MarkSpec, + Node as ProsemirrorNode, +} from "prosemirror-model"; import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state"; -import { selectColumn, selectRow, selectTable } from "prosemirror-utils"; import { Decoration, EditorView } from "prosemirror-view"; import * as React from "react"; import { DefaultTheme, ThemeProps } from "styled-components"; @@ -16,59 +20,17 @@ import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import headingToSlug from "@shared/editor/lib/headingToSlug"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; - -// marks -import Bold from "@shared/editor/marks/Bold"; -import Code from "@shared/editor/marks/Code"; -import Highlight from "@shared/editor/marks/Highlight"; -import Italic from "@shared/editor/marks/Italic"; -import Link from "@shared/editor/marks/Link"; -import TemplatePlaceholder from "@shared/editor/marks/Placeholder"; -import Strikethrough from "@shared/editor/marks/Strikethrough"; -import Underline from "@shared/editor/marks/Underline"; - -// nodes -import Attachment from "@shared/editor/nodes/Attachment"; -import Blockquote from "@shared/editor/nodes/Blockquote"; -import BulletList from "@shared/editor/nodes/BulletList"; -import CheckboxItem from "@shared/editor/nodes/CheckboxItem"; -import CheckboxList from "@shared/editor/nodes/CheckboxList"; -import CodeBlock from "@shared/editor/nodes/CodeBlock"; -import CodeFence from "@shared/editor/nodes/CodeFence"; -import Doc from "@shared/editor/nodes/Doc"; -import Embed from "@shared/editor/nodes/Embed"; -import Emoji from "@shared/editor/nodes/Emoji"; -import HardBreak from "@shared/editor/nodes/HardBreak"; -import Heading from "@shared/editor/nodes/Heading"; -import HorizontalRule from "@shared/editor/nodes/HorizontalRule"; -import Image from "@shared/editor/nodes/Image"; -import ListItem from "@shared/editor/nodes/ListItem"; -import Notice from "@shared/editor/nodes/Notice"; -import OrderedList from "@shared/editor/nodes/OrderedList"; -import Paragraph from "@shared/editor/nodes/Paragraph"; +import Mark from "@shared/editor/marks/Mark"; +import Node from "@shared/editor/nodes/Node"; import ReactNode from "@shared/editor/nodes/ReactNode"; -import Table from "@shared/editor/nodes/Table"; -import TableCell from "@shared/editor/nodes/TableCell"; -import TableHeadCell from "@shared/editor/nodes/TableHeadCell"; -import TableRow from "@shared/editor/nodes/TableRow"; -import Text from "@shared/editor/nodes/Text"; - -// plugins -import BlockMenuTrigger from "@shared/editor/plugins/BlockMenuTrigger"; -import EmojiTrigger from "@shared/editor/plugins/EmojiTrigger"; -import Folding from "@shared/editor/plugins/Folding"; -import History from "@shared/editor/plugins/History"; -import Keys from "@shared/editor/plugins/Keys"; -import MaxLength from "@shared/editor/plugins/MaxLength"; -import PasteHandler from "@shared/editor/plugins/PasteHandler"; -import Placeholder from "@shared/editor/plugins/Placeholder"; -import SmartText from "@shared/editor/plugins/SmartText"; -import TrailingNode from "@shared/editor/plugins/TrailingNode"; -import { EmbedDescriptor, ToastType } from "@shared/editor/types"; +import fullExtensionsPackage from "@shared/editor/packages/full"; +import { EmbedDescriptor, EventType } from "@shared/editor/types"; +import EventEmitter from "@shared/utils/events"; import Flex from "~/components/Flex"; import { Dictionary } from "~/hooks/useDictionary"; import BlockMenu from "./components/BlockMenu"; import ComponentView from "./components/ComponentView"; +import EditorContext from "./components/EditorContext"; import EmojiMenu from "./components/EmojiMenu"; import { SearchResult } from "./components/LinkEditor"; import LinkToolbar from "./components/LinkToolbar"; @@ -87,8 +49,8 @@ export type Props = { defaultValue: string; /** Placeholder displayed when the editor is empty */ placeholder: string; - /** Additional extensions to load into the editor */ - extensions?: Extension[]; + /** Extensions to load into the editor */ + extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[]; /** If the editor should be focused on mount */ autoFocus?: boolean; /** If the editor should not allow editing */ @@ -143,7 +105,7 @@ export type Props = { /** Whether embeds should be rendered without an iframe */ embedsDisabled?: boolean; /** Callback when a toast message is triggered (eg "link copied") */ - onShowToast: (message: string, code: ToastType) => void; + onShowToast: (message: string) => void; className?: string; style?: React.CSSProperties; }; @@ -185,7 +147,7 @@ export class Editor extends React.PureComponent< // no default behavior }, embeds: [], - extensions: [], + extensions: fullExtensionsPackage, }; state = { @@ -200,7 +162,7 @@ export class Editor extends React.PureComponent< isBlurred: boolean; extensions: ExtensionManager; - element?: HTMLElement | null; + element = React.createRef(); view: EditorView; schema: Schema; serializer: MarkdownSerializer; @@ -211,7 +173,7 @@ export class Editor extends React.PureComponent< inputRules: InputRule[]; nodeViews: { [name: string]: ( - node: Node, + node: ProsemirrorNode, view: EditorView, getPos: () => number, decorations: Decoration<{ @@ -224,8 +186,23 @@ export class Editor extends React.PureComponent< marks: { [name: string]: MarkSpec }; commands: Record; rulePlugins: PluginSimple[]; + events = new EventEmitter(); - componentDidMount() { + public constructor(props: Props & ThemeProps) { + super(props); + this.events.on(EventType.linkMenuOpen, this.handleOpenLinkMenu); + this.events.on(EventType.linkMenuClose, this.handleCloseLinkMenu); + this.events.on(EventType.blockMenuOpen, this.handleOpenBlockMenu); + this.events.on(EventType.blockMenuClose, this.handleCloseBlockMenu); + this.events.on(EventType.emojiMenuOpen, this.handleOpenEmojiMenu); + this.events.on(EventType.emojiMenuClose, this.handleCloseEmojiMenu); + } + + /** + * We use componentDidMount instead of constructor as the init method requires + * that the dom is already mounted. + */ + public componentDidMount() { this.init(); if (this.props.scrollTo) { @@ -243,7 +220,7 @@ export class Editor extends React.PureComponent< } } - componentDidUpdate(prevProps: Props) { + public componentDidUpdate(prevProps: Props) { // Allow changes to the 'value' prop to update the editor from outside if (this.props.value && prevProps.value !== this.props.value) { const newState = this.createState(this.props.value); @@ -280,9 +257,7 @@ export class Editor extends React.PureComponent< !this.state.selectionMenuOpen ) { this.isBlurred = true; - if (this.props.onBlur) { - this.props.onBlur(); - } + this.props.onBlur?.(); } if ( @@ -293,13 +268,11 @@ export class Editor extends React.PureComponent< this.state.selectionMenuOpen) ) { this.isBlurred = false; - if (this.props.onFocus) { - this.props.onFocus(); - } + this.props.onFocus?.(); } } - init() { + private init() { this.extensions = this.createExtensions(); this.nodes = this.createNodes(); this.marks = this.createMarks(); @@ -316,138 +289,36 @@ export class Editor extends React.PureComponent< this.commands = this.createCommands(); } - createExtensions() { - const { dictionary } = this.props; - - // adding nodes here? Update server/editor/renderToHtml.ts for serialization - // on the server - return new ExtensionManager( - [ - ...[ - new Doc(), - new HardBreak(), - new Paragraph(), - new Blockquote(), - new CodeBlock({ - dictionary, - onShowToast: this.props.onShowToast, - }), - new CodeFence({ - dictionary, - onShowToast: this.props.onShowToast, - }), - new Emoji(), - new Text(), - new CheckboxList(), - new CheckboxItem(), - new BulletList(), - new Embed({ embeds: this.props.embeds }), - new ListItem(), - new Attachment({ - dictionary, - }), - new Notice({ - dictionary, - }), - new Heading({ - dictionary, - onShowToast: this.props.onShowToast, - }), - new HorizontalRule(), - new Image({ - dictionary, - uploadFile: this.props.uploadFile, - onFileUploadStart: this.props.onFileUploadStart, - onFileUploadStop: this.props.onFileUploadStop, - onShowToast: this.props.onShowToast, - }), - new Table(), - new TableCell({ - onSelectTable: this.handleSelectTable, - onSelectRow: this.handleSelectRow, - }), - new TableHeadCell({ - onSelectColumn: this.handleSelectColumn, - }), - new TableRow(), - new Bold(), - new Code(), - new Highlight(), - new Italic(), - new TemplatePlaceholder(), - new Underline(), - new Link({ - onKeyboardShortcut: this.handleOpenLinkMenu, - onClickLink: this.props.onClickLink, - onClickHashtag: this.props.onClickHashtag, - onHoverLink: this.props.onHoverLink, - }), - new Strikethrough(), - new OrderedList(), - new History(), - new Folding(), - new SmartText(), - new TrailingNode(), - new PasteHandler(), - new Keys({ - onBlur: this.handleEditorBlur, - onFocus: this.handleEditorFocus, - onSave: this.handleSave, - onSaveAndExit: this.handleSaveAndExit, - onCancel: this.props.onCancel, - }), - new BlockMenuTrigger({ - dictionary, - onOpen: this.handleOpenBlockMenu, - onClose: this.handleCloseBlockMenu, - }), - new EmojiTrigger({ - onOpen: (search: string) => { - this.setState({ emojiMenuOpen: true, blockMenuSearch: search }); - }, - onClose: () => { - this.setState({ emojiMenuOpen: false }); - }, - }), - new Placeholder({ - placeholder: this.props.placeholder, - }), - new MaxLength({ - maxLength: this.props.maxLength, - }), - ], - ...(this.props.extensions || []), - ], - this - ); + private createExtensions() { + return new ExtensionManager(this.props.extensions, this); } - createPlugins() { + private createPlugins() { return this.extensions.plugins; } - createRulePlugins() { + private createRulePlugins() { return this.extensions.rulePlugins; } - createKeymaps() { + private createKeymaps() { return this.extensions.keymaps({ schema: this.schema, }); } - createInputRules() { + private createInputRules() { return this.extensions.inputRules({ schema: this.schema, }); } - createNodeViews() { + private createNodeViews() { return this.extensions.extensions .filter((extension: ReactNode) => extension.component) .reduce((nodeViews, extension: ReactNode) => { const nodeView = ( - node: Node, + node: ProsemirrorNode, view: EditorView, getPos: () => number, decorations: Decoration<{ @@ -471,40 +342,40 @@ export class Editor extends React.PureComponent< }, {}); } - createCommands() { + private createCommands() { return this.extensions.commands({ schema: this.schema, view: this.view, }); } - createNodes() { + private createNodes() { return this.extensions.nodes; } - createMarks() { + private createMarks() { return this.extensions.marks; } - createSchema() { + private createSchema() { return new Schema({ nodes: this.nodes, marks: this.marks, }); } - createSerializer() { + private createSerializer() { return this.extensions.serializer(); } - createParser() { + private createParser() { return this.extensions.parser({ schema: this.schema, plugins: this.rulePlugins, }); } - createPasteParser() { + private createPasteParser() { return this.extensions.parser({ schema: this.schema, rules: { linkify: true, emoji: false }, @@ -512,7 +383,7 @@ export class Editor extends React.PureComponent< }); } - createState(value?: string) { + private createState(value?: string) { const doc = this.createDocument(value || this.props.defaultValue); return EditorState.create({ @@ -531,12 +402,12 @@ export class Editor extends React.PureComponent< }); } - createDocument(content: string) { + private createDocument(content: string) { return this.parser.parse(content); } - createView() { - if (!this.element) { + private createView() { + if (!this.element.current) { throw new Error("createView called before ref available"); } @@ -549,7 +420,11 @@ export class Editor extends React.PureComponent< }; const self = this; // eslint-disable-line - const view = new EditorView(this.element, { + const view = new EditorView(this.element.current, { + handleDOMEvents: { + blur: this.handleEditorBlur, + focus: this.handleEditorFocus, + }, state: this.createState(this.props.value), editable: () => !this.props.readOnly, nodeViews: this.nodeViews, @@ -587,7 +462,7 @@ export class Editor extends React.PureComponent< return view; } - scrollToAnchor(hash: string) { + public scrollToAnchor(hash: string) { if (!hash) { return; } @@ -605,25 +480,25 @@ export class Editor extends React.PureComponent< } } - calculateDir = () => { - if (!this.element) { + private calculateDir = () => { + if (!this.element.current) { return; } const isRTL = this.props.dir === "rtl" || - getComputedStyle(this.element).direction === "rtl"; + getComputedStyle(this.element.current).direction === "rtl"; if (this.state.isRTL !== isRTL) { this.setState({ isRTL }); } }; - value = (): string => { + public value = (): string => { return this.serializer.serialize(this.view.state.doc); }; - handleChange = () => { + private handleChange = () => { if (!this.props.onChange) { return; } @@ -633,83 +508,72 @@ export class Editor extends React.PureComponent< }); }; - handleSave = () => { - const { onSave } = this.props; - if (onSave) { - onSave({ done: false }); - } - }; - - handleSaveAndExit = () => { - const { onSave } = this.props; - if (onSave) { - onSave({ done: true }); - } - }; - - handleEditorBlur = () => { + private handleEditorBlur = () => { this.setState({ isEditorFocused: false }); + return false; }; - handleEditorFocus = () => { + private handleEditorFocus = () => { this.setState({ isEditorFocused: true }); + return false; }; - handleOpenSelectionMenu = () => { + private handleOpenSelectionMenu = () => { this.setState({ blockMenuOpen: false, selectionMenuOpen: true }); }; - handleCloseSelectionMenu = () => { + private handleCloseSelectionMenu = () => { + if (!this.state.selectionMenuOpen) { + return; + } this.setState({ selectionMenuOpen: false }); }; - handleOpenLinkMenu = () => { + private handleOpenEmojiMenu = (search: string) => { + this.setState({ emojiMenuOpen: true, blockMenuSearch: search }); + }; + + private handleCloseEmojiMenu = () => { + if (!this.state.emojiMenuOpen) { + return; + } + this.setState({ emojiMenuOpen: false }); + }; + + private handleOpenLinkMenu = () => { this.setState({ blockMenuOpen: false, linkMenuOpen: true }); }; - handleCloseLinkMenu = () => { + private handleCloseLinkMenu = () => { this.setState({ linkMenuOpen: false }); }; - handleOpenBlockMenu = (search: string) => { + private handleOpenBlockMenu = (search: string) => { this.setState({ blockMenuOpen: true, blockMenuSearch: search }); }; - handleCloseBlockMenu = () => { + private handleCloseBlockMenu = () => { if (!this.state.blockMenuOpen) { return; } this.setState({ blockMenuOpen: false }); }; - handleSelectRow = (index: number, state: EditorState) => { - this.view.dispatch(selectRow(index)(state.tr)); - }; - - handleSelectColumn = (index: number, state: EditorState) => { - this.view.dispatch(selectColumn(index)(state.tr)); - }; - - handleSelectTable = (state: EditorState) => { - this.view.dispatch(selectTable(state.tr)); - }; - - // 'public' methods - focusAtStart = () => { + public focusAtStart = () => { const selection = Selection.atStart(this.view.state.doc); const transaction = this.view.state.tr.setSelection(selection); this.view.dispatch(transaction); this.view.focus(); }; - focusAtEnd = () => { + public focusAtEnd = () => { const selection = Selection.atEnd(this.view.state.doc); const transaction = this.view.state.tr.setSelection(selection); this.view.dispatch(transaction); this.view.focus(); }; - getHeadings = () => { + public getHeadings = () => { const headings: { title: string; level: number; id: string }[] = []; const previouslySeen = {}; @@ -740,7 +604,7 @@ export class Editor extends React.PureComponent< return headings; }; - render() { + public render() { const { dir, readOnly, @@ -754,86 +618,90 @@ export class Editor extends React.PureComponent< const { isRTL } = this.state; return ( - - + (this.element = ref)} - /> - {!readOnly && this.view && ( - - - - this.setState({ emojiMenuOpen: false })} - /> - - - )} - + column + > + + {!readOnly && this.view && ( + <> + + + + + + )} + + ); } } -const EditorWithTheme = React.forwardRef((props: Props, ref) => { - return ( - - {(theme) => } - - ); -}); +const LazyLoadedEditor = React.forwardRef( + (props: Props, ref) => { + return ( + + {(theme) => } + + ); + } +); -export default EditorWithTheme; +export default LazyLoadedEditor; diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 9a4d9175a..2082d2745 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useRouteMatch } from "react-router-dom"; +import fullPackage from "@shared/editor/packages/full"; import Document from "~/models/Document"; import ClickablePadding from "~/components/ClickablePadding"; import DocumentMetaWithViews from "~/components/DocumentMetaWithViews"; @@ -16,7 +17,7 @@ import { import MultiplayerEditor from "./AsyncMultiplayerEditor"; import EditableTitle from "./EditableTitle"; -type Props = EditorProps & { +type Props = Omit & { onChangeTitle: (text: string) => void; title: string; id: string; @@ -127,6 +128,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { scrollTo={window.location.hash} readOnly={readOnly} shareId={shareId} + extensions={fullPackage} grow {...rest} /> diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index 20848f0c9..1ca743dbe 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -188,17 +188,18 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { const extensions = React.useMemo(() => { if (!remoteProvider) { - return []; + return props.extensions; } return [ + ...(props.extensions || []), new MultiplayerExtension({ user, provider: remoteProvider, document: ydoc, }), ]; - }, [remoteProvider, user, ydoc]); + }, [remoteProvider, user, ydoc, props.extensions]); React.useEffect(() => { if (isLocalSynced && isRemoteSynced) { @@ -251,17 +252,23 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { return () => window.removeEventListener("error", onUnhandledError); }, [showToast, t]); - if (!extensions.length) { + if (!remoteProvider) { return null; } // while the collaborative document is loading, we render a version of the // document from the last text cache in read-only mode if we have it. const showCache = !isLocalSynced && !isRemoteSynced; + return ( <> {showCache && ( - + )} Promise; - onShowToast: (message: string, code: string) => void; + onShowToast: (message: string) => void; } ) { const { dispatch, state } = view; @@ -79,10 +78,7 @@ const createAndInsertLink = async function ( ) ); - // let the user know - if (onShowToast) { - onShowToast(options.dictionary.createLinkError, ToastType.Error); - } + onShowToast(options.dictionary.createLinkError); } }; diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 9b4f7172f..c32ab0acc 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -6,7 +6,6 @@ import uploadPlaceholderPlugin, { findPlaceholder, } from "../lib/uploadPlaceholder"; import findAttachmentById from "../queries/findAttachmentById"; -import { ToastType } from "../types"; export type Options = { dictionary: any; @@ -17,7 +16,7 @@ export type Options = { uploadFile?: (file: File) => Promise; onFileUploadStart?: () => void; onFileUploadStop?: () => void; - onShowToast: (message: string, code: string) => void; + onShowToast: (message: string) => void; }; const insertFiles = function ( @@ -187,10 +186,7 @@ const insertFiles = function ( view.dispatch(view.state.tr.deleteRange(from, to || from)); } - onShowToast( - error.message || dictionary.fileUploadError, - ToastType.Error - ); + onShowToast(error.message || dictionary.fileUploadError); }) .finally(() => { complete++; diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 9f6947e3c..1c1ef1e6e 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -3,6 +3,7 @@ import { keymap } from "prosemirror-keymap"; import { MarkdownParser, TokenConfig } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; import { EditorView } from "prosemirror-view"; +import { Editor } from "~/editor"; import Mark from "../marks/Mark"; import Node from "../nodes/Node"; import Extension, { CommandFactory } from "./Extension"; @@ -10,16 +11,32 @@ import makeRules from "./markdown/rules"; import { MarkdownSerializer } from "./markdown/serializer"; export default class ExtensionManager { - extensions: (Node | Mark | Extension)[]; + extensions: (Node | Mark | Extension)[] = []; - constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) { - if (editor) { - extensions.forEach((extension) => { + constructor( + extensions: ( + | Extension + | typeof Node + | typeof Mark + | typeof Extension + )[] = [], + editor?: Editor + ) { + extensions.forEach((ext) => { + let extension; + + if (typeof ext === "function") { + extension = new ext(editor?.props); + } else { + extension = ext; + } + + if (editor) { extension.bindEditor(editor); - }); - } + } - this.extensions = extensions; + this.extensions.push(extension); + }); } get nodes() { diff --git a/shared/editor/marks/Link.tsx b/shared/editor/marks/Link.tsx index f08cc0cc3..1460cc260 100644 --- a/shared/editor/marks/Link.tsx +++ b/shared/editor/marks/Link.tsx @@ -15,7 +15,7 @@ import * as React from "react"; import ReactDOM from "react-dom"; import { isInternalUrl } from "../../utils/urls"; import findLinkNodes from "../queries/findLinkNodes"; -import { Dispatch } from "../types"; +import { EventType, Dispatch } from "../types"; import Mark from "./Mark"; const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/; @@ -106,7 +106,7 @@ export default class Link extends Mark { return { "Mod-k": (state: EditorState, dispatch: Dispatch) => { if (state.selection.empty) { - this.options.onKeyboardShortcut(); + this.editor.events.emit(EventType.linkMenuOpen); return true; } diff --git a/shared/editor/nodes/CodeFence.ts b/shared/editor/nodes/CodeFence.ts index bc89cc287..670667b31 100644 --- a/shared/editor/nodes/CodeFence.ts +++ b/shared/editor/nodes/CodeFence.ts @@ -33,12 +33,13 @@ import rust from "refractor/lang/rust"; import sql from "refractor/lang/sql"; import typescript from "refractor/lang/typescript"; import yaml from "refractor/lang/yaml"; +import { Dictionary } from "~/hooks/useDictionary"; import toggleBlockType from "../commands/toggleBlockType"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import Prism, { LANGUAGES } from "../plugins/Prism"; import isInCode from "../queries/isInCode"; -import { Dispatch, ToastType } from "../types"; +import { Dispatch } from "../types"; import Node from "./Node"; const PERSISTENCE_KEY = "rme-code-language"; @@ -67,6 +68,13 @@ const DEFAULT_LANGUAGE = "javascript"; ].forEach(refractor.register); export default class CodeFence extends Node { + constructor(options: { + dictionary: Dictionary; + onShowToast: (message: string) => void; + }) { + super(options); + } + get languageOptions() { return Object.entries(LANGUAGES); } @@ -194,10 +202,7 @@ export default class CodeFence extends Node { const node = view.state.doc.nodeAt(result.pos); if (node) { copy(node.textContent); - this.options.onShowToast( - this.options.dictionary.codeCopied, - ToastType.Info - ); + this.options.onShowToast(this.options.dictionary.codeCopied); } } }; diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index 5c13da6db..a93c95601 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -2,12 +2,17 @@ import nameToEmoji from "gemoji/name-to-emoji.json"; import Token from "markdown-it/lib/token"; import { InputRule } from "prosemirror-inputrules"; import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; -import { EditorState, TextSelection } from "prosemirror-state"; +import { EditorState, TextSelection, Plugin } from "prosemirror-state"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import { run } from "../plugins/BlockMenuTrigger"; +import isInCode from "../queries/isInCode"; import emojiRule from "../rules/emoji"; -import { Dispatch } from "../types"; +import { Dispatch, EventType } from "../types"; import Node from "./Node"; +const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/; +const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/; + export default class Emoji extends Node { get name() { return "emoji"; @@ -61,6 +66,57 @@ export default class Emoji extends Node { return [emojiRule]; } + get plugins() { + return [ + new Plugin({ + props: { + handleClick: () => { + this.editor.events.emit(EventType.emojiMenuClose); + 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 + // correctly. This additional handler adds inputrules-like handling. + if (event.key === "Backspace") { + // timeout ensures that the delete has been handled by prosemirror + // and any characters removed, before we evaluate the rule. + setTimeout(() => { + const { pos } = view.state.selection.$from; + return run(view, pos, pos, OPEN_REGEX, (state, match) => { + if (match) { + this.editor.events.emit(EventType.emojiMenuOpen, match[1]); + } else { + this.editor.events.emit(EventType.emojiMenuClose); + } + return null; + }); + }); + } + + // If the query is active and we're navigating the block menu then + // just ignore the key events in the editor itself until we're done + if ( + event.key === "Enter" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Tab" + ) { + const { pos } = view.state.selection.$from; + + return run(view, pos, pos, OPEN_REGEX, (state, match) => { + // just tell Prosemirror we handled it and not to do anything + return match ? true : null; + }); + } + + return false; + }, + }, + }), + ]; + } + commands({ type }: { type: NodeType }) { return (attrs: Record) => ( state: EditorState, @@ -100,6 +156,29 @@ export default class Emoji extends Node { return tr; }), + // main regex should match only: + // :word + new InputRule(OPEN_REGEX, (state, match) => { + if ( + match && + state.selection.$from.parent.type.name === "paragraph" && + !isInCode(state) + ) { + this.editor.events.emit(EventType.emojiMenuOpen, match[1]); + } + return null; + }), + // invert regex should match some of these scenarios: + // :word + // : + // :word + // :) + new InputRule(CLOSE_REGEX, (state, match) => { + if (match) { + this.editor.events.emit(EventType.emojiMenuClose); + } + return null; + }), ]; } diff --git a/shared/editor/nodes/Heading.ts b/shared/editor/nodes/Heading.ts index f88be5cc5..e0e4af8fc 100644 --- a/shared/editor/nodes/Heading.ts +++ b/shared/editor/nodes/Heading.ts @@ -14,7 +14,6 @@ import toggleBlockType from "../commands/toggleBlockType"; import { Command } from "../lib/Extension"; import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import { ToastType } from "../types"; import Node from "./Node"; export default class Heading extends Node { @@ -180,10 +179,7 @@ export default class Heading extends Node { const urlWithoutHash = window.location.href.split("#")[0]; copy(urlWithoutHash + hash); - this.options.onShowToast( - this.options.dictionary.linkCopied, - ToastType.Info - ); + this.options.onShowToast(this.options.dictionary.linkCopied); }; keys({ type, schema }: { type: NodeType; schema: Schema }) { diff --git a/shared/editor/nodes/TableCell.ts b/shared/editor/nodes/TableCell.ts index b72b6ee37..6a05e7e00 100644 --- a/shared/editor/nodes/TableCell.ts +++ b/shared/editor/nodes/TableCell.ts @@ -5,6 +5,8 @@ import { isTableSelected, isRowSelected, getCellsInColumn, + selectRow, + selectTable, } from "prosemirror-utils"; import { DecorationSet, Decoration } from "prosemirror-view"; import Node from "./Node"; @@ -72,7 +74,7 @@ export default class TableCell extends Node { grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopImmediatePropagation(); - this.options.onSelectTable(state); + this.editor.view.dispatch(selectTable(state.tr)); }); return grip; }) @@ -97,7 +99,7 @@ export default class TableCell extends Node { grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopImmediatePropagation(); - this.options.onSelectRow(index, state); + this.editor.view.dispatch(selectRow(index)(state.tr)); }); return grip; }) diff --git a/shared/editor/nodes/TableHeadCell.ts b/shared/editor/nodes/TableHeadCell.ts index 1cb4995b9..43f86fc11 100644 --- a/shared/editor/nodes/TableHeadCell.ts +++ b/shared/editor/nodes/TableHeadCell.ts @@ -1,7 +1,11 @@ import Token from "markdown-it/lib/token"; import { NodeSpec } from "prosemirror-model"; import { Plugin } from "prosemirror-state"; -import { isColumnSelected, getCellsInRow } from "prosemirror-utils"; +import { + isColumnSelected, + getCellsInRow, + selectColumn, +} from "prosemirror-utils"; import { DecorationSet, Decoration } from "prosemirror-view"; import Node from "./Node"; @@ -72,7 +76,7 @@ export default class TableHeadCell extends Node { grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopImmediatePropagation(); - this.options.onSelectColumn(index, state); + this.editor.view.dispatch(selectColumn(index)(state.tr)); }); return grip; }) diff --git a/shared/editor/packages/README.md b/shared/editor/packages/README.md new file mode 100644 index 000000000..3b7948580 --- /dev/null +++ b/shared/editor/packages/README.md @@ -0,0 +1,2 @@ +Packages are preselected collections of extensions that form the different types +of editors within Outline. diff --git a/shared/editor/packages/basic.ts b/shared/editor/packages/basic.ts new file mode 100644 index 000000000..cf0685286 --- /dev/null +++ b/shared/editor/packages/basic.ts @@ -0,0 +1,44 @@ +import Extension from "../lib/Extension"; +import Bold from "../marks/Bold"; +import Code from "../marks/Code"; +import Italic from "../marks/Italic"; +import Link from "../marks/Link"; +import Mark from "../marks/Mark"; +import Strikethrough from "../marks/Strikethrough"; +import Underline from "../marks/Underline"; +import Doc from "../nodes/Doc"; +import Emoji from "../nodes/Emoji"; +import HardBreak from "../nodes/HardBreak"; +import Image from "../nodes/Image"; +import Node from "../nodes/Node"; +import Paragraph from "../nodes/Paragraph"; +import Text from "../nodes/Text"; +import History from "../plugins/History"; +import MaxLength from "../plugins/MaxLength"; +import PasteHandler from "../plugins/PasteHandler"; +import Placeholder from "../plugins/Placeholder"; +import SmartText from "../plugins/SmartText"; +import TrailingNode from "../plugins/TrailingNode"; + +const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [ + Doc, + HardBreak, + Paragraph, + Emoji, + Text, + Image, + Bold, + Code, + Italic, + Underline, + Link, + Strikethrough, + History, + SmartText, + TrailingNode, + PasteHandler, + Placeholder, + MaxLength, +]; + +export default basicPackage; diff --git a/shared/editor/packages/full.ts b/shared/editor/packages/full.ts new file mode 100644 index 000000000..2918fb3d7 --- /dev/null +++ b/shared/editor/packages/full.ts @@ -0,0 +1,52 @@ +import Extension from "../lib/Extension"; +import Highlight from "../marks/Highlight"; +import Mark from "../marks/Mark"; +import TemplatePlaceholder from "../marks/Placeholder"; +import Attachment from "../nodes/Attachment"; +import BulletList from "../nodes/BulletList"; +import CheckboxItem from "../nodes/CheckboxItem"; +import CheckboxList from "../nodes/CheckboxList"; +import CodeBlock from "../nodes/CodeBlock"; +import CodeFence from "../nodes/CodeFence"; +import Embed from "../nodes/Embed"; +import Heading from "../nodes/Heading"; +import HorizontalRule from "../nodes/HorizontalRule"; +import ListItem from "../nodes/ListItem"; +import Node from "../nodes/Node"; +import Notice from "../nodes/Notice"; +import OrderedList from "../nodes/OrderedList"; +import Table from "../nodes/Table"; +import TableCell from "../nodes/TableCell"; +import TableHeadCell from "../nodes/TableHeadCell"; +import TableRow from "../nodes/TableRow"; +import BlockMenuTrigger from "../plugins/BlockMenuTrigger"; +import Folding from "../plugins/Folding"; +import Keys from "../plugins/Keys"; +import basicPackage from "./basic"; + +const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [ + ...basicPackage, + CodeBlock, + CodeFence, + CheckboxList, + CheckboxItem, + BulletList, + OrderedList, + Embed, + ListItem, + Attachment, + Notice, + Heading, + HorizontalRule, + Table, + TableCell, + TableHeadCell, + TableRow, + Highlight, + TemplatePlaceholder, + Folding, + Keys, + BlockMenuTrigger, +]; + +export default fullPackage; diff --git a/shared/editor/plugins/BlockMenuTrigger.tsx b/shared/editor/plugins/BlockMenuTrigger.tsx index 63d7342c0..d982ad3fd 100644 --- a/shared/editor/plugins/BlockMenuTrigger.tsx +++ b/shared/editor/plugins/BlockMenuTrigger.tsx @@ -7,6 +7,7 @@ import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import * as React from "react"; import ReactDOM from "react-dom"; import Extension from "../lib/Extension"; +import { EventType } from "../types"; const MAX_MATCH = 500; const OPEN_REGEX = /^\/(\w+)?$/; @@ -65,7 +66,7 @@ export default class BlockMenuTrigger extends Extension { new Plugin({ props: { handleClick: () => { - this.options.onClose(); + this.editor.events.emit(EventType.blockMenuClose); return false; }, handleKeyDown: (view, event) => { @@ -79,9 +80,9 @@ export default class BlockMenuTrigger extends Extension { const { pos } = view.state.selection.$from; return run(view, pos, pos, OPEN_REGEX, (state, match) => { if (match) { - this.options.onOpen(match[1]); + this.editor.events.emit(EventType.blockMenuOpen, match[1]); } else { - this.options.onClose(); + this.editor.events.emit(EventType.blockMenuClose); } return null; }); @@ -125,7 +126,7 @@ export default class BlockMenuTrigger extends Extension { decorations.push( Decoration.widget(parent.pos, () => { button.addEventListener("click", () => { - this.options.onOpen(""); + this.editor.events.emit(EventType.blockMenuOpen, ""); }); return button; }) @@ -176,7 +177,7 @@ export default class BlockMenuTrigger extends Extension { state.selection.$from.parent.type.name === "paragraph" && !isInTable(state) ) { - this.options.onOpen(match[1]); + this.editor.events.emit(EventType.blockMenuOpen, match[1]); } return null; }), @@ -186,7 +187,7 @@ export default class BlockMenuTrigger extends Extension { // /word new InputRule(CLOSE_REGEX, (state, match) => { if (match) { - this.options.onClose(); + this.editor.events.emit(EventType.blockMenuClose); } return null; }), diff --git a/shared/editor/plugins/EmojiTrigger.tsx b/shared/editor/plugins/EmojiTrigger.tsx deleted file mode 100644 index 99859e140..000000000 --- a/shared/editor/plugins/EmojiTrigger.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { InputRule } from "prosemirror-inputrules"; -import { Plugin } from "prosemirror-state"; -import Extension from "../lib/Extension"; -import isInCode from "../queries/isInCode"; -import { run } from "./BlockMenuTrigger"; - -const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/; -const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/; - -export default class EmojiTrigger extends Extension { - get name() { - return "emojimenu"; - } - - get plugins() { - return [ - new Plugin({ - props: { - handleClick: () => { - this.options.onClose(); - 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 - // correctly. This additional handler adds inputrules-like handling. - if (event.key === "Backspace") { - // timeout ensures that the delete has been handled by prosemirror - // and any characters removed, before we evaluate the rule. - setTimeout(() => { - const { pos } = view.state.selection.$from; - return run(view, pos, pos, OPEN_REGEX, (state, match) => { - if (match) { - this.options.onOpen(match[1]); - } else { - this.options.onClose(); - } - return null; - }); - }); - } - - // If the query is active and we're navigating the block menu then - // just ignore the key events in the editor itself until we're done - if ( - event.key === "Enter" || - event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "Tab" - ) { - const { pos } = view.state.selection.$from; - - return run(view, pos, pos, OPEN_REGEX, (state, match) => { - // just tell Prosemirror we handled it and not to do anything - return match ? true : null; - }); - } - - return false; - }, - }, - }), - ]; - } - - inputRules() { - return [ - // main regex should match only: - // :word - new InputRule(OPEN_REGEX, (state, match) => { - if ( - match && - state.selection.$from.parent.type.name === "paragraph" && - !isInCode(state) - ) { - this.options.onOpen(match[1]); - } - return null; - }), - // invert regex should match some of these scenarios: - // :word - // : - // :word - // :) - new InputRule(CLOSE_REGEX, (state, match) => { - if (match) { - this.options.onClose(); - } - return null; - }), - ]; - } -} diff --git a/shared/editor/plugins/Keys.ts b/shared/editor/plugins/Keys.ts index b8df79222..207af8cb3 100644 --- a/shared/editor/plugins/Keys.ts +++ b/shared/editor/plugins/Keys.ts @@ -16,8 +16,8 @@ export default class Keys extends Extension { keys(): Record { const onCancel = () => { - if (this.options.onCancel) { - this.options.onCancel(); + if (this.editor.props.onCancel) { + this.editor.props.onCancel(); return true; } return false; @@ -32,15 +32,15 @@ export default class Keys extends Extension { "Mod-Escape": onCancel, "Shift-Escape": onCancel, "Mod-s": () => { - if (this.options.onSave) { - this.options.onSave(); + if (this.editor.props.onSave) { + this.editor.props.onSave({ done: false }); return true; } return false; }, "Mod-Enter": (state: EditorState) => { - if (!isInCode(state) && this.options.onSaveAndExit) { - this.options.onSaveAndExit(); + if (!isInCode(state) && this.editor.props.onSave) { + this.editor.props.onSave({ done: true }); return true; } return false; @@ -52,10 +52,6 @@ export default class Keys extends Extension { return [ new Plugin({ props: { - handleDOMEvents: { - blur: this.options.onBlur, - focus: this.options.onFocus, - }, // we can't use the keys bindings for this as we want to preventDefault // on the original keyboard event when handled handleKeyDown: (view, event) => { diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 9a1ff1427..069d66348 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -3,9 +3,13 @@ import { EditorState, Transaction } from "prosemirror-state"; import * as React from "react"; import { DefaultTheme } from "styled-components"; -export enum ToastType { - Error = "error", - Info = "info", +export enum EventType { + blockMenuOpen = "blockMenuOpen", + blockMenuClose = "blockMenuClose", + emojiMenuOpen = "emojiMenuOpen", + emojiMenuClose = "emojiMenuClose", + linkMenuOpen = "linkMenuOpen", + linkMenuClose = "linkMenuClose", } export type MenuItem = { diff --git a/shared/utils/events.ts b/shared/utils/events.ts new file mode 100644 index 000000000..b3fc7b9c8 --- /dev/null +++ b/shared/utils/events.ts @@ -0,0 +1,29 @@ +/** + * A tiny EventEmitter implementation for the browser. + */ +export default class EventEmitter { + private listeners: { [name: string]: ((data: any) => unknown)[] } = {}; + + public addListener(name: string, callback: (data: any) => unknown) { + if (!this.listeners[name]) { + this.listeners[name] = []; + } + + this.listeners[name].push(callback); + } + + public removeListener(name: string, callback: (data: any) => unknown) { + this.listeners[name] = this.listeners[name]?.filter( + (cb) => cb !== callback + ); + } + + public on = this.addListener; + public off = this.removeListener; + + public emit(name: string, data?: any) { + this.listeners[name]?.forEach((callback) => { + callback(data); + }); + } +}