@pylonsync/loro 0.22.39 → 0.22.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -6
- package/src/outbox.ts +18 -7
- package/src/prosemirror/cursors.test.ts +112 -0
- package/src/prosemirror/cursors.ts +172 -0
- package/src/prosemirror/index.ts +37 -2
- package/src/prosemirror/react.ts +1 -1
- package/src/prosemirror/sync.test.ts +323 -0
- package/src/prosemirror/sync.ts +490 -0
- package/src/server.test.ts +138 -0
- package/src/server.ts +99 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// ProseMirrorSyncPlugin with two real editors (happy-dom) on two LoroDocs.
|
|
2
|
+
// `sync()` stands in for the network: it moves each doc's new ops to the
|
|
3
|
+
// other, the way the outbox and the server broadcast do.
|
|
4
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
5
|
+
if (!globalThis.document) GlobalRegistrator.register();
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { LoroDoc, LoroList, LoroMap, type ContainerID } from "loro-crdt";
|
|
9
|
+
import { undo } from "loro-prosemirror";
|
|
10
|
+
import { Schema } from "prosemirror-model";
|
|
11
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
12
|
+
import { EditorState, TextSelection } from "prosemirror-state";
|
|
13
|
+
import { EditorView } from "prosemirror-view";
|
|
14
|
+
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
15
|
+
import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
|
|
16
|
+
|
|
17
|
+
const defaultSchema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
18
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
19
|
+
|
|
20
|
+
const views: EditorView[] = [];
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const v of views.splice(0)) v.destroy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A server doc with the field's container, and two editors synced to it:
|
|
27
|
+
* on two client docs, or with `shared` on one (two editors in one tab).
|
|
28
|
+
*/
|
|
29
|
+
function setup({ schema = defaultSchema, shared = false }: { schema?: Schema; shared?: boolean } = {}) {
|
|
30
|
+
// The empty document the server makes on insert (crates/crdt prosemirror.rs).
|
|
31
|
+
const server = new LoroDoc();
|
|
32
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
33
|
+
body.setContainer("attributes", new LoroMap());
|
|
34
|
+
body.setContainer("children", new LoroList());
|
|
35
|
+
server.commit();
|
|
36
|
+
const docs = [new LoroDoc(), new LoroDoc()];
|
|
37
|
+
for (const d of docs) {
|
|
38
|
+
d.import(server.export({ mode: "snapshot" }));
|
|
39
|
+
configureProseMirrorMarks(d, schema);
|
|
40
|
+
}
|
|
41
|
+
const containerId = findProseMirrorContainer(docs[0], "body") as ContainerID;
|
|
42
|
+
const make = (doc: LoroDoc) => {
|
|
43
|
+
const view = new EditorView(document.createElement("div"), {
|
|
44
|
+
state: EditorState.create({
|
|
45
|
+
schema,
|
|
46
|
+
plugins: [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
views.push(view);
|
|
50
|
+
return view;
|
|
51
|
+
};
|
|
52
|
+
const [a, b] = [make(docs[0]), make(shared ? docs[0] : docs[1])];
|
|
53
|
+
const sync = () => {
|
|
54
|
+
for (const from of docs) {
|
|
55
|
+
const bytes = from.export({ mode: "update", from: server.oplogVersion() });
|
|
56
|
+
server.import(bytes);
|
|
57
|
+
}
|
|
58
|
+
const all = server.export({ mode: "snapshot" });
|
|
59
|
+
for (const to of docs) to.import(all);
|
|
60
|
+
};
|
|
61
|
+
return { a, b, docs, server, sync };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = (v: EditorView) => v.state.doc.textBetween(0, v.state.doc.content.size, "\n");
|
|
65
|
+
const type = (v: EditorView, s: string) => {
|
|
66
|
+
for (const ch of s) v.dispatch(v.state.tr.insertText(ch));
|
|
67
|
+
};
|
|
68
|
+
const caretAt = (v: EditorView, pos: number) =>
|
|
69
|
+
v.dispatch(v.state.tr.setSelection(TextSelection.create(v.state.doc, pos)));
|
|
70
|
+
|
|
71
|
+
describe("ProseMirrorSyncPlugin", () => {
|
|
72
|
+
test("typing in one editor reaches the other", async () => {
|
|
73
|
+
const { a, b, sync } = setup();
|
|
74
|
+
await tick();
|
|
75
|
+
type(a, "Hello");
|
|
76
|
+
sync();
|
|
77
|
+
expect(text(b)).toBe("Hello");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a remote edit before the caret keeps the caret on its words", async () => {
|
|
81
|
+
const { a, b, sync } = setup();
|
|
82
|
+
await tick();
|
|
83
|
+
type(a, "world");
|
|
84
|
+
sync();
|
|
85
|
+
caretAt(b, 6); // after "world"
|
|
86
|
+
caretAt(a, 1);
|
|
87
|
+
type(a, "hello ");
|
|
88
|
+
sync();
|
|
89
|
+
expect(text(b)).toBe("hello world");
|
|
90
|
+
expect(b.state.selection.head).toBe(12);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("typing right after a remote change goes at the caret, not the end", async () => {
|
|
94
|
+
const { a, b, sync } = setup();
|
|
95
|
+
await tick();
|
|
96
|
+
type(a, "one three");
|
|
97
|
+
sync();
|
|
98
|
+
caretAt(b, 4); // after "one"
|
|
99
|
+
caretAt(a, 10);
|
|
100
|
+
type(a, " four");
|
|
101
|
+
sync();
|
|
102
|
+
type(b, " two"); // no timer in between
|
|
103
|
+
sync();
|
|
104
|
+
expect(text(a)).toBe("one two three four");
|
|
105
|
+
expect(text(b)).toBe("one two three four");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("two people typing at the same spot keep their words whole", async () => {
|
|
109
|
+
const { a, b, sync } = setup();
|
|
110
|
+
await tick();
|
|
111
|
+
type(a, "Hi");
|
|
112
|
+
sync();
|
|
113
|
+
caretAt(a, 3);
|
|
114
|
+
caretAt(b, 3);
|
|
115
|
+
const left = " [from a]";
|
|
116
|
+
const right = " [from b]";
|
|
117
|
+
for (let i = 0; i < left.length; i++) {
|
|
118
|
+
type(a, left[i]);
|
|
119
|
+
type(b, right[i]);
|
|
120
|
+
sync();
|
|
121
|
+
}
|
|
122
|
+
expect(text(a)).toBe(text(b));
|
|
123
|
+
expect([`Hi${left}${right}`, `Hi${right}${left}`]).toContain(text(a));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a paragraph split elsewhere does not move the caret", async () => {
|
|
127
|
+
const { a, b, sync } = setup();
|
|
128
|
+
await tick();
|
|
129
|
+
type(a, "first second");
|
|
130
|
+
sync();
|
|
131
|
+
caretAt(b, 13); // end of "second"
|
|
132
|
+
caretAt(a, 6); // after "first"
|
|
133
|
+
a.dispatch(a.state.tr.split(6));
|
|
134
|
+
sync();
|
|
135
|
+
type(b, "!");
|
|
136
|
+
sync();
|
|
137
|
+
expect(text(a)).toBe("first\n second!");
|
|
138
|
+
expect(text(b)).toBe("first\n second!");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("undo takes back a burst of typing as one step", async () => {
|
|
142
|
+
const { a, sync } = setup();
|
|
143
|
+
await tick();
|
|
144
|
+
type(a, "kept");
|
|
145
|
+
sync();
|
|
146
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
147
|
+
type(a, " typed together");
|
|
148
|
+
undo(a.state, a.dispatch);
|
|
149
|
+
expect(text(a)).toBe("kept");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Loro does not merge an undo step across a remote change in the same
|
|
153
|
+
// container (loro-internal undo.rs, push_with_merge), so while two people
|
|
154
|
+
// type in one paragraph each undo takes back a smaller step. Each step is
|
|
155
|
+
// still only this person's.
|
|
156
|
+
test("undo while someone else types removes only this person's typing", async () => {
|
|
157
|
+
const { a, b, sync } = setup();
|
|
158
|
+
await tick();
|
|
159
|
+
type(a, "x");
|
|
160
|
+
sync();
|
|
161
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
162
|
+
caretAt(a, 2);
|
|
163
|
+
caretAt(b, 1);
|
|
164
|
+
for (const [ca, cb] of [["1", "a"], ["2", "b"], ["3", "c"], ["4", "d"]]) {
|
|
165
|
+
type(a, ca);
|
|
166
|
+
type(b, cb);
|
|
167
|
+
sync();
|
|
168
|
+
}
|
|
169
|
+
undo(b.state, b.dispatch);
|
|
170
|
+
sync();
|
|
171
|
+
expect(text(a)).toContain("1234");
|
|
172
|
+
expect(text(a)).not.toContain("d");
|
|
173
|
+
for (let i = 0; i < 4; i++) undo(b.state, b.dispatch);
|
|
174
|
+
sync();
|
|
175
|
+
expect(text(a)).toBe("x1234");
|
|
176
|
+
expect(text(b)).toBe("x1234");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("undo removes only this editor's edit", async () => {
|
|
180
|
+
const { a, b, sync } = setup();
|
|
181
|
+
await tick();
|
|
182
|
+
type(a, "base");
|
|
183
|
+
sync();
|
|
184
|
+
caretAt(b, 5);
|
|
185
|
+
type(b, " by b");
|
|
186
|
+
sync();
|
|
187
|
+
caretAt(a, 1);
|
|
188
|
+
type(a, "A:");
|
|
189
|
+
sync();
|
|
190
|
+
undo(b.state, b.dispatch);
|
|
191
|
+
sync();
|
|
192
|
+
expect(text(a)).toBe("A:base");
|
|
193
|
+
expect(text(b)).toBe("A:base");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Regressions from the Codex review of 2026-10-17.
|
|
198
|
+
describe("ProseMirrorSyncPlugin review fixes", () => {
|
|
199
|
+
test("the first edit in a new document can be undone", async () => {
|
|
200
|
+
const { a, b, sync } = setup();
|
|
201
|
+
await tick();
|
|
202
|
+
type(a, "first words");
|
|
203
|
+
sync();
|
|
204
|
+
expect(text(b)).toBe("first words");
|
|
205
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
206
|
+
type(a, " more");
|
|
207
|
+
undo(a.state, a.dispatch);
|
|
208
|
+
undo(a.state, a.dispatch);
|
|
209
|
+
sync();
|
|
210
|
+
expect(text(a)).toBe("");
|
|
211
|
+
expect(text(b)).toBe("");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("two editors on one doc see each other's edits", async () => {
|
|
215
|
+
const { a, b, server, sync } = setup({ shared: true });
|
|
216
|
+
await tick();
|
|
217
|
+
type(a, "base");
|
|
218
|
+
expect(text(b)).toBe("base");
|
|
219
|
+
caretAt(b, 5);
|
|
220
|
+
type(b, "B");
|
|
221
|
+
expect(text(a)).toBe("baseB");
|
|
222
|
+
sync();
|
|
223
|
+
expect(JSON.stringify(server.getMap("row").get("body"))).toBeDefined();
|
|
224
|
+
expect(text(a)).toBe("baseB");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("the top node's attributes load and are not overwritten", async () => {
|
|
228
|
+
const langSchema = new Schema({
|
|
229
|
+
nodes: defaultSchema.spec.nodes.update("doc", { ...defaultSchema.spec.nodes.get("doc")!, attrs: { lang: { default: "en" } } }),
|
|
230
|
+
marks: defaultSchema.spec.marks,
|
|
231
|
+
});
|
|
232
|
+
const { a, b, docs, sync } = setup({ schema: langSchema });
|
|
233
|
+
await tick();
|
|
234
|
+
a.dispatch(a.state.tr.setDocAttribute("lang", "fr"));
|
|
235
|
+
type(a, "bonjour");
|
|
236
|
+
sync();
|
|
237
|
+
expect(b.state.doc.attrs.lang).toBe("fr");
|
|
238
|
+
type(b, "!");
|
|
239
|
+
sync();
|
|
240
|
+
expect(a.state.doc.attrs.lang).toBe("fr");
|
|
241
|
+
const attrs = (docs[0].getMap("row").get("body") as LoroMap).get("attributes") as LoroMap;
|
|
242
|
+
expect(attrs.get("lang")).toBe("fr");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("clearing the document does not add a paragraph per editor", async () => {
|
|
246
|
+
const { a, b, docs, server, sync } = setup();
|
|
247
|
+
await tick();
|
|
248
|
+
type(a, "text");
|
|
249
|
+
sync();
|
|
250
|
+
const children = (server.getMap("row").get("body") as LoroMap).get("children") as LoroList;
|
|
251
|
+
children.delete(0, children.length);
|
|
252
|
+
server.commit();
|
|
253
|
+
for (const d of docs) d.import(server.export({ mode: "snapshot" }));
|
|
254
|
+
sync();
|
|
255
|
+
expect(children.length).toBe(0);
|
|
256
|
+
expect(text(a)).toBe("");
|
|
257
|
+
expect(text(b)).toBe("");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a schema whose top node is not doc can edit a new document", async () => {
|
|
261
|
+
const custom = new Schema({
|
|
262
|
+
topNode: "document",
|
|
263
|
+
nodes: defaultSchema.spec.nodes.remove("doc").addToStart("document", { content: "block+" }),
|
|
264
|
+
marks: defaultSchema.spec.marks,
|
|
265
|
+
});
|
|
266
|
+
const { a, b, sync } = setup({ schema: custom });
|
|
267
|
+
await tick();
|
|
268
|
+
type(a, "hello");
|
|
269
|
+
sync();
|
|
270
|
+
expect(text(b)).toBe("hello");
|
|
271
|
+
expect(b.state.doc.type.name).toBe("document");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a caret after an emoji stays after it", async () => {
|
|
275
|
+
const { a, b, sync } = setup();
|
|
276
|
+
await tick();
|
|
277
|
+
type(a, "x😀");
|
|
278
|
+
sync();
|
|
279
|
+
caretAt(b, 4); // after the emoji (1 + 1 + 2)
|
|
280
|
+
caretAt(a, 1);
|
|
281
|
+
type(a, "AB");
|
|
282
|
+
sync();
|
|
283
|
+
expect(text(b)).toBe("ABx😀");
|
|
284
|
+
expect(b.state.selection.head).toBe(6);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a caret after an inline node with content stays put", async () => {
|
|
288
|
+
const nodes = defaultSchema.spec.nodes.addToEnd("chip", {
|
|
289
|
+
group: "inline",
|
|
290
|
+
inline: true,
|
|
291
|
+
content: "text*",
|
|
292
|
+
toDOM: () => ["span", { class: "chip" }, 0],
|
|
293
|
+
});
|
|
294
|
+
const chipSchema = new Schema({ nodes, marks: defaultSchema.spec.marks });
|
|
295
|
+
const { a, b, sync } = setup({ schema: chipSchema });
|
|
296
|
+
await tick();
|
|
297
|
+
type(a, "ab");
|
|
298
|
+
a.dispatch(a.state.tr.insert(3, chipSchema.node("chip", null, [chipSchema.text("XX")])));
|
|
299
|
+
caretAt(a, 7); // after the chip
|
|
300
|
+
type(a, "cd");
|
|
301
|
+
sync();
|
|
302
|
+
caretAt(b, 9); // after "cd"
|
|
303
|
+
caretAt(a, 1);
|
|
304
|
+
type(a, "Z");
|
|
305
|
+
sync();
|
|
306
|
+
expect(b.state.selection.head).toBe(10);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a selected node stays selected through a remote edit", async () => {
|
|
310
|
+
const { a, b, sync } = setup();
|
|
311
|
+
await tick();
|
|
312
|
+
type(a, "pic ");
|
|
313
|
+
a.dispatch(a.state.tr.insert(5, defaultSchema.node("image", { src: "x.png" })));
|
|
314
|
+
sync();
|
|
315
|
+
const { NodeSelection } = await import("prosemirror-state");
|
|
316
|
+
b.dispatch(b.state.tr.setSelection(NodeSelection.create(b.state.doc, 5)));
|
|
317
|
+
caretAt(a, 1);
|
|
318
|
+
type(a, "A ");
|
|
319
|
+
sync();
|
|
320
|
+
expect(b.state.selection).toBeInstanceOf(NodeSelection);
|
|
321
|
+
expect((b.state.selection as InstanceType<typeof NodeSelection>).node.type.name).toBe("image");
|
|
322
|
+
});
|
|
323
|
+
});
|