@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
353 lines (295 loc) • 11.3 kB
text/typescript
import { Selection } from "prosemirror-state";
import { afterEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
/**
* @vitest-environment jsdom
*/
const PLUGIN_KEY = "numbered-list-indexing-decorations$";
// Track editors created in each test so we can unmount them in afterEach —
// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that
// fires after vitest tears down jsdom, throwing
// `ReferenceError: document is not defined` and failing the run.
const activeEditors: BlockNoteEditor<any, any, any>[] = [];
afterEach(() => {
while (activeEditors.length) {
activeEditors.pop()!.unmount();
}
});
function createEditor() {
const editor = BlockNoteEditor.create();
editor.mount(document.createElement("div"));
activeEditors.push(editor);
return editor;
}
function getDecorationSet(editor: BlockNoteEditor<any, any, any>) {
const view = editor._tiptapEditor.view;
const plugin = view.state.plugins.find((p) => (p as any).key === PLUGIN_KEY);
if (!plugin) {
throw new Error("IndexingPlugin not found");
}
return plugin.getState(view.state)!.decorations;
}
/** Returns all decoration specs in document order. */
function getDecoSpecs(editor: BlockNoteEditor<any, any, any>) {
const decoSet = getDecorationSet(editor);
const doc = editor._tiptapEditor.view.state.doc;
const decos = decoSet.find(0, doc.nodeSize - 2);
return decos.map((d: any) => d.spec);
}
/** Returns the data-index values from decoration attrs in document order. */
function getDataIndices(editor: BlockNoteEditor<any, any, any>) {
const decoSet = getDecorationSet(editor);
const doc = editor._tiptapEditor.view.state.doc;
const decos = decoSet.find(0, doc.nodeSize - 2);
return decos.map((d: any) => {
// Decoration attrs are stored on the decoration object
const attrs = (d as any).type?.attrs ?? (d as any).attrs ?? (d as any).type;
return parseInt(attrs["data-index"], 10);
});
}
function setBlocks(
editor: BlockNoteEditor<any, any, any>,
blocks: Array<{ type: string; content?: string; props?: any }>,
) {
editor.replaceBlocks(
editor.document,
blocks.map((b) => ({
type: b.type as any,
content: b.content ?? "text",
...(b.props ? { props: b.props } : {}),
})) as any,
);
}
describe("IndexingPlugin: basic numbering", () => {
it("assigns sequential indices to a contiguous numbered list", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2, 3]);
});
it("resets index after a non-list block", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "paragraph", content: "break" },
{ type: "numberedListItem", content: "c" },
{ type: "numberedListItem", content: "d" },
]);
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2, 1, 2]);
});
it("single numbered list item gets index 1", () => {
const editor = createEditor();
setBlocks(editor, [{ type: "numberedListItem", content: "only" }]);
const indices = getDataIndices(editor);
expect(indices).toEqual([1]);
});
it("no decorations for non-list blocks", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "paragraph", content: "a" },
{ type: "heading", content: "b", props: { level: 1 } },
]);
const indices = getDataIndices(editor);
expect(indices).toEqual([]);
});
});
describe("IndexingPlugin: updates on structural changes", () => {
it("updates indices when a block is deleted from the middle", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
// Delete the second block
const secondBlock = editor.document[1];
editor.removeBlocks([secondBlock]);
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2]);
});
it("updates indices when a block is inserted in the middle", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "c" },
]);
// Insert a block after the first
const firstBlock = editor.document[0];
editor.insertBlocks(
[{ type: "numberedListItem" as any, content: "b" } as any],
firstBlock,
"after",
);
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2, 3]);
});
it("updates indices when first block is deleted", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
editor.removeBlocks([editor.document[0]]);
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2]);
});
it("updates indices with nested list when first block is deleted", () => {
const editor = createEditor();
editor.replaceBlocks(editor.document, [
{
type: "numberedListItem" as any,
content: "first item",
},
{
type: "numberedListItem" as any,
content: "second item",
children: [
{ type: "numberedListItem" as any, content: "nested item" },
{ type: "numberedListItem" as any, content: "second nested item" },
],
},
{
type: "numberedListItem" as any,
content: "third item",
},
] as any);
// Before deletion: top-level [1, 2, 3], nested [1, 2]
const indicesBefore = getDataIndices(editor);
expect(indicesBefore).toEqual([1, 2, 1, 2, 3]);
// Delete first item
editor.removeBlocks([editor.document[0]]);
// After deletion: top-level [1, 2], nested [1, 2]
const indicesAfter = getDataIndices(editor);
expect(indicesAfter).toEqual([1, 1, 2, 2]);
});
it("updates indices when block type changes from numbered list to paragraph", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
// Change second block to paragraph — splits the list
editor.updateBlock(editor.document[1], { type: "paragraph" });
const indices = getDataIndices(editor);
// First list: [1], then paragraph (no decoration), then new list: [1]
expect(indices).toEqual([1, 1]);
});
it("updates indices when block type changes from paragraph to numbered list", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "paragraph", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
// Change paragraph to numbered list — merges the lists
editor.updateBlock(editor.document[1], { type: "numberedListItem" });
const indices = getDataIndices(editor);
expect(indices).toEqual([1, 2, 3]);
});
});
describe("IndexingPlugin: typing preserves indices (early exit)", () => {
it("indices unchanged after typing in the first block", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
const indicesBefore = getDataIndices(editor);
// Type a character in the first block
const view = editor._tiptapEditor.view;
view.dispatch(view.state.tr.insertText("x", 4));
const indicesAfter = getDataIndices(editor);
expect(indicesAfter).toEqual(indicesBefore);
});
it("indices unchanged after typing in the last block", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
const indicesBefore = getDataIndices(editor);
const view = editor._tiptapEditor.view;
const pos = view.state.doc.content.size - 4;
view.dispatch(view.state.tr.insertText("x", pos));
const indicesAfter = getDataIndices(editor);
expect(indicesAfter).toEqual(indicesBefore);
});
it("indices unchanged after typing in a middle block", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
const indicesBefore = getDataIndices(editor);
// Find position inside second block's content
const view = editor._tiptapEditor.view;
let targetPos = 0;
view.state.doc.descendants((node, pos) => {
if (node.type.name === "numberedListItem" && targetPos === 0 && pos > 4) {
targetPos = pos + 1; // inside the inline content
}
});
view.dispatch(view.state.tr.insertText("x", targetPos));
const indicesAfter = getDataIndices(editor);
expect(indicesAfter).toEqual(indicesBefore);
});
});
describe("IndexingPlugin: decoration specs", () => {
it("decorations have correct spec with index, isFirst, hasStart", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
]);
const specs = getDecoSpecs(editor);
expect(specs).toEqual([
{ index: 1, isFirst: true, hasStart: false },
{ index: 2, isFirst: false, hasStart: false },
]);
});
it("first item after a paragraph is marked as isFirst", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "paragraph", content: "break" },
{ type: "numberedListItem", content: "b" },
{ type: "numberedListItem", content: "c" },
]);
const specs = getDecoSpecs(editor);
expect(specs).toEqual([
{ index: 1, isFirst: true, hasStart: false },
{ index: 1, isFirst: true, hasStart: false },
{ index: 2, isFirst: false, hasStart: false },
]);
});
});
describe("IndexingPlugin: selection-only transactions", () => {
it("does not recompute decorations on selection change", () => {
const editor = createEditor();
setBlocks(editor, [
{ type: "numberedListItem", content: "a" },
{ type: "numberedListItem", content: "b" },
]);
const decosBefore = getDecorationSet(editor);
// Move selection without changing content
const view = editor._tiptapEditor.view;
const tr = view.state.tr.setSelection(
Selection.near(view.state.doc.resolve(4)),
);
view.dispatch(tr);
const decosAfter = getDecorationSet(editor);
// Same DecorationSet reference — not recomputed
expect(decosAfter).toBe(decosBefore);
});
});