@pylonsync/loro 0.22.46 → 0.22.50
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 +3 -3
- package/src/prosemirror/ranges.test.ts +32 -1
- package/src/prosemirror/ranges.ts +67 -10
- package/src/prosemirror/sync.test.ts +110 -4
- package/src/prosemirror/sync.ts +77 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.50",
|
|
4
4
|
"description": "Pylon's local-first React layer — wraps loro-crdt with a registry, useLoroDoc hook, and the binary WS wire-format decoder that mirrors crates/router/encode_crdt_frame.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@pylonsync/react": "0.22.
|
|
27
|
-
"@pylonsync/sync": "0.22.
|
|
26
|
+
"@pylonsync/react": "0.22.50",
|
|
27
|
+
"@pylonsync/sync": "0.22.50",
|
|
28
28
|
"loro-crdt": "^1.10.6"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
@@ -8,8 +8,9 @@ import { Schema } from "prosemirror-model";
|
|
|
8
8
|
import { schema as basic } from "prosemirror-schema-basic";
|
|
9
9
|
import { EditorState, TextSelection } from "prosemirror-state";
|
|
10
10
|
import { EditorView } from "prosemirror-view";
|
|
11
|
-
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
11
|
+
import { configureProseMirrorMarks, findProseMirrorContainer, readProseMirror, writeProseMirror } from "./index";
|
|
12
12
|
import {
|
|
13
|
+
resolveRangesInDoc,
|
|
13
14
|
diffMatches,
|
|
14
15
|
encodeRange,
|
|
15
16
|
mapSpans,
|
|
@@ -884,3 +885,33 @@ test("an in-place undo after an outside retype does not keep the retyped range",
|
|
|
884
885
|
expect(reanchored).toEqual([]);
|
|
885
886
|
void a;
|
|
886
887
|
});
|
|
888
|
+
|
|
889
|
+
// A server function has no editor: it reads the document from the doc.
|
|
890
|
+
test("resolveRangesInDoc finds a range in the doc as it is now, or says it is gone", () => {
|
|
891
|
+
const doc = new LoroDoc();
|
|
892
|
+
const body = doc.getMap("row").setContainer("body", new LoroMap());
|
|
893
|
+
body.setContainer("attributes", new LoroMap());
|
|
894
|
+
body.setContainer("children", new LoroList());
|
|
895
|
+
doc.commit();
|
|
896
|
+
configureProseMirrorMarks(doc, schema);
|
|
897
|
+
const p = (t: string) => schema.node("paragraph", null, [schema.text(t)]);
|
|
898
|
+
writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Hello world"), p("Second")]));
|
|
899
|
+
doc.commit();
|
|
900
|
+
const containerId = findProseMirrorContainer(doc, "body")!;
|
|
901
|
+
const state = EditorState.create({ schema, doc: readProseMirror(doc, "body", schema)! });
|
|
902
|
+
// "world": after "Hello " in the first paragraph (position 1 is its start).
|
|
903
|
+
const anchor = encodeRange(state, doc, containerId, 7, 12);
|
|
904
|
+
const text = (r: { from: number; to: number }) => readProseMirror(doc, "body", schema)!.textBetween(r.from, r.to);
|
|
905
|
+
|
|
906
|
+
writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Oh, hello world"), p("Second")]));
|
|
907
|
+
doc.commit();
|
|
908
|
+
const [moved] = resolveRangesInDoc(doc, "body", schema, [{ id: "a", ...anchor }]);
|
|
909
|
+
expect(moved!.gone).toBe(false);
|
|
910
|
+
expect(text(moved!)).toBe("world");
|
|
911
|
+
|
|
912
|
+
writeProseMirror(doc, "body", schema, schema.node("doc", null, [p("Oh, hello"), p("Second")]));
|
|
913
|
+
doc.commit();
|
|
914
|
+
expect(resolveRangesInDoc(doc, "body", schema, [{ id: "a", ...anchor }])[0]!.gone).toBe(true);
|
|
915
|
+
// A doc with no document in the field.
|
|
916
|
+
expect(resolveRangesInDoc(new LoroDoc(), "body", schema, [{ id: "a", ...anchor }])[0]!.gone).toBe(true);
|
|
917
|
+
});
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* // For a margin: where each range is now, or `gone`.
|
|
18
18
|
* resolveRanges(view.state, doc, containerId, ranges)
|
|
19
19
|
*
|
|
20
|
+
* // In a server function, with no editor: positions in the document
|
|
21
|
+
* // readProseMirror (or editProseMirror's state) gives for the doc now.
|
|
22
|
+
* resolveRangesInDoc(doc, "body", schema, ranges)
|
|
23
|
+
*
|
|
20
24
|
* The binding writes some edits as a delete and an insert: splitting or
|
|
21
25
|
* joining paragraphs moves text into new characters, and a cursor on a moved
|
|
22
26
|
* character no longer names it. The editor that makes such an edit still
|
|
@@ -26,14 +30,18 @@
|
|
|
26
30
|
* again. Until then, other editors draw a range only where its cursors find
|
|
27
31
|
* it, so never on characters that replaced its own.
|
|
28
32
|
*/
|
|
29
|
-
import type { ContainerID, LoroDoc } from "loro-crdt";
|
|
30
|
-
import { loroSyncPluginKey } from "loro-prosemirror";
|
|
31
|
-
import type { Node } from "prosemirror-model";
|
|
33
|
+
import type { ContainerID, LoroDoc, LoroMap } from "loro-crdt";
|
|
34
|
+
import { createNodeFromLoroObj, loroSyncPluginKey } from "loro-prosemirror";
|
|
35
|
+
import type { Node, Schema } from "prosemirror-model";
|
|
32
36
|
import { type EditorState, Plugin, PluginKey, type Transaction } from "prosemirror-state";
|
|
33
37
|
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
|
|
34
38
|
import { decodeCursor, encodeCursor } from "./cursors";
|
|
39
|
+
import { findProseMirrorContainer } from "./index";
|
|
35
40
|
import { cursorToPosition, positionToCursor } from "./sync";
|
|
36
41
|
|
|
42
|
+
/** Loro containers to the ProseMirror nodes made from them (loro-prosemirror's mapping). */
|
|
43
|
+
type Mapping = Map<ContainerID, Node | Node[]>;
|
|
44
|
+
|
|
37
45
|
/** A range as encoded cursors (see {@link encodeRange}). */
|
|
38
46
|
export interface EncodedRange {
|
|
39
47
|
from: string | null;
|
|
@@ -116,22 +124,38 @@ export function encodeRange(
|
|
|
116
124
|
return { from: start ? encodeCursor(start) : null, to: end ? encodeCursor(end) : null };
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
/** The mapping of an editor bound with ProseMirrorSyncPlugin, or null without one. */
|
|
128
|
+
function syncMapping(state: EditorState): Mapping | null {
|
|
129
|
+
const sync = loroSyncPluginKey.getState(state) as { mapping?: Mapping } | undefined;
|
|
130
|
+
return sync?.mapping ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
/** Where a stored cursor is now, and whether its character was deleted. */
|
|
120
134
|
function locate(
|
|
121
135
|
encoded: string | null | undefined,
|
|
122
136
|
state: EditorState,
|
|
123
137
|
doc: LoroDoc,
|
|
124
138
|
containerId: ContainerID,
|
|
139
|
+
): { pos: number; moved: boolean } | null {
|
|
140
|
+
const mapping = syncMapping(state);
|
|
141
|
+
return mapping ? locateIn(encoded, state.doc, mapping, doc, containerId) : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function locateIn(
|
|
145
|
+
encoded: string | null | undefined,
|
|
146
|
+
root: Node,
|
|
147
|
+
mapping: Mapping,
|
|
148
|
+
doc: LoroDoc,
|
|
149
|
+
containerId: ContainerID,
|
|
125
150
|
): { pos: number; moved: boolean } | null {
|
|
126
151
|
if (!encoded) return null;
|
|
127
|
-
const sync = loroSyncPluginKey.getState(state) as { mapping?: Map<ContainerID, Node | Node[]> } | undefined;
|
|
128
152
|
const cursor = decodeCursor(encoded);
|
|
129
|
-
if (!
|
|
130
|
-
const inDoc = (pos: number | null) => pos != null && pos >= 0 && pos <=
|
|
153
|
+
if (!cursor) return null;
|
|
154
|
+
const inDoc = (pos: number | null) => pos != null && pos >= 0 && pos <= root.content.size;
|
|
131
155
|
try {
|
|
132
|
-
const exact = cursorToPosition(cursor, doc, containerId,
|
|
156
|
+
const exact = cursorToPosition(cursor, doc, containerId, mapping);
|
|
133
157
|
if (inDoc(exact)) return { pos: exact!, moved: false };
|
|
134
|
-
const near = cursorToPosition(cursor, doc, containerId,
|
|
158
|
+
const near = cursorToPosition(cursor, doc, containerId, mapping, { acceptMoved: true });
|
|
135
159
|
return inDoc(near) ? { pos: near!, moved: true } : null;
|
|
136
160
|
} catch {
|
|
137
161
|
return null;
|
|
@@ -159,10 +183,43 @@ export function resolveRanges(
|
|
|
159
183
|
doc: LoroDoc,
|
|
160
184
|
containerId: ContainerID,
|
|
161
185
|
ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
|
|
186
|
+
): ResolvedRange[] {
|
|
187
|
+
const mapping = syncMapping(state);
|
|
188
|
+
return resolveWith(state.doc, mapping, doc, containerId, ranges);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* {@link resolveRanges} for code with no editor, such as a server function:
|
|
193
|
+
* the positions are in `field`'s document as the doc holds it now, the
|
|
194
|
+
* document `readProseMirror` returns and `editProseMirror` hands its
|
|
195
|
+
* `change`. Every range is `gone` when the doc has no document in `field`.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveRangesInDoc(
|
|
198
|
+
doc: LoroDoc,
|
|
199
|
+
field: string,
|
|
200
|
+
schema: Schema,
|
|
201
|
+
ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
|
|
202
|
+
): ResolvedRange[] {
|
|
203
|
+
const containerId = findProseMirrorContainer(doc, field);
|
|
204
|
+
const map = containerId ? (doc.getContainerById(containerId) as LoroMap | undefined) : undefined;
|
|
205
|
+
if (!containerId || !map || map.get("nodeName") == null) {
|
|
206
|
+
return ranges.map((r) => ({ id: r.id, from: 0, to: 0, gone: true }));
|
|
207
|
+
}
|
|
208
|
+
const mapping: Mapping = new Map();
|
|
209
|
+
const root = createNodeFromLoroObj(schema, map as never, mapping as never) as Node;
|
|
210
|
+
return resolveWith(root, mapping, doc, containerId, ranges);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolveWith(
|
|
214
|
+
root: Node,
|
|
215
|
+
mapping: Mapping | null,
|
|
216
|
+
doc: LoroDoc,
|
|
217
|
+
containerId: ContainerID,
|
|
218
|
+
ranges: Array<Pick<AnchoredRange, "id" | "from" | "to">>,
|
|
162
219
|
): ResolvedRange[] {
|
|
163
220
|
return ranges.map((r) => {
|
|
164
|
-
const from =
|
|
165
|
-
const to =
|
|
221
|
+
const from = mapping ? locateIn(r.from, root, mapping, doc, containerId) : null;
|
|
222
|
+
const to = mapping ? locateIn(r.to, root, mapping, doc, containerId) : null;
|
|
166
223
|
const at = from?.pos ?? to?.pos ?? 0;
|
|
167
224
|
if (!from || !to || (from.moved && to.moved) || to.pos <= from.pos) return { id: r.id, from: at, to: at, gone: true };
|
|
168
225
|
return { id: r.id, from: from.pos, to: to.pos, gone: false };
|
|
@@ -7,11 +7,11 @@ if (!globalThis.document) GlobalRegistrator.register();
|
|
|
7
7
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { LoroDoc, LoroList, LoroMap, type ContainerID } from "loro-crdt";
|
|
9
9
|
import { undo } from "loro-prosemirror";
|
|
10
|
-
import { Schema } from "prosemirror-model";
|
|
10
|
+
import { type Node, Schema } from "prosemirror-model";
|
|
11
11
|
import { schema as basic } from "prosemirror-schema-basic";
|
|
12
|
-
import { EditorState, TextSelection } from "prosemirror-state";
|
|
12
|
+
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
|
13
13
|
import { EditorView } from "prosemirror-view";
|
|
14
|
-
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
14
|
+
import { configureProseMirrorMarks, findProseMirrorContainer, writeProseMirror } from "./index";
|
|
15
15
|
import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
|
|
16
16
|
|
|
17
17
|
const defaultSchema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
@@ -312,7 +312,6 @@ describe("ProseMirrorSyncPlugin review fixes", () => {
|
|
|
312
312
|
type(a, "pic ");
|
|
313
313
|
a.dispatch(a.state.tr.insert(5, defaultSchema.node("image", { src: "x.png" })));
|
|
314
314
|
sync();
|
|
315
|
-
const { NodeSelection } = await import("prosemirror-state");
|
|
316
315
|
b.dispatch(b.state.tr.setSelection(NodeSelection.create(b.state.doc, 5)));
|
|
317
316
|
caretAt(a, 1);
|
|
318
317
|
type(a, "A ");
|
|
@@ -321,3 +320,110 @@ describe("ProseMirrorSyncPlugin review fixes", () => {
|
|
|
321
320
|
expect((b.state.selection as InstanceType<typeof NodeSelection>).node.type.name).toBe("image");
|
|
322
321
|
});
|
|
323
322
|
});
|
|
323
|
+
|
|
324
|
+
describe("ProseMirrorSyncPlugin with nested nodes", () => {
|
|
325
|
+
// A table's rows and cells, with prosemirror-tables' content rules.
|
|
326
|
+
const tableSchema = new Schema({
|
|
327
|
+
nodes: {
|
|
328
|
+
doc: { content: "block+" },
|
|
329
|
+
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
|
330
|
+
table: { group: "block", content: "row+", toDOM: () => ["table", ["tbody", 0]] },
|
|
331
|
+
row: { content: "cell*", toDOM: () => ["tr", 0] },
|
|
332
|
+
cell: { content: "block+", isolating: true, toDOM: () => ["td", 0] },
|
|
333
|
+
text: { group: "inline" },
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
const p = (t: string) => tableSchema.node("paragraph", null, t ? [tableSchema.text(t)] : []);
|
|
337
|
+
const table = (rows: string[][]) =>
|
|
338
|
+
tableSchema.node(
|
|
339
|
+
"table",
|
|
340
|
+
null,
|
|
341
|
+
rows.map((r) => tableSchema.node("row", null, r.map((c) => tableSchema.node("cell", null, [p(c)])))),
|
|
342
|
+
);
|
|
343
|
+
const doc = (rows: string[][]) => tableSchema.node("doc", null, [p("Owners"), table(rows)]);
|
|
344
|
+
|
|
345
|
+
// A model streaming a table: each write is the whole document again. Here
|
|
346
|
+
// the next write fills the first cell and the second. The smallest
|
|
347
|
+
// replace runs from inside one cell into the next, and ProseMirror does
|
|
348
|
+
// not join across isolating cells: the row got a fourth cell.
|
|
349
|
+
test("a document rewritten through a table reaches the editor exactly", async () => {
|
|
350
|
+
const { a, docs } = setup({ schema: tableSchema });
|
|
351
|
+
await tick();
|
|
352
|
+
const steps = [
|
|
353
|
+
doc([["Area", "Owner", "Due"], ["Rele", "", ""]]),
|
|
354
|
+
doc([["Area", "Owner", "Due"], ["Release notes", "Ben", ""]]),
|
|
355
|
+
doc([["Area", "Owner", "Due"], ["Release notes", "Ben", "Oct 25"], ["Sup", "", ""]]),
|
|
356
|
+
doc([["Area", "Owner", "Due"], ["Release notes", "Ben", "Oct 25"], ["Support inbox", "TBD", "Oct 26"]]),
|
|
357
|
+
];
|
|
358
|
+
for (const next of steps) {
|
|
359
|
+
writeProseMirror(docs[1]!, "body", tableSchema, next);
|
|
360
|
+
docs[1]!.commit();
|
|
361
|
+
docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
|
|
362
|
+
await tick();
|
|
363
|
+
expect(a.state.doc.toJSON()).toEqual(next.toJSON());
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a selected cell the change does not touch stays selected", async () => {
|
|
368
|
+
const { a, docs } = setup({ schema: tableSchema });
|
|
369
|
+
await tick();
|
|
370
|
+
const write = async (next: Node) => {
|
|
371
|
+
writeProseMirror(docs[1]!, "body", tableSchema, next);
|
|
372
|
+
docs[1]!.commit();
|
|
373
|
+
docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
|
|
374
|
+
await tick();
|
|
375
|
+
};
|
|
376
|
+
await write(doc([["Area", "Owner", "Due"], ["Rele", "", ""]]));
|
|
377
|
+
let areaCell = -1;
|
|
378
|
+
a.state.doc.descendants((node, pos) => {
|
|
379
|
+
if (areaCell < 0 && node.type.name === "cell") areaCell = pos;
|
|
380
|
+
});
|
|
381
|
+
a.dispatch(a.state.tr.setSelection(NodeSelection.create(a.state.doc, areaCell)));
|
|
382
|
+
await write(doc([["Area", "Owner", "Due"], ["Release notes", "Ben", ""]]));
|
|
383
|
+
expect(a.state.selection).toBeInstanceOf(NodeSelection);
|
|
384
|
+
expect(a.state.selection.from).toBe(areaCell);
|
|
385
|
+
expect((a.state.selection as InstanceType<typeof NodeSelection>).node.textContent).toBe("Area");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("a selected paragraph keeps its selection when the cells around it change", async () => {
|
|
389
|
+
const { a, docs } = setup({ schema: tableSchema });
|
|
390
|
+
await tick();
|
|
391
|
+
const cells = (first: string, second: string) =>
|
|
392
|
+
tableSchema.node("doc", null, [
|
|
393
|
+
tableSchema.node("table", null, [
|
|
394
|
+
tableSchema.node("row", null, [
|
|
395
|
+
tableSchema.node("cell", null, [p("Keep"), p(first)]),
|
|
396
|
+
tableSchema.node("cell", null, [p(second)]),
|
|
397
|
+
]),
|
|
398
|
+
]),
|
|
399
|
+
]);
|
|
400
|
+
const write = async (next: Node) => {
|
|
401
|
+
writeProseMirror(docs[1]!, "body", tableSchema, next);
|
|
402
|
+
docs[1]!.commit();
|
|
403
|
+
docs[0]!.import(docs[1]!.export({ mode: "update", from: docs[0]!.oplogVersion() }));
|
|
404
|
+
await tick();
|
|
405
|
+
};
|
|
406
|
+
await write(cells("Rele", ""));
|
|
407
|
+
// doc > table > row > cell > the "Keep" paragraph.
|
|
408
|
+
a.dispatch(a.state.tr.setSelection(NodeSelection.create(a.state.doc, 3)));
|
|
409
|
+
await write(cells("Release notes", "Ben"));
|
|
410
|
+
expect(a.state.doc.toJSON()).toEqual(cells("Release notes", "Ben").toJSON());
|
|
411
|
+
expect(a.state.selection).toBeInstanceOf(NodeSelection);
|
|
412
|
+
expect((a.state.selection as InstanceType<typeof NodeSelection>).node.textContent).toBe("Keep");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("typing in a paragraph still maps the selection, not the whole block", async () => {
|
|
416
|
+
const { a, b, sync } = setup({ schema: tableSchema });
|
|
417
|
+
await tick();
|
|
418
|
+
b.dispatch(b.state.tr.replaceWith(0, b.state.doc.content.size, doc([["x", "y", "z"]]).content));
|
|
419
|
+
sync();
|
|
420
|
+
await tick();
|
|
421
|
+
// Ada's caret after "Own".
|
|
422
|
+
a.dispatch(a.state.tr.setSelection(TextSelection.create(a.state.doc, 4)));
|
|
423
|
+
b.dispatch(b.state.tr.insertText("The ", 1));
|
|
424
|
+
sync();
|
|
425
|
+
await tick();
|
|
426
|
+
expect(a.state.doc.child(0).textContent).toBe("The Owners");
|
|
427
|
+
expect(a.state.selection.from).toBe(8);
|
|
428
|
+
});
|
|
429
|
+
});
|
package/src/prosemirror/sync.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
loroUndoPluginKey,
|
|
39
39
|
updateLoroToPmState,
|
|
40
40
|
} from "loro-prosemirror";
|
|
41
|
-
import type { Node } from "prosemirror-model";
|
|
41
|
+
import type { Fragment, Node } from "prosemirror-model";
|
|
42
42
|
import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state";
|
|
43
43
|
import type { EditorView } from "prosemirror-view";
|
|
44
44
|
|
|
@@ -265,20 +265,7 @@ function applyRemote(
|
|
|
265
265
|
const next = isEmpty(root)
|
|
266
266
|
? view.state.schema.topNodeType.createAndFill()!
|
|
267
267
|
: (createNodeFromLoroObj(view.state.schema, root as never, state.mapping as never) as Node);
|
|
268
|
-
|
|
269
|
-
const start = current.content.findDiffStart(next.content);
|
|
270
|
-
let tr = view.state.tr;
|
|
271
|
-
if (start != null) {
|
|
272
|
-
let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
|
|
273
|
-
// A run of equal content at both ends can make the two ends cross.
|
|
274
|
-
const overlap = start - Math.min(endA, endB);
|
|
275
|
-
if (overlap > 0) {
|
|
276
|
-
endA += overlap;
|
|
277
|
-
endB += overlap;
|
|
278
|
-
}
|
|
279
|
-
tr = tr.replace(start, endA, next.slice(start, endB));
|
|
280
|
-
}
|
|
281
|
-
tr = withTopAttrs(tr, next);
|
|
268
|
+
let tr = withTopAttrs(replaceToMatch(view.state, next), next);
|
|
282
269
|
// Nothing this editor shows changed (an echo, a change elsewhere in the row).
|
|
283
270
|
if (!tr.docChanged) return;
|
|
284
271
|
// A node selection, a cell selection: ProseMirror maps it through the
|
|
@@ -312,6 +299,81 @@ function applyRemote(
|
|
|
312
299
|
view.dispatch(tr);
|
|
313
300
|
}
|
|
314
301
|
|
|
302
|
+
/**
|
|
303
|
+
* A transaction that turns the editor's document into `next`'s content,
|
|
304
|
+
* changing as little as it can so the selection maps through.
|
|
305
|
+
*/
|
|
306
|
+
function replaceToMatch(state: EditorState, next: Node): Transaction {
|
|
307
|
+
const current = state.doc;
|
|
308
|
+
const start = current.content.findDiffStart(next.content);
|
|
309
|
+
if (start == null) return state.tr;
|
|
310
|
+
let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
|
|
311
|
+
// A run of equal content at both ends can make the two ends cross.
|
|
312
|
+
const overlap = start - Math.min(endA, endB);
|
|
313
|
+
if (overlap > 0) {
|
|
314
|
+
endA += overlap;
|
|
315
|
+
endB += overlap;
|
|
316
|
+
}
|
|
317
|
+
const fine = state.tr.replace(start, endA, next.slice(start, endB));
|
|
318
|
+
if (fine.doc.content.eq(next.content)) return fine;
|
|
319
|
+
// ProseMirror fits a slice into the place it goes, and it does not join
|
|
320
|
+
// across isolating nodes (a table's cells): the fitted result can differ
|
|
321
|
+
// from `next`. Replace whole nodes instead, as deep as the change allows,
|
|
322
|
+
// so positions in the nodes around it still map.
|
|
323
|
+
const exact = replaceChangedNodes(state, next);
|
|
324
|
+
if (exact.doc.content.eq(next.content)) return exact;
|
|
325
|
+
return state.tr.replaceWith(0, current.content.size, next.content);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Replace the nodes that differ between the editor's document and `next`,
|
|
330
|
+
* looking inside each one that keeps its type and attributes, so nodes
|
|
331
|
+
* that did not change keep their positions (and a selection on them).
|
|
332
|
+
*/
|
|
333
|
+
function replaceChangedNodes(state: EditorState, next: Node): Transaction {
|
|
334
|
+
const tr = state.tr;
|
|
335
|
+
replaceChangedChildren(tr, state.doc.content, next.content, 0);
|
|
336
|
+
return tr;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Turn fragment `a`, which starts at `offset` in `tr.doc`, into `b`. Works
|
|
341
|
+
* from the end, so positions before each change still hold.
|
|
342
|
+
*/
|
|
343
|
+
function replaceChangedChildren(tr: Transaction, a: Fragment, b: Fragment, offset: number): void {
|
|
344
|
+
let first = 0;
|
|
345
|
+
while (first < a.childCount && first < b.childCount && a.child(first).eq(b.child(first))) first++;
|
|
346
|
+
let endA = a.childCount;
|
|
347
|
+
let endB = b.childCount;
|
|
348
|
+
while (endA > first && endB > first && a.child(endA - 1).eq(b.child(endB - 1))) {
|
|
349
|
+
endA--;
|
|
350
|
+
endB--;
|
|
351
|
+
}
|
|
352
|
+
let from = offset;
|
|
353
|
+
for (let i = 0; i < first; i++) from += a.child(i).nodeSize;
|
|
354
|
+
let to = from;
|
|
355
|
+
for (let i = first; i < endA; i++) to += a.child(i).nodeSize;
|
|
356
|
+
// Children that pair up one to one: each changed one on its own. (The
|
|
357
|
+
// caller checks the result; a step ProseMirror has to fit falls back.)
|
|
358
|
+
if (endA - first === endB - first) {
|
|
359
|
+
let end = to;
|
|
360
|
+
for (let i = endA - 1; i >= first; i--) {
|
|
361
|
+
const x = a.child(i);
|
|
362
|
+
const y = b.child(i);
|
|
363
|
+
const start = end - x.nodeSize;
|
|
364
|
+
if (!x.eq(y)) {
|
|
365
|
+
if (!x.isLeaf && x.sameMarkup(y)) replaceChangedChildren(tr, x.content, y.content, start + 1);
|
|
366
|
+
else tr.replaceWith(start, end, y);
|
|
367
|
+
}
|
|
368
|
+
end = start;
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const nodes: Node[] = [];
|
|
373
|
+
for (let i = first; i < endB; i++) nodes.push(b.child(i));
|
|
374
|
+
tr.replaceWith(from, to, nodes);
|
|
375
|
+
}
|
|
376
|
+
|
|
315
377
|
/**
|
|
316
378
|
* The selection as Loro cursors, bound to the character on the left of each
|
|
317
379
|
* end. Null ends where the doc has no text to bind to.
|