UNPKG

@blocknote/core

Version:

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

143 lines (128 loc) 5.79 kB
import { Selection, TextSelection } from "prosemirror-state"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; /** * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A * single editor-wide extension that drives the source popup for inline content * that renders a preview. Which inline content it activates on is decided by * each spec's `meta.hasPreview` flag, so individual inline content opts in * rather than the extension being configured with a type. * * Unlike the block version, the popup isn't toggled with a separate state flag: * it's open exactly when the selection is inside the inline content's source. * The store therefore only tracks which inline content (by its position) holds * the selection - moving the selection in opens its popup, moving it out closes * it. Since the source popup is always laid out (just hidden via opacity), the * cursor can navigate into and out of it with the arrow keys as usual. * * The extension is registered once (it's a default extension) and is a no-op * when no inline content declares `meta.hasPreview`. */ export const SourceInlineContentWithPreviewExtension = createExtension( ({ editor }: { editor: BlockNoteEditor<any, any, any> }) => { const store = createStore<{ selected: number | undefined; }>({ selected: undefined, }); // Inline content has a preview iff its spec's implementation declares // `meta.hasPreview`. const nodeHasPreview = (nodeName: string) => !!editor.schema.inlineContentSpecs[nodeName]?.implementation?.meta ?.hasPreview; // Moves the selection out of the inline content, to just `"before"` or // `"after"` it, which closes the popup via the selection-change handler // below. Lets the keyboard commit-and-exit the source the same way arrowing // past its edge does, keeps Enter from splitting the block while editing the // source, and lets the up/down arrows step out of the source rather than // staying trapped inside it. const moveSelectionOut = (direction: "before" | "after") => ({ editor }: { editor: BlockNoteEditor<any, any, any> }) => { const { $from } = editor.prosemirrorState.selection; const node = $from.node(); if (!nodeHasPreview(node.type.name)) { return false; } const view = editor.prosemirrorView!; const selection = Selection.near( view.state.doc.resolve( direction === "before" ? $from.before() : $from.after(), ), direction === "before" ? -1 : 1, ); view.dispatch(view.state.tr.setSelection(selection)); return true; }; return { key: "sourceInlineContentWithPreview", store, keyboardShortcuts: { Enter: moveSelectionOut("after"), "Shift-Enter": moveSelectionOut("after"), Escape: moveSelectionOut("after"), ArrowUp: moveSelectionOut("before"), ArrowDown: moveSelectionOut("after"), // While editing the source, selects the whole source instead of the // whole document. "Mod-a": ({ editor }) => { const { $from } = editor.prosemirrorState.selection; if (!nodeHasPreview($from.node().type.name)) { return false; } const view = editor.prosemirrorView!; view.dispatch( view.state.tr.setSelection( TextSelection.create(view.state.doc, $from.start(), $from.end()), ), ); return true; }, }, mount: ({ dom, signal }) => { // The popup is open exactly when the selection is inside the inline // content, so we just track which inline content (if any) holds it. const unsubscribeSelectionChange = editor.onSelectionChange(() => { const { $from } = editor.prosemirrorState.selection; const node = $from.node(); store.setState({ selected: nodeHasPreview(node.type.name) ? $from.before() : undefined, }); }); signal.addEventListener("abort", unsubscribeSelectionChange); // Sets `visibility: hidden` on the popup for a single frame when pressing up/down arrow // keys. The popup is normally hidden through `opacity: 0`, which means it's still visible // to the browser for navigation. Therefore, the up/down arrows can sometimes move the // selection into the popup from unexpected positions, such as on the same line. Setting // `visibility: hidden` makes the browser ignore it when determining the new selection. // TODO: This is hacky, we should find a cleaner solution. const handleVerticalArrow = (event: KeyboardEvent) => { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") { return; } // When the selection is already inside a source, leave navigation // (moving within or out of it) to the browser as usual. const { $from } = editor.prosemirrorState.selection; if (nodeHasPreview($from.node().type.name)) { return; } dom.classList.add("bn-suppress-source-popup-caret"); requestAnimationFrame(() => dom.classList.remove("bn-suppress-source-popup-caret"), ); }; dom.addEventListener("keydown", handleVerticalArrow, { capture: true, signal, }); const handleBlur = () => store.setState({ selected: undefined }); dom.addEventListener("blur", handleBlur, { capture: true, signal }); }, }; }, );