Skip to content

Commit b1afce2

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

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,58 @@ 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+
localEditor.unmount();
81+
remoteEditor.unmount();
82+
});
3183
it("should update the local position when collaborating", () => {
3284
const ydoc = new Y.Doc();
3385
const remoteYdoc = new Y.Doc();
@@ -92,6 +144,80 @@ describe("RelativePositionMapping (yjs)", () => {
92144
remoteEditor.unmount();
93145
});
94146

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

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

Lines changed: 18 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,23 @@ 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+
return editor
33+
.getExtension<typeof PositionMappingExtension>("positionMapping")
34+
?.mapPosition(position, side);
35+
}
36+
1937
const relativePosition = absolutePositionToRelativePosition(
2038
position + (side === "right" ? 1 : -1),
2139
ySyncPluginState.binding.type,

0 commit comments

Comments
 (0)