@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
text/typescript
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);
},
},
}),
);
}
}