Skip to content

Commit a437d1c

Browse files
committed
fix: proper position tracking for uninitialized doc #2759
1 parent 2486340 commit a437d1c

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

packages/core/src/yjs/extensions/RelativePositionMapping.test.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,60 @@ function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) {
2828
}
2929

3030
describe("RelativePositionMapping (yjs)", () => {
31+
it("should return the same position when no changes are made", () => {
32+
const ydoc = new Y.Doc();
33+
const remoteYdoc = new Y.Doc();
34+
35+
const localEditor = BlockNoteEditor.create(
36+
withCollaboration({
37+
collaboration: {
38+
fragment: ydoc.getXmlFragment("doc"),
39+
user: { color: "#ff0000", name: "Local User" },
40+
provider: undefined,
41+
},
42+
}),
43+
);
44+
const div = document.createElement("div");
45+
localEditor.mount(div);
46+
47+
const remoteEditor = BlockNoteEditor.create(
48+
withCollaboration({
49+
collaboration: {
50+
fragment: remoteYdoc.getXmlFragment("doc"),
51+
user: { color: "#ff0000", name: "Remote User" },
52+
provider: undefined,
53+
},
54+
}),
55+
);
56+
57+
const remoteDiv = document.createElement("div");
58+
remoteEditor.mount(remoteDiv);
59+
setupTwoWaySync(ydoc, remoteYdoc);
60+
61+
const nodeSize = localEditor.prosemirrorState.doc.nodeSize;
62+
const positions: number[] = [];
63+
for (let i = 0; i < nodeSize; i++) {
64+
positions.push(trackPosition(localEditor, i)());
65+
}
66+
67+
expect(positions).toMatchInlineSnapshot(`
68+
[
69+
0,
70+
1,
71+
2,
72+
3,
73+
4,
74+
5,
75+
6,
76+
7,
77+
]
78+
`);
79+
80+
ydoc.destroy();
81+
remoteYdoc.destroy();
82+
localEditor.unmount();
83+
remoteEditor.unmount();
84+
});
3185
it("should update the local position when collaborating", () => {
3286
const ydoc = new Y.Doc();
3387
const remoteYdoc = new Y.Doc();
@@ -92,6 +146,80 @@ describe("RelativePositionMapping (yjs)", () => {
92146
remoteEditor.unmount();
93147
});
94148

149+
it("should match the same positions", () => {
150+
const ydoc = new Y.Doc();
151+
const remoteYdoc = new Y.Doc();
152+
153+
const localEditor = BlockNoteEditor.create(
154+
withCollaboration({
155+
collaboration: {
156+
fragment: ydoc.getXmlFragment("doc"),
157+
user: { color: "#ff0000", name: "Local User" },
158+
provider: undefined,
159+
},
160+
}),
161+
);
162+
const div = document.createElement("div");
163+
localEditor.mount(div);
164+
165+
const remoteEditor = BlockNoteEditor.create(
166+
withCollaboration({
167+
collaboration: {
168+
fragment: remoteYdoc.getXmlFragment("doc"),
169+
user: { color: "#ff0000", name: "Remote User" },
170+
provider: undefined,
171+
},
172+
}),
173+
);
174+
175+
const remoteDiv = document.createElement("div");
176+
remoteEditor.mount(remoteDiv);
177+
setupTwoWaySync(ydoc, remoteYdoc);
178+
179+
localEditor.replaceBlocks(localEditor.document, [
180+
{
181+
type: "paragraph",
182+
content: "Hello World",
183+
},
184+
]);
185+
186+
const nodeSize = localEditor.prosemirrorState.doc.nodeSize;
187+
const positions: (() => number)[] = [];
188+
for (let i = 0; i < nodeSize; i++) {
189+
positions.push(trackPosition(localEditor, i));
190+
}
191+
192+
localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
193+
194+
expect(positions.map((getPos) => getPos())).toMatchInlineSnapshot(`
195+
[
196+
0,
197+
1,
198+
2,
199+
3,
200+
9,
201+
10,
202+
11,
203+
12,
204+
13,
205+
14,
206+
15,
207+
16,
208+
17,
209+
18,
210+
19,
211+
20,
212+
21,
213+
22,
214+
23,
215+
]
216+
`);
217+
ydoc.destroy();
218+
remoteYdoc.destroy();
219+
localEditor.unmount();
220+
remoteEditor.unmount();
221+
});
222+
95223
it("should handle multiple transactions when collaborating", () => {
96224
const ydoc = new Y.Doc();
97225
const remoteYdoc = new Y.Doc();
@@ -208,8 +336,8 @@ describe("RelativePositionMapping (yjs)", () => {
208336
// Store position at "H|ello World" (but on the right side)
209337
const getPosAfterRightPos = trackPosition(localEditor, 4, "right");
210338

211-
// Insert text at the beginning
212-
localEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
339+
// Insert text at the beginning (via remote editor to exercise remote-origin updates)
340+
remoteEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
213341

214342
// Position should be updated
215343
expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length)

packages/core/src/yjs/extensions/RelativePositionMapping.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ySyncPluginKey,
55
} from "y-prosemirror";
66
import { createExtension } from "../../editor/BlockNoteExtension.js";
7+
import type { PositionMappingExtension } from "../../extensions/index.js";
78

89
export const RelativePositionMappingExtension = createExtension(
910
({ editor }) => {
@@ -16,6 +17,29 @@ export const RelativePositionMappingExtension = createExtension(
1617
if (!ySyncPluginState) {
1718
throw new Error("YSync plugin state not found");
1819
}
20+
21+
// 0 is a special case & always should map to itself
22+
if (position === 0) {
23+
return () => 0;
24+
}
25+
26+
// If the document is empty, it has not been synced yet
27+
if (ySyncPluginState.binding.type.length === 0) {
28+
// so, we just fallback to the prosemirror position mapping extension
29+
// If a remote transaction or sync happens in this case. The position map will be invalidated,
30+
// and the positions will be moved to the end of the document
31+
// This is acceptable, because the document had not been synced so there are no positions to map properly into
32+
const fallback = editor.getExtension<typeof PositionMappingExtension>(
33+
"positionMapping",
34+
);
35+
if (!fallback) {
36+
throw new Error(
37+
"positionMapping extension is not available; cannot map position before sync",
38+
);
39+
}
40+
return fallback.mapPosition(position, side);
41+
}
42+
1943
const relativePosition = absolutePositionToRelativePosition(
2044
position + (side === "right" ? 1 : -1),
2145
ySyncPluginState.binding.type,

0 commit comments

Comments
 (0)