@pylonsync/loro 0.22.44 → 0.22.46
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 +5 -4
- package/src/prosemirror/cursors.ts +14 -0
- package/src/prosemirror/ranges.test.ts +886 -0
- package/src/prosemirror/ranges.ts +637 -0
- package/src/prosemirror/sync.ts +23 -5
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
// Anchored ranges (comment anchors) between two real editors (happy-dom) bound to two LoroDocs.
|
|
2
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
3
|
+
if (!globalThis.document) GlobalRegistrator.register();
|
|
4
|
+
|
|
5
|
+
import { afterEach, expect, test } from "bun:test";
|
|
6
|
+
import { LoroDoc, LoroList, LoroMap, LoroText, type ContainerID } from "loro-crdt";
|
|
7
|
+
import { Schema } from "prosemirror-model";
|
|
8
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
9
|
+
import { EditorState, TextSelection } from "prosemirror-state";
|
|
10
|
+
import { EditorView } from "prosemirror-view";
|
|
11
|
+
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
12
|
+
import {
|
|
13
|
+
diffMatches,
|
|
14
|
+
encodeRange,
|
|
15
|
+
mapSpans,
|
|
16
|
+
proseMirrorRangesKey,
|
|
17
|
+
ProseMirrorRangesPlugin,
|
|
18
|
+
resolveRanges,
|
|
19
|
+
setAnchoredRanges,
|
|
20
|
+
type AnchoredRange,
|
|
21
|
+
type EncodedRange,
|
|
22
|
+
} from "./ranges";
|
|
23
|
+
import { undo } from "loro-prosemirror";
|
|
24
|
+
import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
|
|
25
|
+
|
|
26
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
27
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
28
|
+
const views: EditorView[] = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const v of views.splice(0)) v.destroy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function setup() {
|
|
34
|
+
const server = new LoroDoc();
|
|
35
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
36
|
+
body.setContainer("attributes", new LoroMap());
|
|
37
|
+
body.setContainer("children", new LoroList());
|
|
38
|
+
server.commit();
|
|
39
|
+
const docs = [new LoroDoc(), new LoroDoc()];
|
|
40
|
+
for (const d of docs) {
|
|
41
|
+
d.import(server.export({ mode: "snapshot" }));
|
|
42
|
+
configureProseMirrorMarks(d, schema);
|
|
43
|
+
}
|
|
44
|
+
const containerId = findProseMirrorContainer(docs[0], "body") as ContainerID;
|
|
45
|
+
const reanchored: Array<{ id: string; range: EncodedRange }> = [];
|
|
46
|
+
const make = (doc: LoroDoc) => {
|
|
47
|
+
const view = new EditorView(document.createElement("div"), {
|
|
48
|
+
state: EditorState.create({
|
|
49
|
+
schema,
|
|
50
|
+
plugins: [
|
|
51
|
+
ProseMirrorSyncPlugin({ doc, containerId }),
|
|
52
|
+
ProseMirrorUndoPlugin({ doc }),
|
|
53
|
+
ProseMirrorRangesPlugin({ doc, containerId, onReanchor: (id, range) => reanchored.push({ id, range }) }),
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
views.push(view);
|
|
58
|
+
return view;
|
|
59
|
+
};
|
|
60
|
+
const a = make(docs[0]);
|
|
61
|
+
const b = make(docs[1]);
|
|
62
|
+
const sync = () => {
|
|
63
|
+
for (const from of docs) server.import(from.export({ mode: "update", from: server.oplogVersion() }));
|
|
64
|
+
const all = server.export({ mode: "snapshot" });
|
|
65
|
+
for (const to of docs) to.import(all);
|
|
66
|
+
};
|
|
67
|
+
return { a, b, docs, containerId, sync, reanchored };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const type = (v: EditorView, s: string) => {
|
|
71
|
+
for (const ch of s) v.dispatch(v.state.tr.insertText(ch));
|
|
72
|
+
};
|
|
73
|
+
const caretAt = (v: EditorView, pos: number) => v.dispatch(v.state.tr.setSelection(TextSelection.create(v.state.doc, pos)));
|
|
74
|
+
function drawn(v: EditorView): Array<{ id: string; from: number; to: number }> {
|
|
75
|
+
const plugin = v.state.plugins.find((p) => p.spec.key === proseMirrorRangesKey)!;
|
|
76
|
+
const set = plugin.props.decorations!.call(plugin, v.state) as unknown as {
|
|
77
|
+
find(): Array<{ from: number; to: number; spec: { rangeId: string } }>;
|
|
78
|
+
};
|
|
79
|
+
return set
|
|
80
|
+
.find()
|
|
81
|
+
.map((d) => ({ id: d.spec.rangeId, from: d.from, to: d.to }))
|
|
82
|
+
.sort((x, y) => x.from - y.from || x.id.localeCompare(y.id));
|
|
83
|
+
}
|
|
84
|
+
const text = (v: EditorView, from: number, to: number) => v.state.doc.textBetween(from, to);
|
|
85
|
+
function drawnAfter(v: EditorView, ranges: AnchoredRange[]) {
|
|
86
|
+
setAnchoredRanges(v, ranges);
|
|
87
|
+
return drawn(v);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("a range made in one editor is drawn on the same words in another", async () => {
|
|
91
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
92
|
+
await tick();
|
|
93
|
+
type(a, "the quick fox");
|
|
94
|
+
sync();
|
|
95
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10); // "quick"
|
|
96
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
97
|
+
expect(drawn(b)).toEqual([{ id: "t1", from: 5, to: 10 }]);
|
|
98
|
+
expect(text(b, 5, 10)).toBe("quick");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a range stays on its words when text is added before it", async () => {
|
|
102
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
103
|
+
await tick();
|
|
104
|
+
type(a, "the quick fox");
|
|
105
|
+
sync();
|
|
106
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
107
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
108
|
+
caretAt(b, 1);
|
|
109
|
+
type(b, "see ");
|
|
110
|
+
const [r] = drawn(b);
|
|
111
|
+
expect(text(b, r.from, r.to)).toBe("quick");
|
|
112
|
+
// The other editor gets the same answer after sync.
|
|
113
|
+
sync();
|
|
114
|
+
const [ra] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
115
|
+
expect(ra.gone).toBe(false);
|
|
116
|
+
expect(text(a, ra.from, ra.to)).toBe("quick");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a range grows when text is typed inside it", async () => {
|
|
120
|
+
const { a, docs, containerId } = setup();
|
|
121
|
+
await tick();
|
|
122
|
+
type(a, "the quick fox");
|
|
123
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
124
|
+
caretAt(a, 7);
|
|
125
|
+
type(a, "-");
|
|
126
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
127
|
+
expect(text(a, r.from, r.to)).toBe("qu-ick");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a range whose text is deleted is gone and is not drawn", async () => {
|
|
131
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
132
|
+
await tick();
|
|
133
|
+
type(a, "the quick fox");
|
|
134
|
+
sync();
|
|
135
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
136
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
137
|
+
b.dispatch(b.state.tr.delete(5, 11)); // "quick "
|
|
138
|
+
expect(drawn(b)).toEqual([]);
|
|
139
|
+
const [r] = resolveRanges(b.state, docs[1], containerId, [{ id: "t1", ...anchor }]);
|
|
140
|
+
expect(r.gone).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("ranges may overlap and keep their class and attributes", async () => {
|
|
144
|
+
const { a, docs, containerId } = setup();
|
|
145
|
+
await tick();
|
|
146
|
+
type(a, "the quick fox");
|
|
147
|
+
const ranges: AnchoredRange[] = [
|
|
148
|
+
{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 1, 10), className: "thread" },
|
|
149
|
+
{ id: "t2", ...encodeRange(a.state, docs[0], containerId, 5, 14), attributes: { "data-thread-id": "t2" } },
|
|
150
|
+
];
|
|
151
|
+
setAnchoredRanges(a, ranges);
|
|
152
|
+
expect(drawn(a)).toEqual([
|
|
153
|
+
{ id: "t1", from: 1, to: 10 },
|
|
154
|
+
{ id: "t2", from: 5, to: 14 },
|
|
155
|
+
]);
|
|
156
|
+
const plugin = a.state.plugins.find((p) => p.spec.key === proseMirrorRangesKey)!;
|
|
157
|
+
const set = plugin.props.decorations!.call(plugin, a.state) as unknown as {
|
|
158
|
+
find(): Array<{ spec: { rangeId: string }; type: { attrs: Record<string, string> } }>;
|
|
159
|
+
};
|
|
160
|
+
const attrs = Object.fromEntries(set.find().map((d) => [d.spec.rangeId, d.type.attrs]));
|
|
161
|
+
expect(attrs.t1.class).toBe("thread");
|
|
162
|
+
expect(attrs.t2.class).toBe("pylon-anchored-range");
|
|
163
|
+
expect(attrs.t2["data-thread-id"]).toBe("t2");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a reversed range is stored in document order", async () => {
|
|
167
|
+
const { a, docs, containerId } = setup();
|
|
168
|
+
await tick();
|
|
169
|
+
type(a, "the quick fox");
|
|
170
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 10, 5);
|
|
171
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
172
|
+
expect([r.from, r.to]).toEqual([5, 10]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("bad or missing cursors resolve as gone, not as an error", async () => {
|
|
176
|
+
const { a, docs, containerId } = setup();
|
|
177
|
+
await tick();
|
|
178
|
+
type(a, "hello");
|
|
179
|
+
const out = resolveRanges(a.state, docs[0], containerId, [
|
|
180
|
+
{ id: "x", from: "not a cursor", to: "!!" },
|
|
181
|
+
{ id: "y", from: null, to: null },
|
|
182
|
+
]);
|
|
183
|
+
expect(out.map((r) => r.gone)).toEqual([true, true]);
|
|
184
|
+
setAnchoredRanges(a, [{ id: "x", from: "not a cursor", to: "!!" }]);
|
|
185
|
+
expect(drawn(a)).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const covered = (v: EditorView, id = "t1") => {
|
|
189
|
+
const r = drawn(v).find((d) => d.id === id);
|
|
190
|
+
return r ? v.state.doc.textBetween(r.from, r.to, "|") : null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
test("text typed just before or just after a range stays out of it", async () => {
|
|
194
|
+
const { a, docs, containerId } = setup();
|
|
195
|
+
await tick();
|
|
196
|
+
type(a, "the quick fox");
|
|
197
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
198
|
+
caretAt(a, 5);
|
|
199
|
+
type(a, "new ");
|
|
200
|
+
caretAt(a, 14); // after "quick"
|
|
201
|
+
type(a, "est");
|
|
202
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
203
|
+
expect(text(a, r.from, r.to)).toBe("quick");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("the editor that splits a paragraph keeps the range and gives new cursors to store", async () => {
|
|
207
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
208
|
+
await tick();
|
|
209
|
+
type(a, "the quick fox");
|
|
210
|
+
sync();
|
|
211
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
212
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
213
|
+
a.dispatch(a.state.tr.split(5));
|
|
214
|
+
expect(covered(a)).toBe("quick");
|
|
215
|
+
expect(reanchored.map((r) => r.id)).toEqual(["t1"]);
|
|
216
|
+
// The other editor, given the stored cursors, finds it too.
|
|
217
|
+
sync();
|
|
218
|
+
await tick();
|
|
219
|
+
setAnchoredRanges(b, [{ id: "t1", ...reanchored[0].range }]);
|
|
220
|
+
expect(covered(b)).toBe("quick");
|
|
221
|
+
// Once the app sets the new cursors, nothing more to store.
|
|
222
|
+
setAnchoredRanges(a, [{ id: "t1", ...reanchored[0].range }]);
|
|
223
|
+
caretAt(a, 1);
|
|
224
|
+
type(a, "x");
|
|
225
|
+
expect(covered(a)).toBe("quick");
|
|
226
|
+
expect(reanchored.length).toBe(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a range split across two paragraphs keeps both parts", async () => {
|
|
230
|
+
const { a, docs, containerId } = setup();
|
|
231
|
+
await tick();
|
|
232
|
+
type(a, "the quick fox");
|
|
233
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
234
|
+
a.dispatch(a.state.tr.split(7)); // "the qu" | "ick fox"
|
|
235
|
+
expect(covered(a)).toBe("qu|ick");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("text typed inside a range, then a split before it, keeps the whole range", async () => {
|
|
239
|
+
const { a, docs, containerId } = setup();
|
|
240
|
+
await tick();
|
|
241
|
+
type(a, "the quick fox");
|
|
242
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
243
|
+
caretAt(a, 7);
|
|
244
|
+
type(a, "-");
|
|
245
|
+
a.dispatch(a.state.tr.split(5));
|
|
246
|
+
expect(covered(a)).toBe("qu-ick");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("joining paragraphs keeps a range in the second one", async () => {
|
|
250
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
251
|
+
await tick();
|
|
252
|
+
type(a, "the ");
|
|
253
|
+
a.dispatch(a.state.tr.split(5));
|
|
254
|
+
type(a, "quick fox");
|
|
255
|
+
// "the " | "quick fox": quick is at 7..12.
|
|
256
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 7, 12) }]);
|
|
257
|
+
expect(covered(a)).toBe("quick");
|
|
258
|
+
a.dispatch(a.state.tr.join(6));
|
|
259
|
+
expect(a.state.doc.childCount).toBe(1);
|
|
260
|
+
expect(covered(a)).toBe("quick");
|
|
261
|
+
expect(reanchored.length).toBeGreaterThan(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a range whose text is replaced is gone, even when the same words are elsewhere", async () => {
|
|
265
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
266
|
+
await tick();
|
|
267
|
+
type(a, "the quick fox quick");
|
|
268
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
269
|
+
a.dispatch(a.state.tr.insertText("slow", 5, 10));
|
|
270
|
+
expect(covered(a)).toBeNull();
|
|
271
|
+
expect(reanchored).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a range keeps what is left when part of its text is deleted", async () => {
|
|
275
|
+
const { a, docs, containerId } = setup();
|
|
276
|
+
await tick();
|
|
277
|
+
type(a, "the quick fox");
|
|
278
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
279
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
280
|
+
a.dispatch(a.state.tr.delete(5, 8)); // "qui"
|
|
281
|
+
expect(covered(a)).toBe("ck");
|
|
282
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
283
|
+
expect(text(a, r.from, r.to)).toBe("ck");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a selection from the end of one paragraph starts at the next one's text", async () => {
|
|
287
|
+
const { a, docs, containerId } = setup();
|
|
288
|
+
await tick();
|
|
289
|
+
type(a, "one");
|
|
290
|
+
a.dispatch(a.state.tr.split(4));
|
|
291
|
+
type(a, "two");
|
|
292
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 4, 9);
|
|
293
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...anchor }]);
|
|
294
|
+
expect(text(a, r.from, r.to)).toBe("two");
|
|
295
|
+
expect(encodeRange(a.state, docs[0], containerId, 1, 1)).toEqual({ from: null, to: null });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a remote change that leaves the same text redraws the range", async () => {
|
|
299
|
+
const { a, docs, containerId, sync } = setup();
|
|
300
|
+
await tick();
|
|
301
|
+
type(a, "the quick fox");
|
|
302
|
+
sync();
|
|
303
|
+
await tick();
|
|
304
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
305
|
+
expect(drawn(a)).toEqual([{ id: "t1", from: 5, to: 10 }]);
|
|
306
|
+
// B deletes the text and types it again as new characters, in one commit.
|
|
307
|
+
const root = docs[1].getContainerById(containerId) as LoroMap;
|
|
308
|
+
const para = (root.get("children") as LoroList).get(0) as LoroMap;
|
|
309
|
+
const t = (para.get("children") as LoroList).get(0) as LoroText;
|
|
310
|
+
t.delete(0, t.length);
|
|
311
|
+
t.insert(0, "the quick fox");
|
|
312
|
+
docs[1].commit();
|
|
313
|
+
sync();
|
|
314
|
+
await tick();
|
|
315
|
+
await tick();
|
|
316
|
+
expect(a.state.doc.textContent).toBe("the quick fox");
|
|
317
|
+
expect(drawn(a)).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("mapSpans cuts deleted text out and keeps edges inside", () => {
|
|
321
|
+
const state = EditorState.create({ schema, doc: schema.node("doc", null, [schema.node("paragraph", null, [schema.text("the quick fox")])]) });
|
|
322
|
+
const span = [[5, 10]] as const;
|
|
323
|
+
expect(mapSpans(span, state.tr.insertText("slow", 5, 10))).toEqual([]);
|
|
324
|
+
expect(mapSpans(span, state.tr.delete(5, 8))).toEqual([[5, 7]]);
|
|
325
|
+
expect(mapSpans(span, state.tr.insertText("new ", 5))).toEqual([[9, 14]]);
|
|
326
|
+
expect(mapSpans(span, state.tr.insertText("x", 10))).toEqual([[5, 10]]);
|
|
327
|
+
expect(mapSpans(span, state.tr.insertText("x", 7))).toEqual([[5, 11]]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a local edit, then a remote change that leaves the same text, still drops the range", async () => {
|
|
331
|
+
const { a, docs, containerId, sync, reanchored } = setup();
|
|
332
|
+
await tick();
|
|
333
|
+
type(a, "the quick fox");
|
|
334
|
+
sync();
|
|
335
|
+
await tick();
|
|
336
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
337
|
+
caretAt(a, 14);
|
|
338
|
+
type(a, "!");
|
|
339
|
+
sync();
|
|
340
|
+
await tick();
|
|
341
|
+
// B deletes "the quick fox" and types it again as new characters.
|
|
342
|
+
const root = docs[1].getContainerById(containerId) as LoroMap;
|
|
343
|
+
const para = (root.get("children") as LoroList).get(0) as LoroMap;
|
|
344
|
+
const t = (para.get("children") as LoroList).get(0) as LoroText;
|
|
345
|
+
t.delete(0, 13);
|
|
346
|
+
t.insert(0, "the quick fox");
|
|
347
|
+
docs[1].commit();
|
|
348
|
+
sync();
|
|
349
|
+
await tick();
|
|
350
|
+
await tick();
|
|
351
|
+
expect(a.state.doc.textContent).toBe("the quick fox!");
|
|
352
|
+
expect(covered(a)).toBeNull();
|
|
353
|
+
expect(reanchored).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("undoing a split keeps the range and gives new cursors again", async () => {
|
|
357
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
358
|
+
await tick();
|
|
359
|
+
type(a, "the quick fox");
|
|
360
|
+
await new Promise((r) => setTimeout(r, 1100)); // a separate undo step
|
|
361
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
362
|
+
a.dispatch(a.state.tr.split(5));
|
|
363
|
+
expect(covered(a)).toBe("quick");
|
|
364
|
+
undo(a.state, a.dispatch);
|
|
365
|
+
await tick();
|
|
366
|
+
expect(a.state.doc.childCount).toBe(1);
|
|
367
|
+
expect(covered(a)).toBe("quick");
|
|
368
|
+
expect(reanchored.length).toBe(2);
|
|
369
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...reanchored[1].range }]);
|
|
370
|
+
expect(text(a, r.from, r.to)).toBe("quick");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("saves that come back out of order do not undo newer cursors", async () => {
|
|
374
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
375
|
+
await tick();
|
|
376
|
+
type(a, "the quick fox");
|
|
377
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
378
|
+
a.dispatch(a.state.tr.split(5)); // "the " | "quick fox"
|
|
379
|
+
a.dispatch(a.state.tr.split(9)); // "the " | "qu" | "ick fox"
|
|
380
|
+
expect(covered(a)).toBe("qu|ick");
|
|
381
|
+
expect(reanchored.length).toBe(2);
|
|
382
|
+
// The first save comes back last.
|
|
383
|
+
setAnchoredRanges(a, [{ id: "t1", ...reanchored[0].range }]);
|
|
384
|
+
expect(covered(a)).toBe("qu|ick");
|
|
385
|
+
setAnchoredRanges(a, [{ id: "t1", ...reanchored[1].range }]);
|
|
386
|
+
expect(covered(a)).toBe("qu|ick");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("a view update without a new state does not store the cursors again", async () => {
|
|
390
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
391
|
+
await tick();
|
|
392
|
+
type(a, "the quick fox");
|
|
393
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
394
|
+
a.dispatch(a.state.tr.split(5));
|
|
395
|
+
expect(reanchored.length).toBe(1);
|
|
396
|
+
a.setProps({ editable: () => true });
|
|
397
|
+
a.setProps({ editable: () => true });
|
|
398
|
+
expect(reanchored.length).toBe(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("new cursors from the app replace the followed range", async () => {
|
|
402
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
403
|
+
await tick();
|
|
404
|
+
type(a, "the quick fox");
|
|
405
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
406
|
+
caretAt(a, 14);
|
|
407
|
+
type(a, "!");
|
|
408
|
+
setAnchoredRanges(a, [{ id: "t1", from: null, to: null }]);
|
|
409
|
+
caretAt(a, 1);
|
|
410
|
+
type(a, "x");
|
|
411
|
+
expect(covered(a)).toBeNull();
|
|
412
|
+
expect(reanchored).toEqual([]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("a remote change that arrives as one replace over the range shows again with the new cursors", async () => {
|
|
416
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
417
|
+
await tick();
|
|
418
|
+
type(a, "the quick fox");
|
|
419
|
+
sync();
|
|
420
|
+
await tick();
|
|
421
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
422
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
423
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
424
|
+
// A splits before "quick" and types after the text, both before B imports.
|
|
425
|
+
a.dispatch(a.state.tr.split(5));
|
|
426
|
+
caretAt(a, a.state.doc.content.size - 1);
|
|
427
|
+
type(a, "!");
|
|
428
|
+
sync();
|
|
429
|
+
await tick();
|
|
430
|
+
await tick();
|
|
431
|
+
expect(b.state.doc.textBetween(0, b.state.doc.content.size, "|")).toBe("the |quick fox!");
|
|
432
|
+
// B's cursors name characters the split replaced, and the change reached B
|
|
433
|
+
// as one replace over them: B waits for A's cursors.
|
|
434
|
+
setAnchoredRanges(b, [{ id: "t1", ...reanchored.at(-1)!.range }]);
|
|
435
|
+
expect(covered(b)).toBe("quick");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("an editor stores new cursors only for its own last edit", async () => {
|
|
439
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
440
|
+
await tick();
|
|
441
|
+
type(a, "the quick fox");
|
|
442
|
+
sync();
|
|
443
|
+
await tick();
|
|
444
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
445
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
446
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
447
|
+
caretAt(b, b.state.doc.content.size - 1);
|
|
448
|
+
type(b, "!");
|
|
449
|
+
sync();
|
|
450
|
+
await tick();
|
|
451
|
+
const before = reanchored.length;
|
|
452
|
+
a.dispatch(a.state.tr.split(5));
|
|
453
|
+
caretAt(a, 9); // after "qu"
|
|
454
|
+
type(a, "-");
|
|
455
|
+
const fromA = reanchored.length - before;
|
|
456
|
+
sync();
|
|
457
|
+
await tick();
|
|
458
|
+
await tick();
|
|
459
|
+
// Only A's callbacks: B followed the range to draw it.
|
|
460
|
+
expect(reanchored.length - before).toBe(fromA);
|
|
461
|
+
expect(covered(a)).toBe("qu-ick");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("undoing a split together with typing keeps the range", async () => {
|
|
465
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
466
|
+
await tick();
|
|
467
|
+
type(a, "the quick fox");
|
|
468
|
+
await new Promise((r) => setTimeout(r, 1100)); // a separate undo step
|
|
469
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
470
|
+
a.dispatch(a.state.tr.split(5));
|
|
471
|
+
caretAt(a, a.state.doc.content.size - 1);
|
|
472
|
+
type(a, "!"); // the same undo step
|
|
473
|
+
undo(a.state, a.dispatch);
|
|
474
|
+
await tick();
|
|
475
|
+
expect(a.state.doc.textContent).toBe("the quick fox");
|
|
476
|
+
expect(covered(a)).toBe("quick");
|
|
477
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...reanchored.at(-1)!.range }]);
|
|
478
|
+
expect(text(a, r.from, r.to)).toBe("quick");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("someone else's replace of the range's words hides it", async () => {
|
|
482
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
483
|
+
await tick();
|
|
484
|
+
type(a, "the quick fox");
|
|
485
|
+
sync();
|
|
486
|
+
await tick();
|
|
487
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
488
|
+
a.dispatch(a.state.tr.insertText("slow", 5, 10));
|
|
489
|
+
sync();
|
|
490
|
+
await tick();
|
|
491
|
+
await tick();
|
|
492
|
+
expect(b.state.doc.textContent).toBe("the slow fox");
|
|
493
|
+
expect(covered(b)).toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("diffMatches pairs equal entries of a shortest edit script", () => {
|
|
497
|
+
const chars = (s: string) => [...s];
|
|
498
|
+
expect(diffMatches(chars("abc"), chars("abc"))).toEqual([[0, 0], [1, 1], [2, 2]]);
|
|
499
|
+
expect(diffMatches(chars("ab|cd"), chars("abcd!"))).toEqual([[0, 0], [1, 1], [3, 2], [4, 3]]);
|
|
500
|
+
expect(diffMatches(chars("abc"), chars(""))).toEqual([]);
|
|
501
|
+
expect(diffMatches(chars("kitten"), chars("sitting"))!.map(([, j]) => "sitting"[j]).join("")).toBe("ittn");
|
|
502
|
+
const long = "x".repeat(1000);
|
|
503
|
+
expect(diffMatches(chars(long), chars("y".repeat(1000)))).toBeNull();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("Loro calls container listeners before doc listeners on import (the plugin relies on it)", () => {
|
|
507
|
+
const a = new LoroDoc();
|
|
508
|
+
const t = a.getMap("row").setContainer("body", new LoroMap()).setContainer("t", new LoroText());
|
|
509
|
+
a.commit();
|
|
510
|
+
const b = new LoroDoc();
|
|
511
|
+
b.import(a.export({ mode: "snapshot" }));
|
|
512
|
+
const order: string[] = [];
|
|
513
|
+
b.subscribe(() => order.push("doc"));
|
|
514
|
+
(b.getMap("row").get("body") as LoroMap).subscribe(() => order.push("container"));
|
|
515
|
+
t.insert(0, "x");
|
|
516
|
+
a.commit();
|
|
517
|
+
b.import(a.export({ mode: "update", from: b.oplogVersion() }));
|
|
518
|
+
expect(order).toEqual(["container", "doc"]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const retypeFirstText = (d: LoroDoc, containerId: ContainerID, paragraph = 0) => {
|
|
522
|
+
const root = d.getContainerById(containerId) as LoroMap;
|
|
523
|
+
const t = ((root.get("children") as LoroList).get(paragraph) as LoroMap).get("children") as LoroList;
|
|
524
|
+
const text = t.get(0) as LoroText;
|
|
525
|
+
const value = text.toString();
|
|
526
|
+
text.delete(0, value.length);
|
|
527
|
+
text.insert(0, value);
|
|
528
|
+
d.commit();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
test("a range drawn by its cursors is redrawn when a remote import retypes its text", async () => {
|
|
532
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
533
|
+
await tick();
|
|
534
|
+
type(a, "the quick fox");
|
|
535
|
+
sync();
|
|
536
|
+
await tick();
|
|
537
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
538
|
+
expect(covered(b)).toBe("quick");
|
|
539
|
+
retypeFirstText(docs[0], containerId);
|
|
540
|
+
sync();
|
|
541
|
+
await tick();
|
|
542
|
+
await tick();
|
|
543
|
+
const plugin = b.state.plugins.find((p) => p.spec.key === proseMirrorRangesKey)!;
|
|
544
|
+
const set = plugin.props.decorations!.call(plugin, b.state) as unknown as { find(): unknown[] };
|
|
545
|
+
expect(set.find()).toEqual([]);
|
|
546
|
+
expect(b.dom.querySelector(".pylon-anchored-range")).toBeNull();
|
|
547
|
+
void a;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("an import that changes another paragraph does not hide a retype in the same tick", async () => {
|
|
551
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
552
|
+
await tick();
|
|
553
|
+
type(a, "one");
|
|
554
|
+
a.dispatch(a.state.tr.split(4));
|
|
555
|
+
type(a, "the quick fox");
|
|
556
|
+
sync();
|
|
557
|
+
await tick();
|
|
558
|
+
// "one" | "the quick fox": quick is at 10..15. B follows it through A's split before it.
|
|
559
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 10, 15) }]);
|
|
560
|
+
expect(covered(b)).toBe("quick");
|
|
561
|
+
// Two imports before the microtask: one types in the first paragraph, one retypes the second.
|
|
562
|
+
caretAt(a, 4);
|
|
563
|
+
type(a, "!");
|
|
564
|
+
sync();
|
|
565
|
+
retypeFirstText(docs[0], containerId, 1);
|
|
566
|
+
sync();
|
|
567
|
+
await tick();
|
|
568
|
+
await tick();
|
|
569
|
+
expect(covered(b)).toBeNull();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("diffMatches handles long common runs and a pure insert", () => {
|
|
573
|
+
const big = Array.from({ length: 150_000 }, (_, i) => String.fromCharCode(97 + (i % 26)));
|
|
574
|
+
const out = diffMatches(["<", ...big, ">"], ["[", ...big, "]"]);
|
|
575
|
+
expect(out?.length).toBe(150_000);
|
|
576
|
+
expect(diffMatches(["a"], ["a", ...Array(401).fill("b")])).toEqual([[0, 0]]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("someone else's split hides the range here until their new cursors arrive", async () => {
|
|
580
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
581
|
+
await tick();
|
|
582
|
+
type(a, "the quick fox");
|
|
583
|
+
sync();
|
|
584
|
+
await tick();
|
|
585
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
586
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
587
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
588
|
+
a.dispatch(a.state.tr.split(5));
|
|
589
|
+
expect(reanchored.length).toBe(1);
|
|
590
|
+
sync();
|
|
591
|
+
await tick();
|
|
592
|
+
await tick();
|
|
593
|
+
expect(b.state.doc.childCount).toBe(2);
|
|
594
|
+
// B's cursors name the characters the split replaced: never drawn on others.
|
|
595
|
+
expect(covered(b)).toBeNull();
|
|
596
|
+
// B stores nothing; A's cursors bring it back.
|
|
597
|
+
expect(reanchored.length).toBe(1);
|
|
598
|
+
setAnchoredRanges(b, [{ id: "t1", ...reanchored[0].range }]);
|
|
599
|
+
expect(covered(b)).toBe("quick");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("deleting the first of two equal words takes the range with it", async () => {
|
|
603
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
604
|
+
await tick();
|
|
605
|
+
type(a, "quick quick");
|
|
606
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 1, 6) }]);
|
|
607
|
+
a.dispatch(a.state.tr.delete(1, 7));
|
|
608
|
+
expect(a.state.doc.textContent).toBe("quick");
|
|
609
|
+
expect(covered(a)).toBeNull();
|
|
610
|
+
expect(reanchored).toEqual([{ id: "t1", range: { from: null, to: null } }]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a local edit right after a retype import does not store cursors on the new characters", async () => {
|
|
614
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
615
|
+
await tick();
|
|
616
|
+
type(a, "the quick fox");
|
|
617
|
+
sync();
|
|
618
|
+
await tick();
|
|
619
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
620
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
621
|
+
const t = ((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList;
|
|
622
|
+
const text = t.get(0) as LoroText;
|
|
623
|
+
text.delete(0, 13);
|
|
624
|
+
text.insert(0, "the quick fox");
|
|
625
|
+
docs[0].commit();
|
|
626
|
+
sync(); // B imports; the refresh is still queued
|
|
627
|
+
caretAt(b, 14);
|
|
628
|
+
type(b, "!");
|
|
629
|
+
await tick();
|
|
630
|
+
await tick();
|
|
631
|
+
expect(reanchored).toEqual([]);
|
|
632
|
+
expect(covered(b)).toBeNull();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("an import right after an undo is someone else's change", async () => {
|
|
636
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
637
|
+
await tick();
|
|
638
|
+
type(a, "the quick fox");
|
|
639
|
+
sync();
|
|
640
|
+
await tick();
|
|
641
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 5, 10);
|
|
642
|
+
setAnchoredRanges(b, [{ id: "t1", ...anchor }]);
|
|
643
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
644
|
+
caretAt(b, 1);
|
|
645
|
+
type(b, "x");
|
|
646
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
647
|
+
undo(b.state, b.dispatch); // B's undo; loro-prosemirror's undo flag stays set until a timer
|
|
648
|
+
a.dispatch(a.state.tr.split(5));
|
|
649
|
+
sync(); // A's split reaches B inside that window
|
|
650
|
+
expect(b.state.doc.childCount).toBe(2);
|
|
651
|
+
expect(covered(b)).toBeNull();
|
|
652
|
+
expect(reanchored).toEqual([]);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("deleting a range's text where the binding keeps its characters clears its cursors", async () => {
|
|
656
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
657
|
+
await tick();
|
|
658
|
+
type(a, "quick quick");
|
|
659
|
+
sync();
|
|
660
|
+
await tick();
|
|
661
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 1, 6);
|
|
662
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
663
|
+
a.dispatch(a.state.tr.delete(1, 7));
|
|
664
|
+
expect(reanchored).toEqual([{ id: "t1", range: { from: null, to: null } }]);
|
|
665
|
+
// The app stores the cleared anchor; every editor then hides it.
|
|
666
|
+
sync();
|
|
667
|
+
await tick();
|
|
668
|
+
setAnchoredRanges(b, [{ id: "t1", from: null, to: null }]);
|
|
669
|
+
setAnchoredRanges(a, [{ id: "t1", from: null, to: null }]);
|
|
670
|
+
expect(covered(a)).toBeNull();
|
|
671
|
+
expect(covered(b)).toBeNull();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("no new cursors are stored while an import waits for its refresh", async () => {
|
|
675
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
676
|
+
await tick();
|
|
677
|
+
type(a, "the quick fox");
|
|
678
|
+
sync();
|
|
679
|
+
await tick();
|
|
680
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
681
|
+
caretAt(b, 14);
|
|
682
|
+
type(b, "!"); // B now follows the range
|
|
683
|
+
sync();
|
|
684
|
+
await tick();
|
|
685
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
686
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
687
|
+
const value = t.toString();
|
|
688
|
+
t.delete(0, value.length);
|
|
689
|
+
t.insert(0, value);
|
|
690
|
+
docs[0].commit();
|
|
691
|
+
sync();
|
|
692
|
+
caretAt(b, 2); // a selection change before the refresh
|
|
693
|
+
await tick();
|
|
694
|
+
await tick();
|
|
695
|
+
expect(reanchored).toEqual([]);
|
|
696
|
+
expect(covered(b)).toBeNull();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a selection change between a retype import and its refresh does not bring the range back", async () => {
|
|
700
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
701
|
+
await tick();
|
|
702
|
+
type(a, "the quick fox");
|
|
703
|
+
sync();
|
|
704
|
+
await tick();
|
|
705
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
706
|
+
caretAt(b, 14);
|
|
707
|
+
type(b, "!");
|
|
708
|
+
sync();
|
|
709
|
+
await tick();
|
|
710
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
711
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
712
|
+
const value = t.toString();
|
|
713
|
+
t.delete(0, value.length);
|
|
714
|
+
t.insert(0, value);
|
|
715
|
+
docs[0].commit();
|
|
716
|
+
sync();
|
|
717
|
+
caretAt(b, 2);
|
|
718
|
+
await tick();
|
|
719
|
+
await tick();
|
|
720
|
+
caretAt(b, 3);
|
|
721
|
+
expect(covered(b)).toBeNull();
|
|
722
|
+
expect(reanchored).toEqual([]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("a split right after an import still gives new cursors", async () => {
|
|
726
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
727
|
+
await tick();
|
|
728
|
+
type(a, "the quick fox");
|
|
729
|
+
sync();
|
|
730
|
+
await tick();
|
|
731
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
732
|
+
caretAt(a, 14);
|
|
733
|
+
type(a, "!");
|
|
734
|
+
sync(); // B imports; then splits at once
|
|
735
|
+
b.dispatch(b.state.tr.split(5));
|
|
736
|
+
expect(covered(b)).toBe("quick");
|
|
737
|
+
expect(reanchored.map((r) => r.id)).toEqual(["t1"]);
|
|
738
|
+
await tick();
|
|
739
|
+
await tick();
|
|
740
|
+
expect(covered(b)).toBe("quick");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("a followed range whose edge text is deleted gives new cursors once", async () => {
|
|
744
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
745
|
+
await tick();
|
|
746
|
+
type(a, "the quick fox");
|
|
747
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
748
|
+
a.dispatch(a.state.tr.split(7)); // "the qu" | "ick fox"
|
|
749
|
+
a.dispatch(a.state.tr.delete(5, 7)); // "qu"
|
|
750
|
+
expect(covered(a)).toBe("ick");
|
|
751
|
+
const count = reanchored.length;
|
|
752
|
+
a.setProps({ editable: () => true });
|
|
753
|
+
a.setProps({ editable: () => true });
|
|
754
|
+
expect(reanchored.length).toBe(count);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("a deleted range whose cursors still find part of the text is cleared", async () => {
|
|
758
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
759
|
+
await tick();
|
|
760
|
+
type(a, "aaaaa");
|
|
761
|
+
const anchor = encodeRange(a.state, docs[0], containerId, 1, 4);
|
|
762
|
+
setAnchoredRanges(a, [{ id: "t1", ...anchor }]);
|
|
763
|
+
a.dispatch(a.state.tr.delete(1, 4));
|
|
764
|
+
expect(covered(a)).toBeNull();
|
|
765
|
+
expect(reanchored).toEqual([{ id: "t1", range: { from: null, to: null } }]);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("app code writing the field directly is not taken for an undo", async () => {
|
|
769
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
770
|
+
await tick();
|
|
771
|
+
type(a, "the quick fox");
|
|
772
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
773
|
+
await tick();
|
|
774
|
+
// Replace the text with new characters, outside the editor.
|
|
775
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
776
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
777
|
+
t.delete(0, t.length);
|
|
778
|
+
t.insert(0, "the quick fox!");
|
|
779
|
+
docs[0].commit();
|
|
780
|
+
await tick();
|
|
781
|
+
expect(a.state.doc.textContent).toBe("the quick fox!");
|
|
782
|
+
expect(covered(a)).toBeNull();
|
|
783
|
+
expect(reanchored).toEqual([]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("reading the decorations before the refresh does not cancel it", async () => {
|
|
787
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
788
|
+
await tick();
|
|
789
|
+
type(a, "the quick fox");
|
|
790
|
+
sync();
|
|
791
|
+
await tick();
|
|
792
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
793
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
794
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
795
|
+
t.delete(0, 13);
|
|
796
|
+
t.insert(0, "the quick fox");
|
|
797
|
+
docs[0].commit();
|
|
798
|
+
sync();
|
|
799
|
+
drawn(b); // reads the decorations before the microtask
|
|
800
|
+
await tick();
|
|
801
|
+
await tick();
|
|
802
|
+
expect(b.dom.querySelector(".pylon-anchored-range")).toBeNull();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("app code retyping the field's text drops a followed range", async () => {
|
|
806
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
807
|
+
await tick();
|
|
808
|
+
type(a, "the quick fox");
|
|
809
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
810
|
+
caretAt(a, 14);
|
|
811
|
+
type(a, "!"); // a followed range
|
|
812
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
813
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
814
|
+
const value = t.toString();
|
|
815
|
+
t.delete(0, value.length);
|
|
816
|
+
t.insert(0, value);
|
|
817
|
+
docs[0].commit();
|
|
818
|
+
await tick();
|
|
819
|
+
await tick();
|
|
820
|
+
caretAt(a, 2);
|
|
821
|
+
expect(covered(a)).toBeNull();
|
|
822
|
+
expect(reanchored).toEqual([]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("an undo that rewrites text as it was keeps the range and gives new cursors", async () => {
|
|
826
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
827
|
+
await tick();
|
|
828
|
+
type(a, "the quick fox");
|
|
829
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
830
|
+
a.dispatch(a.state.tr.split(5));
|
|
831
|
+
a.dispatch(a.state.tr.join(6)); // the same undo step: the text is as it was
|
|
832
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
833
|
+
expect(covered(a)).toBe("quick");
|
|
834
|
+
undo(a.state, a.dispatch);
|
|
835
|
+
await tick();
|
|
836
|
+
await tick();
|
|
837
|
+
expect(a.state.doc.textContent).toBe("the quick fox");
|
|
838
|
+
expect(covered(a)).toBe("quick");
|
|
839
|
+
const [r] = resolveRanges(a.state, docs[0], containerId, [{ id: "t1", ...reanchored.at(-1)!.range }]);
|
|
840
|
+
expect(text(a, r.from, r.to)).toBe("quick");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("an in-place undo keeps the range through a selection change right after", async () => {
|
|
844
|
+
const { a, docs, containerId, reanchored } = setup();
|
|
845
|
+
await tick();
|
|
846
|
+
type(a, "the quick fox");
|
|
847
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
848
|
+
a.dispatch(a.state.tr.split(5));
|
|
849
|
+
a.dispatch(a.state.tr.join(6));
|
|
850
|
+
setAnchoredRanges(a, [{ id: "t1", ...encodeRange(a.state, docs[0], containerId, 5, 10) }]);
|
|
851
|
+
undo(a.state, a.dispatch);
|
|
852
|
+
caretAt(a, 2);
|
|
853
|
+
await tick();
|
|
854
|
+
await tick();
|
|
855
|
+
expect(covered(a)).toBe("quick");
|
|
856
|
+
expect(reanchored.length).toBe(1);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("an in-place undo after an outside retype does not keep the retyped range", async () => {
|
|
860
|
+
const { a, b, docs, containerId, sync, reanchored } = setup();
|
|
861
|
+
await tick();
|
|
862
|
+
type(b, "the quick fox");
|
|
863
|
+
b.dispatch(b.state.tr.split(14));
|
|
864
|
+
type(b, "two words");
|
|
865
|
+
sync();
|
|
866
|
+
await tick();
|
|
867
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
868
|
+
// B's undo step: split and join the second paragraph, leaving it as it was.
|
|
869
|
+
b.dispatch(b.state.tr.split(19));
|
|
870
|
+
b.dispatch(b.state.tr.join(20));
|
|
871
|
+
setAnchoredRanges(b, [{ id: "t1", ...encodeRange(b.state, docs[1], containerId, 5, 10) }]);
|
|
872
|
+
// A retypes the first paragraph as it was; B undoes before the refresh.
|
|
873
|
+
const root = docs[0].getContainerById(containerId) as LoroMap;
|
|
874
|
+
const t = (((root.get("children") as LoroList).get(0) as LoroMap).get("children") as LoroList).get(0) as LoroText;
|
|
875
|
+
const value = t.toString();
|
|
876
|
+
t.delete(0, value.length);
|
|
877
|
+
t.insert(0, value);
|
|
878
|
+
docs[0].commit();
|
|
879
|
+
sync();
|
|
880
|
+
undo(b.state, b.dispatch);
|
|
881
|
+
await tick();
|
|
882
|
+
await tick();
|
|
883
|
+
expect(covered(b)).toBeNull();
|
|
884
|
+
expect(reanchored).toEqual([]);
|
|
885
|
+
void a;
|
|
886
|
+
});
|