UNPKG

@blocknote/core

Version:

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

148 lines (124 loc) 4.82 kB
import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 } from "uuid"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); export class PlaceholderPlugin extends BlockNoteExtension { public static key() { return "placeholder"; } constructor( editor: BlockNoteEditor<any, any, any>, placeholders: Record< string | "default" | "emptyDocument", string | undefined >, ) { super(); this.addProsemirrorPlugin( new Plugin({ key: PLUGIN_KEY, view: (view) => { const uniqueEditorSelector = `placeholder-selector-${v4()}`; view.dom.classList.add(uniqueEditorSelector); const styleEl = document.createElement("style"); const nonce = editor._tiptapEditor.options.injectNonce; if (nonce) { styleEl.setAttribute("nonce", nonce); } if (editor.prosemirrorView?.root instanceof ShadowRoot) { editor.prosemirrorView.root.append(styleEl); } else { editor.prosemirrorView?.root.head.appendChild(styleEl); } const styleSheet = styleEl.sheet!; const getSelector = (additionalSelectors = "") => `.${uniqueEditorSelector} .bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`; try { // FIXME: the names "default" and "emptyDocument" are hardcoded const { default: defaultPlaceholder, emptyDocument: emptyPlaceholder, ...rest } = placeholders; // add block specific placeholders for (const [blockType, placeholder] of Object.entries(rest)) { const blockTypeSelector = `[data-content-type="${blockType}"]`; styleSheet.insertRule( `${getSelector(blockTypeSelector)} { content: ${JSON.stringify( placeholder, )}; }`, ); } const onlyBlockSelector = `[data-is-only-empty-block]`; const mustBeFocusedSelector = `[data-is-empty-and-focused]`; // placeholder for when there's only one empty block styleSheet.insertRule( `${getSelector(onlyBlockSelector)} { content: ${JSON.stringify( emptyPlaceholder, )}; }`, ); // placeholder for default blocks, only when the cursor is in the block (mustBeFocused) styleSheet.insertRule( `${getSelector(mustBeFocusedSelector)} { content: ${JSON.stringify( defaultPlaceholder, )}; }`, ); } catch (e) { // eslint-disable-next-line no-console console.warn( `Failed to insert placeholder CSS rule - this is likely due to the browser not supporting certain CSS pseudo-element selectors (:has, :only-child:, or :before)`, e, ); } return { destroy: () => { if (editor.prosemirrorView?.root instanceof ShadowRoot) { editor.prosemirrorView.root.removeChild(styleEl); } else { editor.prosemirrorView?.root.head.removeChild(styleEl); } }, }; }, props: { decorations: (state) => { const { doc, selection } = state; if (!editor.isEditable) { return; } if (!selection.empty) { return; } // Don't show placeholder when the cursor is inside a code block if (selection.$from.parent.type.spec.code) { return; } const decs = []; // decoration for when there's only one empty block // positions are hardcoded for now if (state.doc.content.size === 6) { decs.push( Decoration.node(2, 4, { "data-is-only-empty-block": "true", }), ); } const $pos = selection.$anchor; const node = $pos.parent; if (node.content.size === 0) { const before = $pos.before(); decs.push( Decoration.node(before, before + node.nodeSize, { "data-is-empty-and-focused": "true", }), ); } return DecorationSet.create(doc, decs); }, }, }), ); } }