UNPKG

@blocknote/core

Version:

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

212 lines (183 loc) 5.87 kB
import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { ySyncPluginKey } from "y-prosemirror"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import type { BlockFromConfig, FileBlockConfig, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; export type FilePanelState< I extends InlineContentSchema, S extends StyleSchema, > = UiElementPosition & { // TODO: This typing is not quite right (children should be from BSchema) block: BlockFromConfig<FileBlockConfig, I, S>; }; export class FilePanelView<I extends InlineContentSchema, S extends StyleSchema> implements PluginView { public state?: FilePanelState<I, S>; public emitUpdate: () => void; constructor( private readonly editor: BlockNoteEditor< Record<string, FileBlockConfig>, I, S >, private readonly pluginKey: PluginKey<FilePanelState<I, S>>, private readonly pmView: EditorView, emitUpdate: (state: FilePanelState<I, S>) => void, ) { this.emitUpdate = () => { if (!this.state) { throw new Error("Attempting to update uninitialized file panel"); } emitUpdate(this.state); }; pmView.dom.addEventListener("mousedown", this.mouseDownHandler); pmView.dom.addEventListener("dragstart", this.dragstartHandler); // 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); } mouseDownHandler = () => { if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; // For dragging the whole editor. dragstartHandler = () => { if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; scrollHandler = () => { if (this.state?.show) { const blockElement = this.pmView.root.querySelector( `[data-node-type="blockContainer"][data-id="${this.state.block.id}"]`, ); if (!blockElement) { return; } this.state.referencePos = blockElement.getBoundingClientRect(); this.emitUpdate(); } }; update(view: EditorView, prevState: EditorState) { const pluginState = this.pluginKey.getState(view.state); const prevPluginState = this.pluginKey.getState(prevState); if (!this.state?.show && pluginState?.block && this.editor.isEditable) { const blockElement = this.pmView.root.querySelector( `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]`, ); if (!blockElement) { return; } this.state = { show: true, referencePos: blockElement.getBoundingClientRect(), block: pluginState.block, }; this.emitUpdate(); return; } const isOpening = pluginState?.block && !prevPluginState?.block; const isClosing = !pluginState?.block && prevPluginState?.block; if (isOpening && this.state && !this.state.show) { this.state.show = true; this.emitUpdate(); } if (isClosing && this.state?.show) { this.state.show = false; this.emitUpdate(); } } closeMenu = () => { if (this.state?.show) { this.state.show = false; this.emitUpdate(); } }; destroy() { this.pmView.dom.removeEventListener("mousedown", this.mouseDownHandler); this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler); this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); } } const filePanelPluginKey = new PluginKey<FilePanelState<any, any>>( "FilePanelPlugin", ); export class FilePanelProsemirrorPlugin< I extends InlineContentSchema, S extends StyleSchema, > extends BlockNoteExtension { public static key() { return "filePanel"; } private view: FilePanelView<I, S> | undefined; constructor(editor: BlockNoteEditor<Record<string, FileBlockConfig>, I, S>) { super(); this.addProsemirrorPlugin( new Plugin<{ block: BlockFromConfig<FileBlockConfig, I, S> | undefined; }>({ key: filePanelPluginKey, view: (editorView) => { this.view = new FilePanelView<I, S>( editor, filePanelPluginKey, 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; }, }, state: { init: () => { return { block: undefined, }; }, apply: (transaction, prev) => { const state: FilePanelState<I, S> | undefined = transaction.getMeta(filePanelPluginKey); if (state) { return state; } if ( !transaction.getMeta(ySyncPluginKey) && (transaction.selectionSet || transaction.docChanged) ) { return { block: undefined }; } return prev; }, }, }), ); } public get shown() { return this.view?.state?.show || false; } public onUpdate(callback: (state: FilePanelState<I, S>) => void) { return this.on("update", callback); } public closeMenu = () => this.view?.closeMenu(); }