diff --git a/app/editor/index.tsx b/app/editor/index.tsx index a15ed08d8..9fc8559eb 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -27,6 +27,7 @@ import Mark from "@shared/editor/marks/Mark"; import { richExtensions, withComments } 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"; @@ -136,17 +137,13 @@ type State = { /** If the editor is currently focused */ isEditorFocused: boolean; /** If the toolbar for a text selection is visible */ - selectionMenuOpen: boolean; - /** If the block insert menu is visible (triggered with /) */ - blockMenuOpen: boolean; + selectionToolbarOpen: boolean; + /** If a suggestions menu is visible */ + suggestionsMenuOpen: SuggestionsMenuType | false; /** If the insert link toolbar is visible */ - linkMenuOpen: boolean; - /** The search term currently filtering the block menu */ - blockMenuSearch: string; - /** If the emoji insert menu is visible */ - emojiMenuOpen: boolean; - /** If the mention user menu is visible */ - mentionMenuOpen: boolean; + linkToolbarOpen: boolean; + /** The query for the suggestion menu */ + query: string; }; /** @@ -172,15 +169,13 @@ export class Editor extends React.PureComponent< extensions, }; - state = { + state: State = { isRTL: false, isEditorFocused: false, - selectionMenuOpen: false, - blockMenuOpen: false, - linkMenuOpen: false, - blockMenuSearch: "", - emojiMenuOpen: false, - mentionMenuOpen: false, + suggestionsMenuOpen: false, + selectionToolbarOpen: false, + linkToolbarOpen: false, + query: "", }; isBlurred = true; @@ -214,14 +209,15 @@ export class Editor extends React.PureComponent< 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); - this.events.on(EventType.mentionMenuOpen, this.handleOpenMentionMenu); - this.events.on(EventType.mentionMenuClose, this.handleCloseMentionMenu); + this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar); + this.events.on( + EventType.SuggestionsMenuOpen, + this.handleOpenSuggestionsMenu + ); + this.events.on( + EventType.SuggestionsMenuClose, + this.handleCloseSuggestionsMenu + ); } /** @@ -279,9 +275,9 @@ export class Editor extends React.PureComponent< if ( !this.isBlurred && !this.state.isEditorFocused && - !this.state.blockMenuOpen && - !this.state.linkMenuOpen && - !this.state.selectionMenuOpen + !this.state.suggestionsMenuOpen && + !this.state.linkToolbarOpen && + !this.state.selectionToolbarOpen ) { this.isBlurred = true; this.props.onBlur?.(); @@ -290,9 +286,9 @@ export class Editor extends React.PureComponent< if ( this.isBlurred && (this.state.isEditorFocused || - this.state.blockMenuOpen || - this.state.linkMenuOpen || - this.state.selectionMenuOpen) + this.state.suggestionsMenuOpen || + this.state.linkToolbarOpen || + this.state.selectionToolbarOpen) ) { this.isBlurred = false; this.props.onFocus?.(); @@ -666,52 +662,56 @@ export class Editor extends React.PureComponent< return false; }; - private handleOpenSelectionMenu = () => { - this.setState({ blockMenuOpen: false, selectionMenuOpen: true }); + private handleOpenSelectionToolbar = () => { + this.setState((state) => ({ + ...state, + selectionToolbarOpen: true, + suggestionsMenuOpen: false, + query: "", + })); }; - private handleCloseSelectionMenu = () => { - if (!this.state.selectionMenuOpen) { + private handleCloseSelectionToolbar = () => { + if (!this.state.selectionToolbarOpen) { return; } - this.setState({ selectionMenuOpen: false }); + this.setState((state) => ({ + ...state, + selectionToolbarOpen: false, + })); }; - private handleOpenEmojiMenu = (search: string) => { - this.setState({ emojiMenuOpen: true, blockMenuSearch: search }); + private handleOpenLinkToolbar = () => { + this.setState((state) => ({ + ...state, + suggestionsMenuOpen: false, + linkToolbarOpen: true, + query: "", + })); }; - private handleOpenMentionMenu = (search: string) => { - this.setState({ mentionMenuOpen: true, blockMenuSearch: search }); + private handleCloseLinkToolbar = () => { + this.setState((state) => ({ + ...state, + linkToolbarOpen: false, + })); }; - private handleCloseEmojiMenu = () => { - if (!this.state.emojiMenuOpen) { - return; - } - this.setState({ emojiMenuOpen: false }); + private handleOpenSuggestionsMenu = (data: { + type: SuggestionsMenuType; + query: string; + }) => { + this.setState((state) => ({ + ...state, + suggestionsMenuOpen: data.type, + query: data.query, + })); }; - private handleCloseMentionMenu = () => { - if (!this.state.mentionMenuOpen) { - return; - } - this.setState({ mentionMenuOpen: false }); - }; - - private handleOpenLinkMenu = () => { - this.setState({ blockMenuOpen: false, linkMenuOpen: true }); - }; - - private handleCloseLinkMenu = () => { - this.setState({ linkMenuOpen: false }); - }; - - private handleOpenBlockMenu = (search: string) => { - this.setState({ blockMenuOpen: true, blockMenuSearch: search }); - }; - - private handleCloseBlockMenu = (insertNewLine?: boolean) => { + private handleCloseSuggestionsMenu = ( + type: SuggestionsMenuType, + insertNewLine?: boolean + ) => { if (insertNewLine) { const transaction = this.view.state.tr.split( this.view.state.selection.to @@ -719,10 +719,14 @@ export class Editor extends React.PureComponent< this.view.dispatch(transaction); this.view.focus(); } - if (!this.state.blockMenuOpen) { + if (this.state.suggestionsMenuOpen !== type) { return; } - this.setState({ blockMenuOpen: false }); + this.setState((state) => ({ + ...state, + suggestionsMenuOpen: false, + query: "", + })); }; public render() { @@ -762,45 +766,68 @@ export class Editor extends React.PureComponent< <> {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.handleOpenLinkMenu} + onLinkToolbarOpen={this.handleOpenLinkToolbar} onFileUploadStart={this.props.onFileUploadStart} onFileUploadStop={this.props.onFileUploadStop} embeds={this.props.embeds} diff --git a/shared/editor/extensions/BlockMenu.tsx b/shared/editor/extensions/BlockMenu.tsx new file mode 100644 index 000000000..9ad40b926 --- /dev/null +++ b/shared/editor/extensions/BlockMenu.tsx @@ -0,0 +1,99 @@ +import { PlusIcon } from "outline-icons"; +import { Plugin } from "prosemirror-state"; +import { findParentNode } from "prosemirror-utils"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import { SuggestionsMenuType } from "../plugins/Suggestions"; +import { EventType } from "../types"; +import Suggestion from "./Suggestion"; + +export default class BlockMenu extends Suggestion { + get defaultOptions() { + return { + type: SuggestionsMenuType.Block, + openRegex: /^\/(\w+)?$/, + closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/, + }; + } + + get name() { + return "blockmenu"; + } + + get plugins() { + const button = document.createElement("button"); + button.className = "block-menu-trigger"; + button.type = "button"; + ReactDOM.render(, button); + + return [ + ...super.plugins, + new Plugin({ + props: { + decorations: (state) => { + const parent = findParentNode( + (node) => node.type.name === "paragraph" + )(state.selection); + + if (!parent) { + return; + } + + const isTopLevel = state.selection.$from.depth === 1; + if (!isTopLevel) { + return; + } + + const decorations: Decoration[] = []; + const isEmptyNode = parent && parent.node.content.size === 0; + const isSlash = parent && parent.node.textContent === "/"; + + if (isEmptyNode) { + decorations.push( + Decoration.widget( + parent.pos, + () => { + button.addEventListener("click", () => { + this.editor.events.emit(EventType.SuggestionsMenuOpen, { + type: SuggestionsMenuType.Block, + query: "", + }); + }); + return button; + }, + { + key: "block-trigger", + } + ) + ); + + const isEmptyDoc = state.doc.textContent === ""; + if (!isEmptyDoc) { + decorations.push( + Decoration.node( + parent.pos, + parent.pos + parent.node.nodeSize, + { + class: "placeholder", + "data-empty-text": this.options.dictionary.newLineEmpty, + } + ) + ); + } + } else if (isSlash) { + decorations.push( + Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, { + class: "placeholder", + "data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`, + }) + ); + } + + return DecorationSet.create(state.doc, decorations); + }, + }, + }), + ]; + } +} diff --git a/shared/editor/extensions/BlockMenuTrigger.tsx b/shared/editor/extensions/BlockMenuTrigger.tsx deleted file mode 100644 index 65355cd6a..000000000 --- a/shared/editor/extensions/BlockMenuTrigger.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { PlusIcon } from "outline-icons"; -import { InputRule } from "prosemirror-inputrules"; -import { EditorState, Plugin } from "prosemirror-state"; -import { isInTable } from "prosemirror-tables"; -import { findParentNode } from "prosemirror-utils"; -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+)?$/; -const CLOSE_REGEX = /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/; - -// based on the input rules code in Prosemirror, here: -// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js -export function run( - view: EditorView, - from: number, - to: number, - regex: RegExp, - handler: ( - state: EditorState, - match: RegExpExecArray | null, - from?: number, - to?: number - ) => boolean | null -) { - if (view.composing) { - return false; - } - const state = view.state; - const $from = state.doc.resolve(from); - if ($from.parent.type.spec.code) { - return false; - } - - const textBefore = $from.parent.textBetween( - Math.max(0, $from.parentOffset - MAX_MATCH), - $from.parentOffset, - undefined, - "\ufffc" - ); - - const match = regex.exec(textBefore); - const tr = handler(state, match, match ? from - match[0].length : from, to); - if (!tr) { - return false; - } - return true; -} - -export default class BlockMenuTrigger extends Extension { - get name() { - return "blockmenu"; - } - - get plugins() { - const button = document.createElement("button"); - button.className = "block-menu-trigger"; - button.type = "button"; - ReactDOM.render(, button); - - return [ - new Plugin({ - props: { - handleClick: () => { - this.editor.events.emit(EventType.blockMenuClose); - 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.blockMenuOpen, match[1]); - } else { - this.editor.events.emit(EventType.blockMenuClose); - } - 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 - match ? true : null - ); - } - - return false; - }, - decorations: (state) => { - const parent = findParentNode( - (node) => node.type.name === "paragraph" - )(state.selection); - - if (!parent) { - return; - } - - const isTopLevel = state.selection.$from.depth === 1; - if (!isTopLevel) { - return; - } - - const decorations: Decoration[] = []; - const isEmptyNode = parent && parent.node.content.size === 0; - const isSlash = parent && parent.node.textContent === "/"; - - if (isEmptyNode) { - decorations.push( - Decoration.widget( - parent.pos, - () => { - button.addEventListener("click", () => { - this.editor.events.emit(EventType.blockMenuOpen, ""); - }); - return button; - }, - { - key: "block-trigger", - } - ) - ); - - const isEmptyDoc = state.doc.textContent === ""; - if (!isEmptyDoc) { - decorations.push( - Decoration.node( - parent.pos, - parent.pos + parent.node.nodeSize, - { - class: "placeholder", - "data-empty-text": this.options.dictionary.newLineEmpty, - } - ) - ); - } - } else if (isSlash) { - decorations.push( - Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, { - class: "placeholder", - "data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`, - }) - ); - } - - return DecorationSet.create(state.doc, decorations); - }, - }, - }), - ]; - } - - inputRules() { - return [ - // main regex should match only: - // /word - new InputRule(OPEN_REGEX, (state, match) => { - if ( - match && - state.selection.$from.parent.type.name === "paragraph" && - !isInTable(state) - ) { - this.editor.events.emit(EventType.blockMenuOpen, 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.blockMenuClose); - } - return null; - }), - ]; - } -} diff --git a/shared/editor/extensions/DateTime.ts b/shared/editor/extensions/DateTime.ts index a4fa9a315..23debaae1 100644 --- a/shared/editor/extensions/DateTime.ts +++ b/shared/editor/extensions/DateTime.ts @@ -25,17 +25,17 @@ export default class DateTime extends Extension { // in places other than the start of a line new InputRule(/\/date\s$/, ({ tr }, _match, start, end) => { tr.delete(start, end).insertText(getCurrentDateAsString() + " "); - this.editor.events.emit(EventType.blockMenuClose); + this.editor.events.emit(EventType.SuggestionsMenuClose); return tr; }), new InputRule(/\/time\s$/, ({ tr }, _match, start, end) => { tr.delete(start, end).insertText(getCurrentTimeAsString() + " "); - this.editor.events.emit(EventType.blockMenuClose); + this.editor.events.emit(EventType.SuggestionsMenuClose); return tr; }), new InputRule(/\/datetime\s$/, ({ tr }, _match, start, end) => { tr.delete(start, end).insertText(`${getCurrentDateTimeAsString()} `); - this.editor.events.emit(EventType.blockMenuClose); + this.editor.events.emit(EventType.SuggestionsMenuClose); return tr; }), ]; diff --git a/shared/editor/extensions/Folding.tsx b/shared/editor/extensions/Folding.tsx deleted file mode 100644 index 99f5c9dec..000000000 --- a/shared/editor/extensions/Folding.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Plugin } from "prosemirror-state"; -import { findBlockNodes } from "prosemirror-utils"; -import { Decoration, DecorationSet } from "prosemirror-view"; -import Storage from "../../utils/Storage"; -import Extension from "../lib/Extension"; -import { headingToPersistenceKey } from "../lib/headingToSlug"; -import findCollapsedNodes from "../queries/findCollapsedNodes"; - -export default class Folding extends Extension { - get name() { - return "folding"; - } - - get plugins() { - let loaded = false; - - return [ - new Plugin({ - view: (view) => { - loaded = false; - view.dispatch(view.state.tr.setMeta("folding", { loaded: true })); - return {}; - }, - appendTransaction: (transactions, oldState, newState) => { - if (loaded) { - return; - } - if ( - !transactions.some((transaction) => transaction.getMeta("folding")) - ) { - return; - } - - let modified = false; - const tr = newState.tr; - const blocks = findBlockNodes(newState.doc); - - for (const block of blocks) { - if (block.node.type.name === "heading") { - const persistKey = headingToPersistenceKey( - block.node, - this.editor.props.id - ); - const persistedState = Storage.get(persistKey); - - if (persistedState === "collapsed") { - tr.setNodeMarkup(block.pos, undefined, { - ...block.node.attrs, - collapsed: true, - }); - modified = true; - } - } - } - - loaded = true; - return modified ? tr : null; - }, - props: { - decorations: (state) => { - const { doc } = state; - const decorations: Decoration[] = findCollapsedNodes(doc).map( - (block) => - Decoration.node(block.pos, block.pos + block.node.nodeSize, { - class: "folded-content", - }) - ); - - return DecorationSet.create(doc, decorations); - }, - }, - }), - ]; - } -} diff --git a/shared/editor/extensions/PasteHandler.ts b/shared/editor/extensions/PasteHandler.ts index f722582bd..8db6455d3 100644 --- a/shared/editor/extensions/PasteHandler.ts +++ b/shared/editor/extensions/PasteHandler.ts @@ -17,7 +17,7 @@ function isDropboxPaper(html: string): boolean { export default class PasteHandler extends Extension { get name() { - return "markdown-paste"; + return "paste-handler"; } get plugins() { diff --git a/shared/editor/extensions/Suggestion.tsx b/shared/editor/extensions/Suggestion.tsx new file mode 100644 index 000000000..40a3c52cf --- /dev/null +++ b/shared/editor/extensions/Suggestion.tsx @@ -0,0 +1,41 @@ +import { InputRule } from "prosemirror-inputrules"; +import { NodeType, Schema } from "prosemirror-model"; +import { 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)]; + } + + inputRules = (_options: { type: NodeType; schema: Schema }) => { + console.log(this.name, this.options.openRegex); + + return [ + new InputRule(this.options.openRegex, (state, match) => { + if ( + match && + state.selection.$from.parent.type.name === "paragraph" && + (!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); + } + return null; + }), + ]; + }; +} diff --git a/shared/editor/marks/Link.tsx b/shared/editor/marks/Link.tsx index bcd423b93..4a1eaf225 100644 --- a/shared/editor/marks/Link.tsx +++ b/shared/editor/marks/Link.tsx @@ -117,7 +117,7 @@ export default class Link extends Mark { return { "Mod-k": (state: EditorState, dispatch: Dispatch) => { if (state.selection.empty) { - this.editor.events.emit(EventType.linkMenuOpen); + this.editor.events.emit(EventType.LinkToolbarOpen); return true; } diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index 9c03b602a..3d964665f 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -1,19 +1,32 @@ 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, Plugin } from "prosemirror-state"; -import { run } from "../extensions/BlockMenuTrigger"; +import { + NodeSpec, + Node as ProsemirrorNode, + NodeType, + Schema, +} from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; +import Suggestion from "../extensions/Suggestion"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import isInCode from "../queries/isInCode"; +import { SuggestionsMenuType } from "../plugins/Suggestions"; import emojiRule from "../rules/emoji"; -import { Dispatch, EventType } from "../types"; -import Node from "./Node"; +import { Dispatch } from "../types"; -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 Suggestion { + get type() { + return "node"; + } + + get defaultOptions() { + return { + type: SuggestionsMenuType.Emoji, + openRegex: /(?:^|\s):([0-9a-zA-Z_+-]+)?$/, + closeRegex: /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/, + enabledInTable: true, + }; + } -export default class Emoji extends Node { get name() { return "emoji"; } @@ -63,58 +76,7 @@ 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 - match ? true : null - ); - } - - return false; - }, - }, - }), - ]; - } - - commands({ type }: { type: NodeType }) { + commands({ type }: { type: NodeType; schema: Schema }) { return (attrs: Record) => ( state: EditorState, dispatch: Dispatch @@ -135,50 +97,6 @@ export default class Emoji extends Node { }; } - inputRules({ type }: { type: NodeType }): InputRule[] { - return [ - new InputRule(/^:([a-zA-Z0-9_+-]+):$/, (state, match, start, end) => { - const [okay, markup] = match; - const { tr } = state; - if (okay) { - tr.replaceWith( - start - 1, - end, - type.create({ - "data-name": markup, - markup, - }) - ); - } - - 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; - }), - ]; - } - toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { const name = node.attrs["data-name"]; if (name) { diff --git a/shared/editor/nodes/Heading.ts b/shared/editor/nodes/Heading.ts index 76fc07a32..f911d15d0 100644 --- a/shared/editor/nodes/Heading.ts +++ b/shared/editor/nodes/Heading.ts @@ -15,6 +15,7 @@ import toggleBlockType from "../commands/toggleBlockType"; import { Command } from "../lib/Extension"; import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import { FoldingHeadersPlugin } from "../plugins/FoldingHeaders"; import Node from "./Node"; export default class Heading extends Node { @@ -264,7 +265,7 @@ export default class Heading extends Node { }, }); - return [plugin]; + return [new FoldingHeadersPlugin(this.editor.props.id), plugin]; } inputRules({ type }: { type: NodeType }) { diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts index 1a53d5c7b..72d9d2a7e 100644 --- a/shared/editor/nodes/Mention.ts +++ b/shared/editor/nodes/Mention.ts @@ -1,19 +1,32 @@ import Token from "markdown-it/lib/token"; -import { InputRule } from "prosemirror-inputrules"; -import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; -import { EditorState, TextSelection, Plugin } from "prosemirror-state"; -import { run } from "../extensions/BlockMenuTrigger"; +import { + NodeSpec, + Node as ProsemirrorNode, + NodeType, + Schema, +} from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; +import Suggestion from "../extensions/Suggestion"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; -import isInCode from "../queries/isInCode"; +import { SuggestionsMenuType } from "../plugins/Suggestions"; import mentionRule from "../rules/mention"; -import { Dispatch, EventType } from "../types"; -import Node from "./Node"; +import { Dispatch } from "../types"; -// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w -const OPEN_REGEX = /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u; -const CLOSE_REGEX = /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u; +export default class Mention extends Suggestion { + 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, + }; + } -export default class Mention extends Node { get name() { return "mention"; } @@ -66,61 +79,7 @@ export default class Mention extends Node { return [mentionRule]; } - get plugins() { - return [ - new Plugin({ - props: { - handleClick: () => { - this.editor.events.emit(EventType.mentionMenuClose); - 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.mentionMenuOpen, - match[1] - ); - } else { - this.editor.events.emit(EventType.mentionMenuClose); - } - 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 - match ? true : null - ); - } - - return false; - }, - }, - }), - ]; - } - - commands({ type }: { type: NodeType }) { + commands({ type }: { type: NodeType; schema: Schema }) { return (attrs: Record) => ( state: EditorState, dispatch: Dispatch @@ -141,33 +100,6 @@ export default class Mention extends Node { }; } - inputRules(): InputRule[] { - 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.editor.events.emit(EventType.mentionMenuOpen, 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.mentionMenuClose); - } - return null; - }), - ]; - } - toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { const mType = node.attrs.type; const mId = node.attrs.modelId; diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 2e65bc2e0..201facefa 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -1,7 +1,6 @@ -import BlockMenuTrigger from "../extensions/BlockMenuTrigger"; +import BlockMenu from "../extensions/BlockMenu"; import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import DateTime from "../extensions/DateTime"; -import Folding from "../extensions/Folding"; import History from "../extensions/History"; import Keys from "../extensions/Keys"; import MaxLength from "../extensions/MaxLength"; @@ -106,8 +105,7 @@ export const richExtensions: Nodes = [ TableRow, Highlight, TemplatePlaceholder, - Folding, - BlockMenuTrigger, + BlockMenu, Math, MathBlock, PreventTab, diff --git a/shared/editor/plugins/FoldingHeaders.ts b/shared/editor/plugins/FoldingHeaders.ts new file mode 100644 index 000000000..63be4d934 --- /dev/null +++ b/shared/editor/plugins/FoldingHeaders.ts @@ -0,0 +1,67 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { findBlockNodes } from "prosemirror-utils"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import Storage from "../../utils/Storage"; +import { headingToPersistenceKey } from "../lib/headingToSlug"; +import findCollapsedNodes from "../queries/findCollapsedNodes"; + +export class FoldingHeadersPlugin extends Plugin { + constructor(documentId: string | undefined) { + const plugin = new PluginKey("folding"); + let loaded = false; + + super({ + key: plugin, + view: (view) => { + loaded = false; + view.dispatch(view.state.tr.setMeta("folding", { loaded: true })); + return {}; + }, + appendTransaction: (transactions, oldState, newState) => { + if (loaded) { + return; + } + if ( + !transactions.some((transaction) => transaction.getMeta("folding")) + ) { + return; + } + + let modified = false; + const tr = newState.tr; + const blocks = findBlockNodes(newState.doc); + + for (const block of blocks) { + if (block.node.type.name === "heading") { + const persistKey = headingToPersistenceKey(block.node, documentId); + const persistedState = Storage.get(persistKey); + + if (persistedState === "collapsed") { + tr.setNodeMarkup(block.pos, undefined, { + ...block.node.attrs, + collapsed: true, + }); + modified = true; + } + } + } + + loaded = true; + return modified ? tr : null; + }, + props: { + decorations: (state) => { + const { doc } = state; + const decorations: Decoration[] = findCollapsedNodes(doc).map( + (block) => + Decoration.node(block.pos, block.pos + block.node.nodeSize, { + class: "folded-content", + }) + ); + + return DecorationSet.create(doc, decorations); + }, + }, + }); + } +} diff --git a/shared/editor/plugins/Suggestions.ts b/shared/editor/plugins/Suggestions.ts new file mode 100644 index 000000000..5d04ae99f --- /dev/null +++ b/shared/editor/plugins/Suggestions.ts @@ -0,0 +1,126 @@ +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; +}; + +export class SuggestionsMenuPlugin extends Plugin { + constructor(editor: Editor, options: Options) { + 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 + // 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 this.execute( + view, + pos, + pos, + options.openRegex, + (state, match) => { + if (match) { + editor.events.emit(EventType.SuggestionsMenuOpen, { + type: options.type, + query: match[1], + }); + } else { + editor.events.emit( + EventType.SuggestionsMenuClose, + options.type + ); + } + return null; + } + ); + }); + } + + const { pos } = view.state.selection.$from; + + // 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" + ) { + return this.execute( + view, + pos, + pos, + options.openRegex, + (state, match) => + // just tell Prosemirror we handled it and not to do anything + match ? true : null + ); + } + + return false; + }, + }, + }); + } + + // based on the input rules code in Prosemirror, here: + // https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js + private execute( + view: EditorView, + from: number, + to: number, + regex: RegExp, + handler: ( + state: EditorState, + match: RegExpExecArray | null, + from?: number, + to?: number + ) => boolean | null + ) { + if (view.composing) { + return false; + } + const state = view.state; + const $from = state.doc.resolve(from); + if ($from.parent.type.spec.code) { + return false; + } + + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - MAX_MATCH), + $from.parentOffset, + undefined, + "\ufffc" + ); + + const match = regex.exec(textBefore); + const tr = handler(state, match, match ? from - match[0].length : from, to); + if (!tr) { + return false; + } + return true; + } +} diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 57b8424c3..cfc9aa048 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -7,14 +7,9 @@ import { DefaultTheme } from "styled-components"; export type PlainTextSerializer = (node: ProsemirrorNode) => string; export enum EventType { - blockMenuOpen = "blockMenuOpen", - blockMenuClose = "blockMenuClose", - emojiMenuOpen = "emojiMenuOpen", - emojiMenuClose = "emojiMenuClose", - linkMenuOpen = "linkMenuOpen", - linkMenuClose = "linkMenuClose", - mentionMenuOpen = "mentionMenuOpen", - mentionMenuClose = "mentionMenuClose", + SuggestionsMenuOpen = "suggestionMenuOpen", + SuggestionsMenuClose = "suggestionMenuClose", + LinkToolbarOpen = "linkMenuOpen", } export type MenuItem = {