Skip to content

Commit 3c38d84

Browse files
authored
perf: optimize plugin traversals for large documents BLO-1111 (#2600)
1 parent dd4b9a3 commit 3c38d84

9 files changed

Lines changed: 1144 additions & 510 deletions

File tree

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,14 @@
9696
"@tiptap/core": "^3.13.0",
9797
"@tiptap/extension-bold": "^3.13.0",
9898
"@tiptap/extension-code": "^3.13.0",
99-
"@tiptap/extensions": "^3.13.0",
10099
"@tiptap/extension-horizontal-rule": "^3.13.0",
101100
"@tiptap/extension-italic": "^3.13.0",
102101
"@tiptap/extension-link": "^3.13.0",
103102
"@tiptap/extension-paragraph": "^3.13.0",
104103
"@tiptap/extension-strike": "^3.13.0",
105104
"@tiptap/extension-text": "^3.13.0",
106105
"@tiptap/extension-underline": "^3.13.0",
106+
"@tiptap/extensions": "^3.13.0",
107107
"@tiptap/pm": "^3.13.0",
108108
"emoji-mart": "^5.6.0",
109109
"fast-deep-equal": "^3.1.3",
@@ -112,7 +112,7 @@
112112
"prosemirror-model": "^1.25.4",
113113
"prosemirror-state": "^1.4.4",
114114
"prosemirror-tables": "^1.8.3",
115-
"prosemirror-transform": "^1.10.5",
115+
"prosemirror-transform": "^1.11.0",
116116
"prosemirror-view": "^1.41.4",
117117
"rehype-format": "^5.0.1",
118118
"rehype-parse": "^9.0.1",
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { Selection } from "prosemirror-state";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
5+
6+
/**
7+
* @vitest-environment jsdom
8+
*/
9+
10+
const PLUGIN_KEY = "numbered-list-indexing-decorations$";
11+
12+
function createEditor() {
13+
const editor = BlockNoteEditor.create();
14+
editor.mount(document.createElement("div"));
15+
return editor;
16+
}
17+
18+
function getDecorationSet(editor: BlockNoteEditor<any, any, any>) {
19+
const view = editor._tiptapEditor.view;
20+
const plugin = view.state.plugins.find(
21+
(p) => (p as any).key === PLUGIN_KEY,
22+
);
23+
if (!plugin) {
24+
throw new Error("IndexingPlugin not found");
25+
}
26+
return plugin.getState(view.state)!.decorations;
27+
}
28+
29+
/** Returns all decoration specs in document order. */
30+
function getDecoSpecs(editor: BlockNoteEditor<any, any, any>) {
31+
const decoSet = getDecorationSet(editor);
32+
const doc = editor._tiptapEditor.view.state.doc;
33+
const decos = decoSet.find(0, doc.nodeSize - 2);
34+
return decos.map((d: any) => d.spec);
35+
}
36+
37+
/** Returns the data-index values from decoration attrs in document order. */
38+
function getDataIndices(editor: BlockNoteEditor<any, any, any>) {
39+
const decoSet = getDecorationSet(editor);
40+
const doc = editor._tiptapEditor.view.state.doc;
41+
const decos = decoSet.find(0, doc.nodeSize - 2);
42+
return decos.map((d: any) => {
43+
// Decoration attrs are stored on the decoration object
44+
const attrs =
45+
(d as any).type?.attrs ?? (d as any).attrs ?? (d as any).type;
46+
return parseInt(attrs["data-index"], 10);
47+
});
48+
}
49+
50+
function setBlocks(
51+
editor: BlockNoteEditor<any, any, any>,
52+
blocks: Array<{ type: string; content?: string; props?: any }>,
53+
) {
54+
editor.replaceBlocks(
55+
editor.document,
56+
blocks.map((b) => ({
57+
type: b.type as any,
58+
content: b.content ?? "text",
59+
...(b.props ? { props: b.props } : {}),
60+
})) as any,
61+
);
62+
}
63+
64+
describe("IndexingPlugin: basic numbering", () => {
65+
it("assigns sequential indices to a contiguous numbered list", () => {
66+
const editor = createEditor();
67+
setBlocks(editor, [
68+
{ type: "numberedListItem", content: "a" },
69+
{ type: "numberedListItem", content: "b" },
70+
{ type: "numberedListItem", content: "c" },
71+
]);
72+
73+
const indices = getDataIndices(editor);
74+
expect(indices).toEqual([1, 2, 3]);
75+
});
76+
77+
it("resets index after a non-list block", () => {
78+
const editor = createEditor();
79+
setBlocks(editor, [
80+
{ type: "numberedListItem", content: "a" },
81+
{ type: "numberedListItem", content: "b" },
82+
{ type: "paragraph", content: "break" },
83+
{ type: "numberedListItem", content: "c" },
84+
{ type: "numberedListItem", content: "d" },
85+
]);
86+
87+
const indices = getDataIndices(editor);
88+
expect(indices).toEqual([1, 2, 1, 2]);
89+
});
90+
91+
it("single numbered list item gets index 1", () => {
92+
const editor = createEditor();
93+
setBlocks(editor, [{ type: "numberedListItem", content: "only" }]);
94+
95+
const indices = getDataIndices(editor);
96+
expect(indices).toEqual([1]);
97+
});
98+
99+
it("no decorations for non-list blocks", () => {
100+
const editor = createEditor();
101+
setBlocks(editor, [
102+
{ type: "paragraph", content: "a" },
103+
{ type: "heading", content: "b", props: { level: 1 } },
104+
]);
105+
106+
const indices = getDataIndices(editor);
107+
expect(indices).toEqual([]);
108+
});
109+
});
110+
111+
describe("IndexingPlugin: updates on structural changes", () => {
112+
it("updates indices when a block is deleted from the middle", () => {
113+
const editor = createEditor();
114+
setBlocks(editor, [
115+
{ type: "numberedListItem", content: "a" },
116+
{ type: "numberedListItem", content: "b" },
117+
{ type: "numberedListItem", content: "c" },
118+
]);
119+
120+
// Delete the second block
121+
const secondBlock = editor.document[1];
122+
editor.removeBlocks([secondBlock]);
123+
124+
const indices = getDataIndices(editor);
125+
expect(indices).toEqual([1, 2]);
126+
});
127+
128+
it("updates indices when a block is inserted in the middle", () => {
129+
const editor = createEditor();
130+
setBlocks(editor, [
131+
{ type: "numberedListItem", content: "a" },
132+
{ type: "numberedListItem", content: "c" },
133+
]);
134+
135+
// Insert a block after the first
136+
const firstBlock = editor.document[0];
137+
editor.insertBlocks(
138+
[{ type: "numberedListItem" as any, content: "b" } as any],
139+
firstBlock,
140+
"after",
141+
);
142+
143+
const indices = getDataIndices(editor);
144+
expect(indices).toEqual([1, 2, 3]);
145+
});
146+
147+
it("updates indices when first block is deleted", () => {
148+
const editor = createEditor();
149+
setBlocks(editor, [
150+
{ type: "numberedListItem", content: "a" },
151+
{ type: "numberedListItem", content: "b" },
152+
{ type: "numberedListItem", content: "c" },
153+
]);
154+
155+
editor.removeBlocks([editor.document[0]]);
156+
157+
const indices = getDataIndices(editor);
158+
expect(indices).toEqual([1, 2]);
159+
});
160+
161+
it("updates indices with nested list when first block is deleted", () => {
162+
const editor = createEditor();
163+
editor.replaceBlocks(editor.document, [
164+
{
165+
type: "numberedListItem" as any,
166+
content: "first item",
167+
},
168+
{
169+
type: "numberedListItem" as any,
170+
content: "second item",
171+
children: [
172+
{ type: "numberedListItem" as any, content: "nested item" },
173+
{ type: "numberedListItem" as any, content: "second nested item" },
174+
],
175+
},
176+
{
177+
type: "numberedListItem" as any,
178+
content: "third item",
179+
},
180+
] as any);
181+
182+
// Before deletion: top-level [1, 2, 3], nested [1, 2]
183+
const indicesBefore = getDataIndices(editor);
184+
expect(indicesBefore).toEqual([1, 2, 1, 2, 3]);
185+
186+
// Delete first item
187+
editor.removeBlocks([editor.document[0]]);
188+
189+
// After deletion: top-level [1, 2], nested [1, 2]
190+
const indicesAfter = getDataIndices(editor);
191+
expect(indicesAfter).toEqual([1, 1, 2, 2]);
192+
});
193+
194+
it("updates indices when block type changes from numbered list to paragraph", () => {
195+
const editor = createEditor();
196+
setBlocks(editor, [
197+
{ type: "numberedListItem", content: "a" },
198+
{ type: "numberedListItem", content: "b" },
199+
{ type: "numberedListItem", content: "c" },
200+
]);
201+
202+
// Change second block to paragraph — splits the list
203+
editor.updateBlock(editor.document[1], { type: "paragraph" });
204+
205+
const indices = getDataIndices(editor);
206+
// First list: [1], then paragraph (no decoration), then new list: [1]
207+
expect(indices).toEqual([1, 1]);
208+
});
209+
210+
it("updates indices when block type changes from paragraph to numbered list", () => {
211+
const editor = createEditor();
212+
setBlocks(editor, [
213+
{ type: "numberedListItem", content: "a" },
214+
{ type: "paragraph", content: "b" },
215+
{ type: "numberedListItem", content: "c" },
216+
]);
217+
218+
// Change paragraph to numbered list — merges the lists
219+
editor.updateBlock(editor.document[1], { type: "numberedListItem" });
220+
221+
const indices = getDataIndices(editor);
222+
expect(indices).toEqual([1, 2, 3]);
223+
});
224+
});
225+
226+
describe("IndexingPlugin: typing preserves indices (early exit)", () => {
227+
it("indices unchanged after typing in the first block", () => {
228+
const editor = createEditor();
229+
setBlocks(editor, [
230+
{ type: "numberedListItem", content: "a" },
231+
{ type: "numberedListItem", content: "b" },
232+
{ type: "numberedListItem", content: "c" },
233+
]);
234+
235+
const indicesBefore = getDataIndices(editor);
236+
237+
// Type a character in the first block
238+
const view = editor._tiptapEditor.view;
239+
view.dispatch(view.state.tr.insertText("x", 4));
240+
241+
const indicesAfter = getDataIndices(editor);
242+
expect(indicesAfter).toEqual(indicesBefore);
243+
});
244+
245+
it("indices unchanged after typing in the last block", () => {
246+
const editor = createEditor();
247+
setBlocks(editor, [
248+
{ type: "numberedListItem", content: "a" },
249+
{ type: "numberedListItem", content: "b" },
250+
{ type: "numberedListItem", content: "c" },
251+
]);
252+
253+
const indicesBefore = getDataIndices(editor);
254+
255+
const view = editor._tiptapEditor.view;
256+
const pos = view.state.doc.content.size - 4;
257+
view.dispatch(view.state.tr.insertText("x", pos));
258+
259+
const indicesAfter = getDataIndices(editor);
260+
expect(indicesAfter).toEqual(indicesBefore);
261+
});
262+
263+
it("indices unchanged after typing in a middle block", () => {
264+
const editor = createEditor();
265+
setBlocks(editor, [
266+
{ type: "numberedListItem", content: "a" },
267+
{ type: "numberedListItem", content: "b" },
268+
{ type: "numberedListItem", content: "c" },
269+
]);
270+
271+
const indicesBefore = getDataIndices(editor);
272+
273+
// Find position inside second block's content
274+
const view = editor._tiptapEditor.view;
275+
let targetPos = 0;
276+
view.state.doc.descendants((node, pos) => {
277+
if (
278+
node.type.name === "numberedListItem" &&
279+
targetPos === 0 &&
280+
pos > 4
281+
) {
282+
targetPos = pos + 1; // inside the inline content
283+
}
284+
});
285+
view.dispatch(view.state.tr.insertText("x", targetPos));
286+
287+
const indicesAfter = getDataIndices(editor);
288+
expect(indicesAfter).toEqual(indicesBefore);
289+
});
290+
});
291+
292+
describe("IndexingPlugin: decoration specs", () => {
293+
it("decorations have correct spec with index, isFirst, hasStart", () => {
294+
const editor = createEditor();
295+
setBlocks(editor, [
296+
{ type: "numberedListItem", content: "a" },
297+
{ type: "numberedListItem", content: "b" },
298+
]);
299+
300+
const specs = getDecoSpecs(editor);
301+
expect(specs).toEqual([
302+
{ index: 1, isFirst: true, hasStart: false },
303+
{ index: 2, isFirst: false, hasStart: false },
304+
]);
305+
});
306+
307+
it("first item after a paragraph is marked as isFirst", () => {
308+
const editor = createEditor();
309+
setBlocks(editor, [
310+
{ type: "numberedListItem", content: "a" },
311+
{ type: "paragraph", content: "break" },
312+
{ type: "numberedListItem", content: "b" },
313+
{ type: "numberedListItem", content: "c" },
314+
]);
315+
316+
const specs = getDecoSpecs(editor);
317+
expect(specs).toEqual([
318+
{ index: 1, isFirst: true, hasStart: false },
319+
{ index: 1, isFirst: true, hasStart: false },
320+
{ index: 2, isFirst: false, hasStart: false },
321+
]);
322+
});
323+
});
324+
325+
describe("IndexingPlugin: selection-only transactions", () => {
326+
it("does not recompute decorations on selection change", () => {
327+
const editor = createEditor();
328+
setBlocks(editor, [
329+
{ type: "numberedListItem", content: "a" },
330+
{ type: "numberedListItem", content: "b" },
331+
]);
332+
333+
const decosBefore = getDecorationSet(editor);
334+
335+
// Move selection without changing content
336+
const view = editor._tiptapEditor.view;
337+
const tr = view.state.tr.setSelection(
338+
Selection.near(view.state.doc.resolve(4)),
339+
);
340+
view.dispatch(tr);
341+
342+
const decosAfter = getDecorationSet(editor);
343+
// Same DecorationSet reference — not recomputed
344+
expect(decosAfter).toBe(decosBefore);
345+
});
346+
});

0 commit comments

Comments
 (0)