Skip to content

Commit 1438c51

Browse files
authored
feat: position storage (#1529)
This adds a `trackPosition` API, which is able to track positions across transactions, whether they are collaborative or not in a single unified API.
1 parent f5a83c1 commit 1438c51

File tree

4 files changed

+500
-7
lines changed

4 files changed

+500
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
2+
import * as Y from "yjs";
3+
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
4+
import { trackPosition } from "./positionMapping.js";
5+
6+
describe("PositionStorage with local editor", () => {
7+
let editor: BlockNoteEditor;
8+
9+
beforeEach(() => {
10+
editor = BlockNoteEditor.create();
11+
editor.mount(document.createElement("div"));
12+
});
13+
14+
afterEach(() => {
15+
editor.mount(undefined);
16+
editor._tiptapEditor.destroy();
17+
});
18+
19+
describe("mount and unmount", () => {
20+
it("should register transaction handler on creation", () => {
21+
editor._tiptapEditor.on = vi.fn();
22+
trackPosition(editor, 0);
23+
24+
expect(editor._tiptapEditor.on).toHaveBeenCalledWith(
25+
"transaction",
26+
expect.any(Function)
27+
);
28+
});
29+
});
30+
31+
describe("set and get positions", () => {
32+
it("should store and retrieve positions without Y.js", () => {
33+
const getPos = trackPosition(editor, 10);
34+
35+
expect(getPos()).toBe(10);
36+
});
37+
38+
it("should handle right side positions", () => {
39+
const getPos = trackPosition(editor, 10, "right");
40+
41+
expect(getPos()).toBe(10);
42+
});
43+
});
44+
45+
it("should update mapping for local transactions before the position", () => {
46+
// Set initial content
47+
editor.insertBlocks(
48+
[
49+
{
50+
id: "1",
51+
type: "paragraph",
52+
content: [
53+
{
54+
type: "text",
55+
text: "Hello World",
56+
styles: {},
57+
},
58+
],
59+
},
60+
],
61+
editor.document[0],
62+
"before"
63+
);
64+
65+
// Start tracking
66+
const getPos = trackPosition(editor, 10);
67+
68+
// Move the cursor to the start of the document
69+
editor.setTextCursorPosition(editor.document[0], "start");
70+
71+
// Insert text at the start of the document
72+
editor.insertInlineContent([
73+
{
74+
type: "text",
75+
text: "Test",
76+
styles: {},
77+
},
78+
]);
79+
80+
// Position should be updated according to mapping
81+
expect(getPos()).toBe(14);
82+
});
83+
84+
it("should not update mapping for local transactions after the position", () => {
85+
// Set initial content
86+
editor.insertBlocks(
87+
[
88+
{
89+
id: "1",
90+
type: "paragraph",
91+
content: [
92+
{
93+
type: "text",
94+
text: "Hello World",
95+
styles: {},
96+
},
97+
],
98+
},
99+
],
100+
editor.document[0],
101+
"before"
102+
);
103+
// Start tracking
104+
const getPos = trackPosition(editor, 10);
105+
106+
// Move the cursor to the end of the document
107+
editor.setTextCursorPosition(editor.document[0], "end");
108+
109+
// Insert text at the end of the document
110+
editor.insertInlineContent([
111+
{
112+
type: "text",
113+
text: "Test",
114+
styles: {},
115+
},
116+
]);
117+
118+
// Position should not be updated
119+
expect(getPos()).toBe(10);
120+
});
121+
122+
it("should track positions on each side", () => {
123+
editor.replaceBlocks(editor.document, [
124+
{
125+
type: "paragraph",
126+
content: "Hello World",
127+
},
128+
]);
129+
130+
// Store position at "Hello| World"
131+
const getCursorPos = trackPosition(editor, 6);
132+
const getStartPos = trackPosition(editor, 3);
133+
const getStartRightPos = trackPosition(editor, 3, "right");
134+
const getPosAfterPos = trackPosition(editor, 4);
135+
const getPosAfterRightPos = trackPosition(editor, 4, "right");
136+
// Insert text at the beginning
137+
editor._tiptapEditor.commands.insertContentAt(3, "Test ");
138+
139+
// Position should be updated
140+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
141+
expect(getStartPos()).toBe(3); // 3
142+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
143+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
144+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
145+
});
146+
147+
it("should handle multiple transactions", () => {
148+
editor.replaceBlocks(editor.document, [
149+
{
150+
type: "paragraph",
151+
content: "Hello World",
152+
},
153+
]);
154+
155+
// Store position at "Hello| World"
156+
const getCursorPos = trackPosition(editor, 6);
157+
const getStartPos = trackPosition(editor, 3);
158+
const getStartRightPos = trackPosition(editor, 3, "right");
159+
const getPosAfterPos = trackPosition(editor, 4);
160+
const getPosAfterRightPos = trackPosition(editor, 4, "right");
161+
162+
// Insert text at the beginning
163+
editor._tiptapEditor.commands.insertContentAt(3, "T");
164+
editor._tiptapEditor.commands.insertContentAt(4, "e");
165+
editor._tiptapEditor.commands.insertContentAt(5, "s");
166+
editor._tiptapEditor.commands.insertContentAt(6, "t");
167+
editor._tiptapEditor.commands.insertContentAt(7, " ");
168+
169+
// Position should be updated
170+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
171+
expect(getStartPos()).toBe(3); // 3
172+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
173+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
174+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
175+
});
176+
});
177+
178+
describe("PositionStorage with remote editor", () => {
179+
// Function to sync two documents
180+
function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) {
181+
// Create update message from source
182+
const update = Y.encodeStateAsUpdate(sourceDoc);
183+
184+
// Apply update to target
185+
Y.applyUpdate(targetDoc, update);
186+
}
187+
188+
// Set up two-way sync
189+
function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) {
190+
// Sync initial states
191+
syncDocs(doc1, doc2);
192+
syncDocs(doc2, doc1);
193+
194+
// Set up observers for future changes
195+
doc1.on("update", (update: Uint8Array) => {
196+
Y.applyUpdate(doc2, update);
197+
});
198+
199+
doc2.on("update", (update: Uint8Array) => {
200+
Y.applyUpdate(doc1, update);
201+
});
202+
}
203+
204+
describe("remote editor", () => {
205+
let localEditor: BlockNoteEditor;
206+
let remoteEditor: BlockNoteEditor;
207+
let ydoc: Y.Doc;
208+
let remoteYdoc: Y.Doc;
209+
210+
beforeEach(() => {
211+
ydoc = new Y.Doc();
212+
remoteYdoc = new Y.Doc();
213+
// Create a mock editor
214+
localEditor = BlockNoteEditor.create({
215+
collaboration: {
216+
fragment: ydoc.getXmlFragment("doc"),
217+
user: { color: "#ff0000", name: "Local User" },
218+
provider: undefined,
219+
},
220+
});
221+
const div = document.createElement("div");
222+
localEditor.mount(div);
223+
224+
remoteEditor = BlockNoteEditor.create({
225+
collaboration: {
226+
fragment: remoteYdoc.getXmlFragment("doc"),
227+
user: { color: "#ff0000", name: "Remote User" },
228+
provider: undefined,
229+
},
230+
});
231+
232+
const remoteDiv = document.createElement("div");
233+
remoteEditor.mount(remoteDiv);
234+
setupTwoWaySync(ydoc, remoteYdoc);
235+
});
236+
237+
afterEach(() => {
238+
ydoc.destroy();
239+
remoteYdoc.destroy();
240+
localEditor.mount(undefined);
241+
localEditor._tiptapEditor.destroy();
242+
remoteEditor.mount(undefined);
243+
remoteEditor._tiptapEditor.destroy();
244+
});
245+
246+
it("should update the local position when collaborating", () => {
247+
localEditor.replaceBlocks(localEditor.document, [
248+
{
249+
type: "paragraph",
250+
content: "Hello World",
251+
},
252+
]);
253+
254+
// Store position at "Hello| World"
255+
const getCursorPos = trackPosition(localEditor, 6);
256+
// Store position at "|Hello World"
257+
const getStartPos = trackPosition(localEditor, 3);
258+
// Store position at "|Hello World" (but on the right side)
259+
const getStartRightPos = trackPosition(localEditor, 3, "right");
260+
// Store position at "H|ello World"
261+
const getPosAfterPos = trackPosition(localEditor, 4);
262+
// Store position at "H|ello World" (but on the right side)
263+
const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
264+
265+
// Insert text at the beginning
266+
localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
267+
268+
// Position should be updated
269+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
270+
expect(getStartPos()).toBe(3); // 3
271+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
272+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
273+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
274+
});
275+
276+
it("should handle multiple transactions when collaborating", () => {
277+
localEditor.replaceBlocks(localEditor.document, [
278+
{
279+
type: "paragraph",
280+
content: "Hello World",
281+
},
282+
]);
283+
284+
// Store position at "Hello| World"
285+
const getCursorPos = trackPosition(localEditor, 6);
286+
// Store position at "|Hello World"
287+
const getStartPos = trackPosition(localEditor, 3);
288+
// Store position at "|Hello World" (but on the right side)
289+
const getStartRightPos = trackPosition(localEditor, 3, "right");
290+
// Store position at "H|ello World"
291+
const getPosAfterPos = trackPosition(localEditor, 4);
292+
// Store position at "H|ello World" (but on the right side)
293+
const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
294+
295+
// Insert text at the beginning
296+
localEditor._tiptapEditor.commands.insertContentAt(3, "T");
297+
localEditor._tiptapEditor.commands.insertContentAt(4, "e");
298+
localEditor._tiptapEditor.commands.insertContentAt(5, "s");
299+
localEditor._tiptapEditor.commands.insertContentAt(6, "t");
300+
localEditor._tiptapEditor.commands.insertContentAt(7, " ");
301+
302+
// Position should be updated
303+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
304+
expect(getStartPos()).toBe(3); // 3
305+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
306+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
307+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
308+
});
309+
310+
it("should update the local position from a remote transaction", () => {
311+
remoteEditor.replaceBlocks(remoteEditor.document, [
312+
{
313+
type: "paragraph",
314+
content: "Hello World",
315+
},
316+
]);
317+
318+
// Store position at "Hello| World"
319+
const getCursorPos = trackPosition(localEditor, 6);
320+
// Store position at "|Hello World"
321+
const getStartPos = trackPosition(localEditor, 3);
322+
// Store position at "|Hello World" (but on the right side)
323+
const getStartRightPos = trackPosition(localEditor, 3, "right");
324+
// Store position at "H|ello World"
325+
const getPosAfterPos = trackPosition(localEditor, 4);
326+
// Store position at "H|ello World" (but on the right side)
327+
const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
328+
329+
// Insert text at the beginning
330+
localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
331+
332+
// Position should be updated
333+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
334+
expect(getStartPos()).toBe(3); // 3
335+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
336+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
337+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
338+
});
339+
340+
it("should update the remote position from a remote transaction", () => {
341+
remoteEditor.replaceBlocks(remoteEditor.document, [
342+
{
343+
type: "paragraph",
344+
content: "Hello World",
345+
},
346+
]);
347+
348+
// Store position at "Hello| World"
349+
const getCursorPos = trackPosition(remoteEditor, 6);
350+
// Store position at "|Hello World"
351+
const getStartPos = trackPosition(remoteEditor, 3);
352+
// Store position at "|Hello World" (but on the right side)
353+
const getStartRightPos = trackPosition(remoteEditor, 3, "right");
354+
// Store position at "H|ello World"
355+
const getPosAfterPos = trackPosition(remoteEditor, 4);
356+
// Store position at "H|ello World" (but on the right side)
357+
const getPosAfterRightPos = trackPosition(remoteEditor, 4, "right");
358+
359+
// Insert text at the beginning
360+
localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
361+
362+
// Position should be updated
363+
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)
364+
expect(getStartPos()).toBe(3); // 3
365+
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
366+
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
367+
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
368+
});
369+
});
370+
});

0 commit comments

Comments
 (0)