UNPKG

@blocknote/core

Version:

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

309 lines (260 loc) 9 kB
import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core"; import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; 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"; export type FormattingToolbarState = UiElementPosition; export class FormattingToolbarView implements PluginView { public state?: FormattingToolbarState; public emitUpdate: () => void; public preventHide = false; public preventShow = false; public shouldShow: (props: { view: EditorView; state: EditorState; from: number; to: number; }) => boolean = ({ view, state, from, to }) => { const { doc, selection } = state; const { empty } = selection; // Sometime check for `empty` is not enough. // Doubleclick an empty paragraph returns a node size of 2. // So we check also for an empty text size. const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection); // Don't show toolbar inside code blocks if ( selection.$from.parent.type.spec.code || (isNodeSelection(selection) && selection.node.type.spec.code) ) { return false; } if (empty || isEmptyTextBlock) { return false; } const focusedElement = document.activeElement; if (!this.isElementWithinEditorWrapper(focusedElement) && view.editable) { // editable editors must have focus for the toolbar to show return false; } return true; }; constructor( private readonly editor: BlockNoteEditor< BlockSchema, InlineContentSchema, StyleSchema >, private readonly pmView: EditorView, emitUpdate: (state: FormattingToolbarState) => void, ) { this.emitUpdate = () => { if (!this.state) { throw new Error( "Attempting to update uninitialized formatting toolbar", ); } emitUpdate(this.state); }; pmView.dom.addEventListener("mousedown", this.viewMousedownHandler); pmView.root.addEventListener("mouseup", this.mouseupHandler); pmView.dom.addEventListener("dragstart", this.dragHandler); pmView.dom.addEventListener("dragover", this.dragHandler); pmView.dom.addEventListener("blur", this.blurHandler); // 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. pmView.root.addEventListener("scroll", this.scrollHandler, true); } blurHandler = (event: FocusEvent) => { if (this.preventHide) { this.preventHide = false; return; } const editorWrapper = this.pmView.dom.parentElement!; // Checks if the focus is moving to an element outside the editor. If it is, // the toolbar is hidden. if ( // An element is clicked. event && event.relatedTarget && // Element is inside the editor. (editorWrapper === (event.relatedTarget as Node) || editorWrapper.contains(event.relatedTarget as Node) || (event.relatedTarget as HTMLElement).matches( ".bn-ui-container, .bn-ui-container *", )) ) { return; } if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; isElementWithinEditorWrapper = (element: Node | null) => { if (!element) { return false; } const editorWrapper = this.pmView.dom.parentElement; if (!editorWrapper) { return false; } return editorWrapper.contains(element); }; viewMousedownHandler = (e: MouseEvent) => { if (!this.isElementWithinEditorWrapper(e.target as Node)) { this.preventShow = true; } }; mouseupHandler = () => { if (this.preventShow) { this.preventShow = false; setTimeout(() => this.update(this.pmView)); } }; // For dragging the whole editor. dragHandler = () => { if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; scrollHandler = () => { if (this.state?.show) { this.state.referencePos = this.getSelectionBoundingBox(); this.emitUpdate(); } }; update(view: EditorView, oldState?: EditorState) { // Delays the update to handle edge case with drag and drop, where the view // is blurred asynchronously and happens only after the state update. // Wrapping in a setTimeout gives enough time to wait for the blur event to // occur before updating the toolbar. const { state, composing } = view; const { doc, selection } = state; const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); if (composing || isSame) { return; } // support for CellSelections const { ranges } = selection; const from = Math.min(...ranges.map((range) => range.$from.pos)); const to = Math.max(...ranges.map((range) => range.$to.pos)); const shouldShow = this.shouldShow({ view, state, from, to, }); // in jsdom, Range.prototype.getClientRects is not implemented, // this would cause `getSelectionBoundingBox` to fail // we can just ignore jsdom for now and not show the toolbar const jsdom = typeof Range.prototype.getClientRects === "undefined"; // Checks if menu should be shown/updated. if (!this.preventShow && (shouldShow || this.preventHide) && !jsdom) { // Unlike other UI elements, we don't prevent the formatting toolbar from // showing when the editor is not editable. This is because some buttons, // e.g. the download file button, should still be accessible. Therefore, // logic for hiding when the editor is non-editable is handled // individually in each button. const nextState = { show: true, referencePos: this.getSelectionBoundingBox(), }; if ( nextState.show !== this.state?.show || nextState.referencePos.toJSON() !== this.state?.referencePos.toJSON() ) { this.state = nextState; this.emitUpdate(); } return; } // Checks if menu should be hidden. if ( this.state?.show && !this.preventHide && (!shouldShow || this.preventShow || !this.editor.isEditable) ) { this.state.show = false; this.emitUpdate(); return; } } destroy() { this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); this.pmView.root.removeEventListener("mouseup", this.mouseupHandler); this.pmView.dom.removeEventListener("dragstart", this.dragHandler); this.pmView.dom.removeEventListener("dragover", this.dragHandler); this.pmView.dom.removeEventListener("blur", this.blurHandler); this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); } closeMenu = () => { if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; getSelectionBoundingBox() { const { state } = this.pmView; const { selection } = state; // support for CellSelections const { ranges } = selection; const from = Math.min(...ranges.map((range) => range.$from.pos)); const to = Math.max(...ranges.map((range) => range.$to.pos)); if (isNodeSelection(selection)) { const node = this.pmView.nodeDOM(from) as HTMLElement; if (node) { return node.getBoundingClientRect(); } } return posToDOMRect(this.pmView, from, to); } } export const formattingToolbarPluginKey = new PluginKey( "FormattingToolbarPlugin", ); export class FormattingToolbarProsemirrorPlugin extends BlockNoteExtension { public static key() { return "formattingToolbar"; } private view: FormattingToolbarView | undefined; constructor(editor: BlockNoteEditor<any, any, any>) { super(); this.addProsemirrorPlugin( new Plugin({ key: formattingToolbarPluginKey, view: (editorView) => { this.view = new FormattingToolbarView(editor, editorView, (state) => { this.emit("update", state); }); return this.view; }, props: { handleKeyDown: (_view, event: KeyboardEvent) => { if (event.key === "Escape" && this.shown) { this.view!.closeMenu(); return true; } return false; }, }, }), ); } public get shown() { return this.view?.state?.show || false; } public onUpdate(callback: (state: FormattingToolbarState) => void) { return this.on("update", callback); } public closeMenu = () => this.view!.closeMenu(); }