UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

397 lines (345 loc) 13.5 kB
import { findParentNode } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { trackPosition } from "../../api/positionMapping.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import { BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); export type SuggestionMenuState = UiElementPosition & { query: string; ignoreQueryLength?: boolean; }; class SuggestionMenuView< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, > { public state?: SuggestionMenuState; public emitUpdate: (triggerCharacter: string) => void; private rootEl?: Document | ShadowRoot; pluginState: SuggestionPluginState; constructor( private readonly editor: BlockNoteEditor<BSchema, I, S>, emitUpdate: (menuName: string, state: SuggestionMenuState) => void, ) { this.pluginState = undefined; this.emitUpdate = (menuName: string) => { if (!this.state) { throw new Error("Attempting to update uninitialized suggestions menu"); } emitUpdate(menuName, { ...this.state, ignoreQueryLength: this.pluginState?.ignoreQueryLength, }); }; this.rootEl = this.editor.prosemirrorView?.root; // Setting capture=true ensures that any parent container of the editor that // gets scrolled will trigger the scroll event. Scroll events do not bubble // and so won't propagate to the document by default. this.rootEl?.addEventListener("scroll", this.handleScroll, true); } handleScroll = () => { if (this.state?.show) { const decorationNode = this.rootEl?.querySelector( `[data-decoration-id="${this.pluginState!.decorationId}"]`, ); if (!decorationNode) { return; } this.state.referencePos = decorationNode.getBoundingClientRect(); this.emitUpdate(this.pluginState!.triggerCharacter!); } }; update(view: EditorView, prevState: EditorState) { const prev: SuggestionPluginState = suggestionMenuPluginKey.getState(prevState); const next: SuggestionPluginState = suggestionMenuPluginKey.getState( view.state, ); // See how the state changed const started = prev === undefined && next !== undefined; const stopped = prev !== undefined && next === undefined; const changed = prev !== undefined && next !== undefined; // Cancel when suggestion isn't active if (!started && !changed && !stopped) { return; } this.pluginState = stopped ? prev : next; if (stopped || !this.editor.isEditable) { if (this.state) { this.state.show = false; } this.emitUpdate(this.pluginState!.triggerCharacter); return; } const decorationNode = this.rootEl?.querySelector( `[data-decoration-id="${this.pluginState!.decorationId}"]`, ); if (this.editor.isEditable && decorationNode) { this.state = { show: true, referencePos: decorationNode.getBoundingClientRect(), query: this.pluginState!.query, }; this.emitUpdate(this.pluginState!.triggerCharacter!); } } destroy() { this.rootEl?.removeEventListener("scroll", this.handleScroll, true); } closeMenu = () => { this.editor.transact((tr) => tr.setMeta(suggestionMenuPluginKey, null)); }; clearQuery = () => { if (this.pluginState === undefined) { return; } this.editor._tiptapEditor .chain() .focus() // TODO need to make an API for this .deleteRange({ from: this.pluginState.queryStartPos() - (this.pluginState.deleteTriggerCharacter ? this.pluginState.triggerCharacter!.length : 0), to: this.editor.transact((tr) => tr.selection.from), }) .run(); }; } type SuggestionPluginState = | { triggerCharacter: string; deleteTriggerCharacter: boolean; queryStartPos: () => number; query: string; decorationId: string; ignoreQueryLength?: boolean; } | undefined; const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); /** * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions. * * This is basically a simplified version of TipTap's [Suggestions](https://github.com/ueberdosis/tiptap/tree/db92a9b313c5993b723c85cd30256f1d4a0b65e1/packages/suggestion) plugin. * * This version is adapted from the aforementioned version in the following ways: * - This version supports generic items instead of only strings (to allow for more advanced filtering for example) * - This version hides some unnecessary complexity from the user of the plugin. * - This version handles key events differently */ export class SuggestionMenuProseMirrorPlugin< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, > extends BlockNoteExtension { public static key() { return "suggestionMenu"; } private view: SuggestionMenuView<BSchema, I, S> | undefined; private triggerCharacters: string[] = []; constructor(editor: BlockNoteEditor<BSchema, I, S>) { super(); const triggerCharacters = this.triggerCharacters; this.addProsemirrorPlugin( new Plugin({ key: suggestionMenuPluginKey, view: () => { this.view = new SuggestionMenuView<BSchema, I, S>( editor, (triggerCharacter, state) => { this.emit(`update ${triggerCharacter}`, state); }, ); return this.view; }, state: { // Initialize the plugin's internal state. init(): SuggestionPluginState { return undefined; }, // Apply changes to the plugin state from an editor transaction. apply: ( transaction, prev, _oldState, newState, ): SuggestionPluginState => { // TODO: More clearly define which transactions should be ignored. if (transaction.getMeta("orderedListIndexing") !== undefined) { return prev; } // Ignore transactions in code blocks. if (transaction.selection.$from.parent.type.spec.code) { return prev; } // Either contains the trigger character if the menu should be shown, // or null if it should be hidden. const suggestionPluginTransactionMeta: { triggerCharacter: string; deleteTriggerCharacter?: boolean; ignoreQueryLength?: boolean; } | null = transaction.getMeta(suggestionMenuPluginKey); if ( typeof suggestionPluginTransactionMeta === "object" && suggestionPluginTransactionMeta !== null ) { if (prev) { // Close the previous menu if it exists this.closeMenu(); } const trackedPosition = trackPosition( editor, newState.selection.from - // Need to account for the trigger char that was inserted, so we offset the position by the length of the trigger character. suggestionPluginTransactionMeta.triggerCharacter.length, ); return { triggerCharacter: suggestionPluginTransactionMeta.triggerCharacter, deleteTriggerCharacter: suggestionPluginTransactionMeta.deleteTriggerCharacter !== false, // When reading the queryStartPos, we offset the result by the length of the trigger character, to make it easy on the caller queryStartPos: () => trackedPosition() + suggestionPluginTransactionMeta.triggerCharacter.length, query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, ignoreQueryLength: suggestionPluginTransactionMeta?.ignoreQueryLength, }; } // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated. if (prev === undefined) { return prev; } // Checks if the menu should be hidden. if ( // Highlighting text should hide the menu. newState.selection.from !== newState.selection.to || // Transactions with plugin metadata should hide the menu. suggestionPluginTransactionMeta === null || // Certain mouse events should hide the menu. // TODO: Change to global mousedown listener. transaction.getMeta("focus") || transaction.getMeta("blur") || transaction.getMeta("pointer") || // Moving the caret before the character which triggered the menu should hide it. (prev.triggerCharacter !== undefined && newState.selection.from < prev.queryStartPos()) || // Moving the caret to a new block should hide the menu. !newState.selection.$from.sameParent( newState.doc.resolve(prev.queryStartPos()), ) ) { return undefined; } const next = { ...prev }; // Updates the current query. next.query = newState.doc.textBetween( prev.queryStartPos(), newState.selection.from, ); return next; }, }, props: { handleTextInput(view, _from, _to, text) { if (triggerCharacters.includes(text)) { view.dispatch(view.state.tr.insertText(text)); view.dispatch( view.state.tr .setMeta(suggestionMenuPluginKey, { triggerCharacter: text, }) .scrollIntoView(), ); return true; } return false; }, // Setup decorator on the currently active suggestion. decorations(state) { const suggestionPluginState: SuggestionPluginState = ( this as Plugin ).getState(state); if (suggestionPluginState === undefined) { return null; } // If the menu was opened programmatically by another extension, it may not use a trigger character. In this // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty. if (!suggestionPluginState.deleteTriggerCharacter) { const blockNode = findBlock(state.selection); if (blockNode) { return DecorationSet.create(state.doc, [ Decoration.node( blockNode.pos, blockNode.pos + blockNode.node.nodeSize, { nodeName: "span", class: "bn-suggestion-decorator", "data-decoration-id": suggestionPluginState.decorationId, }, ), ]); } } // Creates an inline decoration around the trigger character. return DecorationSet.create(state.doc, [ Decoration.inline( suggestionPluginState.queryStartPos() - suggestionPluginState.triggerCharacter!.length, suggestionPluginState.queryStartPos(), { nodeName: "span", class: "bn-suggestion-decorator", "data-decoration-id": suggestionPluginState.decorationId, }, ), ]); }, }, }), ); } public onUpdate( triggerCharacter: string, callback: (state: SuggestionMenuState) => void, ) { if (!this.triggerCharacters.includes(triggerCharacter)) { this.addTriggerCharacter(triggerCharacter); } // TODO: be able to remove the triggerCharacter return this.on(`update ${triggerCharacter}`, callback); } addTriggerCharacter = (triggerCharacter: string) => { this.triggerCharacters.push(triggerCharacter); }; // TODO: Should this be called automatically when listeners are removed? removeTriggerCharacter = (triggerCharacter: string) => { this.triggerCharacters = this.triggerCharacters.filter( (c) => c !== triggerCharacter, ); }; closeMenu = () => this.view!.closeMenu(); clearQuery = () => this.view!.clearQuery(); public get shown() { return this.view?.state?.show || false; } } export function createSuggestionMenu< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(editor: BlockNoteEditor<BSchema, I, S>, triggerCharacter: string) { editor.suggestionMenus.addTriggerCharacter(triggerCharacter); }