From 576d29d6bc3453602e449b3bf0b1b051a98869c5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 15 Oct 2026 18:20:27 +0200 Subject: [PATCH 1/2] Added DOM-based placeholder implementation --- packages/core/src/editor/Block.css | 16 +- .../Placeholder/Placeholder.benchmark.ts | 123 ++++++++++++++ .../extensions/Placeholder/Placeholder.old.ts | 147 +++++++++++++++++ .../Placeholder/Placeholder.test.ts | 32 ++-- .../src/extensions/Placeholder/Placeholder.ts | 155 +++++------------- packages/react/src/editor/styles.css | 2 +- tests/a11y/benchmark-placeholders.md | 13 ++ tests/a11y/benchmark-placeholders.sh | 8 + .../a11y/vite.placeholder-benchmark.config.ts | 14 ++ 9 files changed, 382 insertions(+), 128 deletions(-) create mode 100644 packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts create mode 100644 packages/core/src/extensions/Placeholder/Placeholder.old.ts create mode 100644 tests/a11y/benchmark-placeholders.md create mode 100644 tests/a11y/benchmark-placeholders.sh create mode 100644 tests/a11y/vite.placeholder-benchmark.config.ts diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index d18a3ffb76..7a2275e714 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -866,12 +866,20 @@ NESTED BLOCKS } /* PLACEHOLDERS*/ -.bn-block-content:has(.ProseMirror-trailingBreak:only-child):after { +.bn-inline-content:has(> .bn-placeholder) { + position: relative; + flex: 1; +} + +.bn-placeholder { + position: absolute; + z-index: 1; + inset-inline-start: 0; + top: 0; + width: 100%; font-style: italic; - /* Removes text cursor offset. */ - margin-inline: -2px; pointer-events: none; - max-width: 100%; + user-select: none; } /* TEXT COLORS */ diff --git a/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts b/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts new file mode 100644 index 0000000000..9b50ede8df --- /dev/null +++ b/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts @@ -0,0 +1,123 @@ +/* eslint-disable no-console -- This opt-in benchmark reports timings to the terminal. */ +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { en } from "../../i18n/locales/en.js"; +import { PlaceholderExtension } from "./Placeholder.js"; +import { PlaceholderExtension as OldPlaceholderExtension } from "./Placeholder.old.js"; +import { expect, test } from "vite-plus/test"; +import "@blocknote/core/fonts/inter.css"; +import "@blocknote/mantine/style.css"; + +function nextPaint() { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +function median(samples: number[]) { + const sorted = [...samples].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return (sorted[middle - 1] + sorted[middle]) / 2; +} + +// Opt-in benchmark, excluded from normal tests. Run the script in tests/a11y. +// Render time measures editor creation, mounting, and forced layout. +test("compare CSS and DOM placeholders with 1,000 empty blocks", async () => { + // Preserve the old implementation's presentation without shipping its CSS. + const legacyStyle = document.createElement("style"); + legacyStyle.textContent = ` + .bn-block-content:has(.ProseMirror-trailingBreak:only-child):after { + font-style: italic; + margin-inline: -2px; + pointer-events: none; + max-width: 100%; + color: var(--bn-colors-side-menu); + } + `; + document.head.append(legacyStyle); + await document.fonts.load("16px Inter"); + await document.fonts.ready; + + try { + for (const type of ["paragraph", "bulletListItem"] as const) { + const initialContent = Array.from({ length: 1000 }, (_, index) => ({ + id: `block-${index}`, + type, + })); + const samples = { + CSS: [] as number[], + DOM: [] as number[], + }; + // Five warm-up pairs, then 30 measured pairs; alternate execution order. + for (let iteration = 0; iteration < 35; iteration++) { + const order = + iteration % 2 ? (["DOM", "CSS"] as const) : (["CSS", "DOM"] as const); + for (const implementation of order) { + const host = document.createElement("div"); + host.className = "bn-container bn-root"; + host.dataset.colorScheme = "light"; + host.style.width = "900px"; + document.body.append(host); + await nextPaint(); + const extension = + implementation === "CSS" + ? OldPlaceholderExtension + : PlaceholderExtension; + const start = performance.now(); + const editor = BlockNoteEditor.create({ + initialContent, + disableExtensions: ["placeholder"], + extensions: [ + ({ editor }) => ({ + ...extension({ placeholders: en.placeholders })({ editor }), + key: "benchmark-placeholder", + }), + ], + }); + try { + editor.mount(host); + const height = host.getBoundingClientRect().height; + const layout = performance.now() - start; + await nextPaint(); + // Validate outside the timed region so missing placeholders cannot + // masquerade as a performance improvement. + expect(host.querySelectorAll(".bn-block-content")).toHaveLength( + 1000, + ); + expect(height).toBeGreaterThan(1000); + const placeholders = + implementation === "DOM" + ? host.querySelectorAll(".bn-placeholder").length + : [...host.querySelectorAll(".bn-block-content")].filter( + (block) => { + const content = getComputedStyle( + block, + "::after", + ).content; + return ( + content !== "none" && + content !== "normal" && + content !== '""' + ); + }, + ).length; + expect(placeholders).toBe(type === "paragraph" ? 1 : 1000); + if (iteration >= 5) { + samples[implementation].push(layout); + } + } finally { + editor._tiptapEditor.destroy(); + host.remove(); + } + } + } + const oldMedian = median(samples.CSS); + const newMedian = median(samples.DOM); + const delta = (newMedian / oldMedian - 1) * 100; + console.log( + `${type}: median render time — old ${oldMedian.toFixed(2)} ms, new ${newMedian.toFixed(2)} ms; delta ${delta >= 0 ? "+" : ""}${delta.toFixed(1)}%`, + ); + } + } finally { + legacyStyle.remove(); + } +}); diff --git a/packages/core/src/extensions/Placeholder/Placeholder.old.ts b/packages/core/src/extensions/Placeholder/Placeholder.old.ts new file mode 100644 index 0000000000..fedcbadbdc --- /dev/null +++ b/packages/core/src/extensions/Placeholder/Placeholder.old.ts @@ -0,0 +1,147 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { uuidv4 } from "lib0/random"; + +import { + createExtension, + ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; +import { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor.js"; + +const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); + +export const PlaceholderExtension = createExtension( + ({ + editor, + options, + }: ExtensionOptions< + Pick, "placeholders"> + >) => { + const placeholders = options.placeholders; + return { + key: "placeholder", + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + view: (view) => { + const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`; + view.dom.classList.add(uniqueEditorSelector); + const styleEl = document.createElement("style"); + + const nonce = editor._tiptapEditor.options.injectNonce; + if (nonce) { + styleEl.setAttribute("nonce", nonce); + } + + if (view.root instanceof window.ShadowRoot) { + view.root.append(styleEl); + } else { + view.root.head.appendChild(styleEl); + } + + const styleSheet = styleEl.sheet!; + + const getSelector = (additionalSelectors = "") => + `.${uniqueEditorSelector} .bn-block-content${additionalSelectors}:has(.ProseMirror-trailingBreak:only-child):after`; + + 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: () => { + // `remove()` is a no-op if something else (e.g. a router + // merging ``) already detached the element. + styleEl.remove(); + }, + }; + }, + 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); + }, + }, + }), + ], + } as const; + }, +); diff --git a/packages/core/src/extensions/Placeholder/Placeholder.test.ts b/packages/core/src/extensions/Placeholder/Placeholder.test.ts index 1c9b2b3227..39e9a36d9b 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.test.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.test.ts @@ -6,18 +6,24 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; * @vitest-environment jsdom */ -it("does not throw on unmount when its style element was already removed", () => { - const editor = BlockNoteEditor.create(); - editor.mount(document.createElement("div")); +it("renders a placeholder without injecting placeholder CSS and unmounts cleanly", () => { + const host = document.createElement("div"); + const editor = BlockNoteEditor.create({ + initialContent: [{ id: "empty", type: "paragraph" }], + }); - const styleEl = [...document.head.querySelectorAll("style")].find((el) => - ( - el.sheet?.cssRules[0] as CSSStyleRule | undefined - )?.selectorText?.startsWith(".placeholder-selector-"), - ); - expect(styleEl).toBeDefined(); - // e.g. a router merging the next page's `` into the current one - styleEl!.remove(); - - expect(() => editor.unmount()).not.toThrow(); + try { + editor.mount(host); + expect(host.querySelector(".bn-placeholder")?.textContent).toBe( + "Enter text or type '/' for commands", + ); + const placeholderRules = [...document.head.querySelectorAll("style")] + .flatMap((style) => [...(style.sheet?.cssRules ?? [])]) + .filter((rule) => rule.cssText.includes(".placeholder-selector-")); + expect(placeholderRules).toEqual([]); + expect(() => editor.unmount()).not.toThrow(); + expect(host.querySelector(".bn-placeholder")).toBeNull(); + } finally { + editor._tiptapEditor.destroy(); + } }); diff --git a/packages/core/src/extensions/Placeholder/Placeholder.ts b/packages/core/src/extensions/Placeholder/Placeholder.ts index fedcbadbdc..bd556949b0 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.ts @@ -1,6 +1,5 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -import { uuidv4 } from "lib0/random"; import { createExtension, @@ -23,121 +22,57 @@ export const PlaceholderExtension = createExtension( prosemirrorPlugins: [ new Plugin({ key: PLUGIN_KEY, - view: (view) => { - const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`; - view.dom.classList.add(uniqueEditorSelector); - const styleEl = document.createElement("style"); - - const nonce = editor._tiptapEditor.options.injectNonce; - if (nonce) { - styleEl.setAttribute("nonce", nonce); - } - - if (view.root instanceof window.ShadowRoot) { - view.root.append(styleEl); - } else { - view.root.head.appendChild(styleEl); - } - - const styleSheet = styleEl.sheet!; - - const getSelector = (additionalSelectors = "") => - `.${uniqueEditorSelector} .bn-block-content${additionalSelectors}:has(.ProseMirror-trailingBreak:only-child):after`; - - 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: () => { - // `remove()` is a no-op if something else (e.g. a router - // merging ``) already detached the element. - styleEl.remove(); - }, - }; - }, 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", - }), - ); + return DecorationSet.empty; } - 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", - }), + const { doc, selection } = state; + const decorations: Decoration[] = []; + doc.descendants((node, pos) => { + if ( + !node.isTextblock || + !node.type.isInGroup("blockContent") || + node.content.size || + node.type.spec.code + ) { + return; + } + + let placeholder = placeholders?.[node.type.name]; + if (doc.content.size === 6) { + placeholder = placeholders?.emptyDocument ?? placeholder; + } + if (selection.empty && selection.$anchor.parent === node) { + placeholder = placeholders?.default ?? placeholder; + } + if (!placeholder) { + return false; + } + + const text = placeholder; + decorations.push( + Decoration.widget( + pos + 1, + (view) => { + const span = view.dom.ownerDocument.createElement("span"); + span.className = "bn-placeholder"; + span.contentEditable = "false"; + span.setAttribute("aria-hidden", "true"); + span.textContent = text; + return span; + }, + { + side: -1, + key: `placeholder-${text}`, + ignoreSelection: true, + }, + ), ); - } - - return DecorationSet.create(doc, decs); + return false; + }); + return DecorationSet.create(doc, decorations); }, }, }), diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index f72b760521..faa287410f 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -120,7 +120,7 @@ } /* Placeholder styling */ -.bn-block-content:has(.ProseMirror-trailingBreak:only-child):after { +.bn-placeholder { color: var(--bn-colors-side-menu); } diff --git a/tests/a11y/benchmark-placeholders.md b/tests/a11y/benchmark-placeholders.md new file mode 100644 index 0000000000..3efffaffbe --- /dev/null +++ b/tests/a11y/benchmark-placeholders.md @@ -0,0 +1,13 @@ +# Placeholder render benchmark + +Start Docker, then run this command from the repository root: + +```sh +bash tests/a11y/benchmark-placeholders.sh +``` + +The script uses the shared browser-test Docker image, building it if needed. It compares `Placeholder.old.ts` (CSS placeholders) with `Placeholder.ts` (DOM placeholders) in Chromium using two documents: 1,000 empty paragraphs and 1,000 empty bullet list items. With the default behavior, paragraphs show one placeholder at the cursor, while every bullet item shows a placeholder. + +Each implementation gets 5 warm-up runs and 30 measured runs per document, alternating execution order. Terminal output shows the old and new median render times and their percentage difference for each document. Render time includes editor creation, mounting, and layout. A positive delta means the new implementation takes longer. + +The benchmark is excluded from normal test runs. Compare results from the same run and avoid other heavy workloads while measuring. diff --git a/tests/a11y/benchmark-placeholders.sh b/tests/a11y/benchmark-placeholders.sh new file mode 100644 index 0000000000..9e32a38a8f --- /dev/null +++ b/tests/a11y/benchmark-placeholders.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run from the repository root: bash tests/a11y/benchmark-placeholders.sh +set -eo pipefail +cd "$(git rev-parse --show-toplevel)" +bash tests/docker-run.sh -e CI=1 \ + -v "$PWD/tests/a11y:/work/tests/a11y" \ + --entrypoint /work/tests/node_modules/.bin/vp -- \ + test --config a11y/vite.placeholder-benchmark.config.ts --run diff --git a/tests/a11y/vite.placeholder-benchmark.config.ts b/tests/a11y/vite.placeholder-benchmark.config.ts new file mode 100644 index 0000000000..ffc449291b --- /dev/null +++ b/tests/a11y/vite.placeholder-benchmark.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite-plus"; +import config from "./vite.config.js"; + +export default defineConfig({ + ...config, + test: { + ...config.test, + name: "placeholder-benchmark", + include: [ + "../packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts", + ], + reporters: ["verbose"], + }, +}); From 5efd6125878feedbed1e12ee1a6539fa494bbf87 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 19 Oct 2026 19:19:49 +0200 Subject: [PATCH 2/2] Improved placeholder render performance --- packages/core/src/editor/Block.css | 16 +- .../Placeholder/Placeholder.benchmark.ts | 93 +++++++---- .../src/extensions/Placeholder/Placeholder.ts | 152 +++++++++++++----- tests/a11y/benchmark-placeholders.md | 8 +- 4 files changed, 195 insertions(+), 74 deletions(-) diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 7a2275e714..f0f965d5cc 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -866,16 +866,20 @@ NESTED BLOCKS } /* PLACEHOLDERS*/ -.bn-inline-content:has(> .bn-placeholder) { - position: relative; +/* Keep aligned placeholders full-width so list markers stay in place. */ +.bn-block-content:is( + [data-text-alignment="center"], + [data-text-alignment="right"] + ) + .bn-inline-content:has(> .bn-placeholder) { flex: 1; } .bn-placeholder { - position: absolute; - z-index: 1; - inset-inline-start: 0; - top: 0; + /* A zero-height float leaves the caret on the empty line without requiring + absolute positioning and a full-width flex item for every empty block. */ + float: inline-start; + height: 0; width: 100%; font-style: italic; pointer-events: none; diff --git a/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts b/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts index 9b50ede8df..c086ac1cd8 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts @@ -19,8 +19,21 @@ function median(samples: number[]) { return (sorted[middle - 1] + sorted[middle]) / 2; } +// Count both implementations outside the timed region to verify that the +// expected placeholders are rendered after each operation. +function countPlaceholders(host: HTMLElement, implementation: "CSS" | "DOM") { + if (implementation === "DOM") { + return host.querySelectorAll(".bn-placeholder").length; + } + return [...host.querySelectorAll(".bn-block-content")].filter((block) => { + const content = getComputedStyle(block, "::after").content; + return content !== "none" && content !== "normal" && content !== '""'; + }).length; +} + // Opt-in benchmark, excluded from normal tests. Run the script in tests/a11y. -// Render time measures editor creation, mounting, and forced layout. +// Each operation includes synchronous rendering and forced style/layout, but +// excludes the following animation frames and validation (and does not time paint). test("compare CSS and DOM placeholders with 1,000 empty blocks", async () => { // Preserve the old implementation's presentation without shipping its CSS. const legacyStyle = document.createElement("style"); @@ -44,8 +57,16 @@ test("compare CSS and DOM placeholders with 1,000 empty blocks", async () => { type, })); const samples = { - CSS: [] as number[], - DOM: [] as number[], + CSS: { + initial: [] as number[], + selection: [] as number[], + content: [] as number[], + }, + DOM: { + initial: [] as number[], + selection: [] as number[], + content: [] as number[], + }, }; // Five warm-up pairs, then 30 measured pairs; alternate execution order. for (let iteration = 0; iteration < 35; iteration++) { @@ -84,25 +105,41 @@ test("compare CSS and DOM placeholders with 1,000 empty blocks", async () => { 1000, ); expect(height).toBeGreaterThan(1000); - const placeholders = - implementation === "DOM" - ? host.querySelectorAll(".bn-placeholder").length - : [...host.querySelectorAll(".bn-block-content")].filter( - (block) => { - const content = getComputedStyle( - block, - "::after", - ).content; - return ( - content !== "none" && - content !== "normal" && - content !== '""' - ); - }, - ).length; - expect(placeholders).toBe(type === "paragraph" ? 1 : 1000); + const expectedPlaceholders = type === "paragraph" ? 1 : 1000; + expect(countPlaceholders(host, implementation)).toBe( + expectedPlaceholders, + ); + + // Move from the first block to the middle of the same document. + await nextPaint(); + const selectionStart = performance.now(); + editor.setTextCursorPosition("block-500"); + host.getBoundingClientRect(); + const selectionLayout = performance.now() - selectionStart; + await nextPaint(); + expect(editor.getTextCursorPosition().block.id).toBe("block-500"); + expect(countPlaceholders(host, implementation)).toBe( + expectedPlaceholders, + ); + + // Insert one character into that empty block. This changes the + // document and removes its placeholder, exercising cache invalidation. + await nextPaint(); + const contentStart = performance.now(); + const view = editor.prosemirrorView; + view.dispatch(view.state.tr.insertText("x")); + host.getBoundingClientRect(); + const contentLayout = performance.now() - contentStart; + await nextPaint(); + expect(view.state.selection.$anchor.parent.textContent).toBe("x"); + expect(countPlaceholders(host, implementation)).toBe( + expectedPlaceholders - 1, + ); + if (iteration >= 5) { - samples[implementation].push(layout); + samples[implementation].initial.push(layout); + samples[implementation].selection.push(selectionLayout); + samples[implementation].content.push(contentLayout); } } finally { editor._tiptapEditor.destroy(); @@ -110,12 +147,14 @@ test("compare CSS and DOM placeholders with 1,000 empty blocks", async () => { } } } - const oldMedian = median(samples.CSS); - const newMedian = median(samples.DOM); - const delta = (newMedian / oldMedian - 1) * 100; - console.log( - `${type}: median render time — old ${oldMedian.toFixed(2)} ms, new ${newMedian.toFixed(2)} ms; delta ${delta >= 0 ? "+" : ""}${delta.toFixed(1)}%`, - ); + for (const phase of ["initial", "selection", "content"] as const) { + const oldMedian = median(samples.CSS[phase]); + const newMedian = median(samples.DOM[phase]); + const delta = (newMedian / oldMedian - 1) * 100; + console.log( + `${type} ${phase}: median render time — CSS ${oldMedian.toFixed(2)} ms, DOM ${newMedian.toFixed(2)} ms; delta ${delta >= 0 ? "+" : ""}${delta.toFixed(1)}%`, + ); + } } } finally { legacyStyle.remove(); diff --git a/packages/core/src/extensions/Placeholder/Placeholder.ts b/packages/core/src/extensions/Placeholder/Placeholder.ts index bd556949b0..6bb09667fc 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.ts @@ -1,3 +1,4 @@ +import type { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; @@ -9,6 +10,8 @@ import { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor.js"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); +// Renders static placeholders for empty blocks and a selection placeholder +// for the block containing the cursor, caching both across state updates. export const PlaceholderExtension = createExtension( ({ editor, @@ -17,62 +20,131 @@ export const PlaceholderExtension = createExtension( Pick, "placeholders"> >) => { const placeholders = options.placeholders; + // Static placeholders depend only on the document; selection placeholders + // override them at the cursor. The cached static set stays intact. + let cache: + | { + doc: Node; + staticPlaceholders: DecorationSet; + selectionPlaceholderPosition: number | undefined; + finalPlaceholders: DecorationSet; + } + | undefined; + + // Checks whether a node is an empty, non-code block that accepts placeholder text. + function canShowPlaceholder(node: Node) { + return ( + node.isTextblock && + node.type.isInGroup("blockContent") && + node.content.size === 0 && + !node.type.spec.code + ); + } + + // Creates placeholder decorations for blocks like headings/list items which are shown + // regardless of selection position. + function createStaticPlaceholders(doc: Node) { + const decorations: Decoration[] = []; + doc.descendants((node, pos) => { + if (!node.isTextblock) { + return; + } + if (canShowPlaceholder(node)) { + let text = placeholders?.[node.type.name]; + // A document containing one empty block has this fixed node size. + if (doc.content.size === 6) { + text = placeholders?.emptyDocument ?? text; + } + if (text) { + decorations.push(createPlaceholderWidget(pos + 1, text)); + } + } + // Inline children cannot contain another block placeholder. + return false; + }); + return DecorationSet.create(doc, decorations); + } + + // Creates a widget that lazily renders a non-editable placeholder span at the given position. + function createPlaceholderWidget(pos: number, text: string) { + return Decoration.widget( + pos, + (view) => { + const span = view.dom.ownerDocument.createElement("span"); + span.className = "bn-placeholder"; + span.contentEditable = "false"; + span.setAttribute("aria-hidden", "true"); + span.textContent = text; + return span; + }, + { side: -1, key: `placeholder-${text}`, ignoreSelection: true }, + ); + } + return { key: "placeholder", prosemirrorPlugins: [ new Plugin({ key: PLUGIN_KEY, props: { + // Reuses static placeholders and applies the selection placeholder for this state. decorations: (state) => { if (!editor.isEditable) { return DecorationSet.empty; } const { doc, selection } = state; - const decorations: Decoration[] = []; - doc.descendants((node, pos) => { - if ( - !node.isTextblock || - !node.type.isInGroup("blockContent") || - node.content.size || - node.type.spec.code - ) { - return; - } + const selectionPlaceholderPosition = + selection.empty && canShowPlaceholder(selection.$anchor.parent) + ? selection.$anchor.start() + : undefined; - let placeholder = placeholders?.[node.type.name]; - if (doc.content.size === 6) { - placeholder = placeholders?.emptyDocument ?? placeholder; - } - if (selection.empty && selection.$anchor.parent === node) { - placeholder = placeholders?.default ?? placeholder; - } - if (!placeholder) { - return false; - } + // ProseMirror documents are immutable. The same document and + // selection placeholder position produce the same decorations, even in a new state. + if ( + cache?.doc === doc && + cache.selectionPlaceholderPosition === + selectionPlaceholderPosition + ) { + return cache.finalPlaceholders; + } - const text = placeholder; - decorations.push( - Decoration.widget( - pos + 1, - (view) => { - const span = view.dom.ownerDocument.createElement("span"); - span.className = "bn-placeholder"; - span.contentEditable = "false"; - span.setAttribute("aria-hidden", "true"); - span.textContent = text; - return span; - }, - { - side: -1, - key: `placeholder-${text}`, - ignoreSelection: true, - }, + // Only document edits require a traversal. Cursor moves reuse the + // static placeholders and update the selection placeholder. + const staticPlaceholders = + cache?.doc === doc + ? cache.staticPlaceholders + : createStaticPlaceholders(doc); + let finalPlaceholders = staticPlaceholders; + const selectionPlaceholderText = placeholders?.default; + if ( + selectionPlaceholderPosition !== undefined && + selectionPlaceholderText != null + ) { + finalPlaceholders = staticPlaceholders.remove( + staticPlaceholders.find( + selectionPlaceholderPosition, + selectionPlaceholderPosition, ), ); - return false; - }); - return DecorationSet.create(doc, decorations); + // Empty selection text hides the static placeholder at the cursor. + if (selectionPlaceholderText) { + finalPlaceholders = finalPlaceholders.add(doc, [ + createPlaceholderWidget( + selectionPlaceholderPosition, + selectionPlaceholderText, + ), + ]); + } + } + + cache = { + doc, + staticPlaceholders, + selectionPlaceholderPosition, + finalPlaceholders, + }; + return finalPlaceholders; }, }, }), diff --git a/tests/a11y/benchmark-placeholders.md b/tests/a11y/benchmark-placeholders.md index 3efffaffbe..80cbecc62d 100644 --- a/tests/a11y/benchmark-placeholders.md +++ b/tests/a11y/benchmark-placeholders.md @@ -8,6 +8,12 @@ bash tests/a11y/benchmark-placeholders.sh The script uses the shared browser-test Docker image, building it if needed. It compares `Placeholder.old.ts` (CSS placeholders) with `Placeholder.ts` (DOM placeholders) in Chromium using two documents: 1,000 empty paragraphs and 1,000 empty bullet list items. With the default behavior, paragraphs show one placeholder at the cursor, while every bullet item shows a placeholder. -Each implementation gets 5 warm-up runs and 30 measured runs per document, alternating execution order. Terminal output shows the old and new median render times and their percentage difference for each document. Render time includes editor creation, mounting, and layout. A positive delta means the new implementation takes longer. +Each implementation gets 5 warm-up runs and 30 measured runs per document, alternating execution order. Terminal output shows the old and new median render times and their percentage difference for each document. Three operations are measured on each fresh editor: + +- **Initial render:** editor creation, mounting, and forced layout. +- **Selection change:** move the cursor from the first block to block 500, then force layout. +- **Content change:** insert one character at that cursor using a ProseMirror transaction, then force layout. This removes the selected block's placeholder and invalidates the DOM implementation's document cache. + +Timings include synchronous rendering and style/layout, but not paint or animation-frame waits. Placeholder counts and the resulting selection/content are validated outside the timed regions. A positive delta means the DOM implementation takes longer. The benchmark is excluded from normal test runs. Compare results from the same run and avoid other heavy workloads while measuring.