Skip to content

Commit beea4f3

Browse files
committed
fix: performance improvements lists and previousblocks
1 parent a850078 commit beea4f3

9 files changed

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