Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -919,12 +919,24 @@ NESTED BLOCKS
}

/* PLACEHOLDERS*/
.bn-block-content:has(.ProseMirror-trailingBreak:only-child):after {
/* 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 {
/* 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;
/* Removes text cursor offset. */
margin-inline: -2px;
pointer-events: none;
max-width: 100%;
user-select: none;
}

/* TEXT COLORS */
Expand Down
162 changes: 162 additions & 0 deletions packages/core/src/extensions/Placeholder/Placeholder.benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* 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<void>((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;
}

// 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.
// 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");
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: {
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++) {
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 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].initial.push(layout);
samples[implementation].selection.push(selectionLayout);
samples[implementation].content.push(contentLayout);
}
} finally {
editor._tiptapEditor.destroy();
host.remove();
}
}
}
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();
}
});
147 changes: 147 additions & 0 deletions packages/core/src/extensions/Placeholder/Placeholder.old.ts
Original file line number Diff line number Diff line change
@@ -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<BlockNoteEditorOptions<any, any, any>, "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 `<head>`) 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;
},
);
32 changes: 19 additions & 13 deletions packages/core/src/extensions/Placeholder/Placeholder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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();
}
});
Loading
Loading