@pylonsync/loro 0.22.39 → 0.22.40
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 +13 -6
- package/src/prosemirror/index.ts +1 -1
- package/src/prosemirror/react.ts +1 -1
- package/src/prosemirror/sync.test.ts +193 -0
- package/src/prosemirror/sync.ts +402 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.40",
|
|
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"
|
|
@@ -13,21 +13,23 @@
|
|
|
13
13
|
"./wire": "./src/wire.ts",
|
|
14
14
|
"./registry": "./src/registry.ts",
|
|
15
15
|
"./prosemirror": "./src/prosemirror/index.ts",
|
|
16
|
-
"./prosemirror/react": "./src/prosemirror/react.ts"
|
|
16
|
+
"./prosemirror/react": "./src/prosemirror/react.ts",
|
|
17
|
+
"./prosemirror/sync": "./src/prosemirror/sync.ts"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@pylonsync/react": "0.22.
|
|
23
|
-
"@pylonsync/sync": "0.22.
|
|
23
|
+
"@pylonsync/react": "0.22.40",
|
|
24
|
+
"@pylonsync/sync": "0.22.40",
|
|
24
25
|
"loro-crdt": "^1.10.6"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"react": ">=19.0.0",
|
|
28
29
|
"loro-prosemirror": ">=0.4.4 <1.0.0",
|
|
29
30
|
"prosemirror-model": "^1.22.0",
|
|
30
|
-
"prosemirror-state": "^1.4.0"
|
|
31
|
+
"prosemirror-state": "^1.4.0",
|
|
32
|
+
"prosemirror-view": "^1.33.0"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
33
35
|
"@types/react": "^19.0.0",
|
|
@@ -35,7 +37,9 @@
|
|
|
35
37
|
"loro-prosemirror": "^0.4.4",
|
|
36
38
|
"prosemirror-model": "^1.25.0",
|
|
37
39
|
"prosemirror-state": "^1.4.3",
|
|
38
|
-
"prosemirror-schema-basic": "^1.2.4"
|
|
40
|
+
"prosemirror-schema-basic": "^1.2.4",
|
|
41
|
+
"prosemirror-view": "^1.42.3",
|
|
42
|
+
"@happy-dom/global-registrator": "^20.10.0"
|
|
39
43
|
},
|
|
40
44
|
"peerDependenciesMeta": {
|
|
41
45
|
"loro-prosemirror": {
|
|
@@ -46,6 +50,9 @@
|
|
|
46
50
|
},
|
|
47
51
|
"prosemirror-state": {
|
|
48
52
|
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"prosemirror-view": {
|
|
55
|
+
"optional": true
|
|
49
56
|
}
|
|
50
57
|
},
|
|
51
58
|
"license": "MIT"
|
package/src/prosemirror/index.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* const doc = useLoroDoc("Doc", id);
|
|
16
16
|
* configureProseMirrorMarks(doc, schema);
|
|
17
17
|
* const containerId = await waitForProseMirrorContainer(doc, "body");
|
|
18
|
-
* const plugin =
|
|
18
|
+
* const plugin = ProseMirrorSyncPlugin({ doc, containerId }); // ./sync
|
|
19
19
|
*
|
|
20
20
|
* In a server function, read and change the document without an editor
|
|
21
21
|
* with `readProseMirror` and `writeProseMirror`.
|
package/src/prosemirror/react.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
|
|
5
5
|
* if (!containerId) return <Loading />;
|
|
6
|
-
* const plugins = [
|
|
6
|
+
* const plugins = [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })];
|
|
7
7
|
*
|
|
8
8
|
* `containerId` is null until the server's snapshot brings the field's
|
|
9
9
|
* container; mount the editor after that (see ./index.ts for why a client
|
|
@@ -0,0 +1,193 @@
|
|
|
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 schema = 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
|
+
/** A server doc with the field's container, and two clients synced to it. */
|
|
26
|
+
function setup() {
|
|
27
|
+
// The empty document the server makes on insert (crates/crdt prosemirror.rs).
|
|
28
|
+
const server = new LoroDoc();
|
|
29
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
30
|
+
body.set("nodeName", "doc");
|
|
31
|
+
body.setContainer("attributes", new LoroMap());
|
|
32
|
+
body.setContainer("children", new LoroList());
|
|
33
|
+
server.commit();
|
|
34
|
+
const docs = [new LoroDoc(), new LoroDoc()];
|
|
35
|
+
for (const d of docs) {
|
|
36
|
+
d.import(server.export({ mode: "snapshot" }));
|
|
37
|
+
configureProseMirrorMarks(d, schema);
|
|
38
|
+
}
|
|
39
|
+
const containerId = findProseMirrorContainer(docs[0], "body") as ContainerID;
|
|
40
|
+
const make = (doc: LoroDoc) => {
|
|
41
|
+
const view = new EditorView(document.createElement("div"), {
|
|
42
|
+
state: EditorState.create({
|
|
43
|
+
schema,
|
|
44
|
+
plugins: [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })],
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
views.push(view);
|
|
48
|
+
return view;
|
|
49
|
+
};
|
|
50
|
+
const [a, b] = [make(docs[0]), make(docs[1])];
|
|
51
|
+
const sync = () => {
|
|
52
|
+
for (const from of docs) {
|
|
53
|
+
const bytes = from.export({ mode: "update", from: server.oplogVersion() });
|
|
54
|
+
server.import(bytes);
|
|
55
|
+
}
|
|
56
|
+
const all = server.export({ mode: "snapshot" });
|
|
57
|
+
for (const to of docs) to.import(all);
|
|
58
|
+
};
|
|
59
|
+
return { a, b, docs, sync };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const text = (v: EditorView) => v.state.doc.textBetween(0, v.state.doc.content.size, "\n");
|
|
63
|
+
const type = (v: EditorView, s: string) => {
|
|
64
|
+
for (const ch of s) v.dispatch(v.state.tr.insertText(ch));
|
|
65
|
+
};
|
|
66
|
+
const caretAt = (v: EditorView, pos: number) =>
|
|
67
|
+
v.dispatch(v.state.tr.setSelection(TextSelection.create(v.state.doc, pos)));
|
|
68
|
+
|
|
69
|
+
describe("ProseMirrorSyncPlugin", () => {
|
|
70
|
+
test("typing in one editor reaches the other", async () => {
|
|
71
|
+
const { a, b, sync } = setup();
|
|
72
|
+
await tick();
|
|
73
|
+
type(a, "Hello");
|
|
74
|
+
sync();
|
|
75
|
+
expect(text(b)).toBe("Hello");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a remote edit before the caret keeps the caret on its words", async () => {
|
|
79
|
+
const { a, b, sync } = setup();
|
|
80
|
+
await tick();
|
|
81
|
+
type(a, "world");
|
|
82
|
+
sync();
|
|
83
|
+
caretAt(b, 6); // after "world"
|
|
84
|
+
caretAt(a, 1);
|
|
85
|
+
type(a, "hello ");
|
|
86
|
+
sync();
|
|
87
|
+
expect(text(b)).toBe("hello world");
|
|
88
|
+
expect(b.state.selection.head).toBe(12);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("typing right after a remote change goes at the caret, not the end", async () => {
|
|
92
|
+
const { a, b, sync } = setup();
|
|
93
|
+
await tick();
|
|
94
|
+
type(a, "one three");
|
|
95
|
+
sync();
|
|
96
|
+
caretAt(b, 4); // after "one"
|
|
97
|
+
caretAt(a, 10);
|
|
98
|
+
type(a, " four");
|
|
99
|
+
sync();
|
|
100
|
+
type(b, " two"); // no timer in between
|
|
101
|
+
sync();
|
|
102
|
+
expect(text(a)).toBe("one two three four");
|
|
103
|
+
expect(text(b)).toBe("one two three four");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("two people typing at the same spot keep their words whole", async () => {
|
|
107
|
+
const { a, b, sync } = setup();
|
|
108
|
+
await tick();
|
|
109
|
+
type(a, "Hi");
|
|
110
|
+
sync();
|
|
111
|
+
caretAt(a, 3);
|
|
112
|
+
caretAt(b, 3);
|
|
113
|
+
const left = " [from a]";
|
|
114
|
+
const right = " [from b]";
|
|
115
|
+
for (let i = 0; i < left.length; i++) {
|
|
116
|
+
type(a, left[i]);
|
|
117
|
+
type(b, right[i]);
|
|
118
|
+
sync();
|
|
119
|
+
}
|
|
120
|
+
expect(text(a)).toBe(text(b));
|
|
121
|
+
expect([`Hi${left}${right}`, `Hi${right}${left}`]).toContain(text(a));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a paragraph split elsewhere does not move the caret", async () => {
|
|
125
|
+
const { a, b, sync } = setup();
|
|
126
|
+
await tick();
|
|
127
|
+
type(a, "first second");
|
|
128
|
+
sync();
|
|
129
|
+
caretAt(b, 13); // end of "second"
|
|
130
|
+
caretAt(a, 6); // after "first"
|
|
131
|
+
a.dispatch(a.state.tr.split(6));
|
|
132
|
+
sync();
|
|
133
|
+
type(b, "!");
|
|
134
|
+
sync();
|
|
135
|
+
expect(text(a)).toBe("first\n second!");
|
|
136
|
+
expect(text(b)).toBe("first\n second!");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("undo takes back a burst of typing as one step", async () => {
|
|
140
|
+
const { a, sync } = setup();
|
|
141
|
+
await tick();
|
|
142
|
+
type(a, "kept");
|
|
143
|
+
sync();
|
|
144
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
145
|
+
type(a, " typed together");
|
|
146
|
+
undo(a.state, a.dispatch);
|
|
147
|
+
expect(text(a)).toBe("kept");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Loro does not merge an undo step across a remote change in the same
|
|
151
|
+
// container (loro-internal undo.rs, push_with_merge), so while two people
|
|
152
|
+
// type in one paragraph each undo takes back a smaller step. Each step is
|
|
153
|
+
// still only this person's.
|
|
154
|
+
test("undo while someone else types removes only this person's typing", async () => {
|
|
155
|
+
const { a, b, sync } = setup();
|
|
156
|
+
await tick();
|
|
157
|
+
type(a, "x");
|
|
158
|
+
sync();
|
|
159
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
160
|
+
caretAt(a, 2);
|
|
161
|
+
caretAt(b, 1);
|
|
162
|
+
for (const [ca, cb] of [["1", "a"], ["2", "b"], ["3", "c"], ["4", "d"]]) {
|
|
163
|
+
type(a, ca);
|
|
164
|
+
type(b, cb);
|
|
165
|
+
sync();
|
|
166
|
+
}
|
|
167
|
+
undo(b.state, b.dispatch);
|
|
168
|
+
sync();
|
|
169
|
+
expect(text(a)).toContain("1234");
|
|
170
|
+
expect(text(a)).not.toContain("d");
|
|
171
|
+
for (let i = 0; i < 4; i++) undo(b.state, b.dispatch);
|
|
172
|
+
sync();
|
|
173
|
+
expect(text(a)).toBe("x1234");
|
|
174
|
+
expect(text(b)).toBe("x1234");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("undo removes only this editor's edit", async () => {
|
|
178
|
+
const { a, b, sync } = setup();
|
|
179
|
+
await tick();
|
|
180
|
+
type(a, "base");
|
|
181
|
+
sync();
|
|
182
|
+
caretAt(b, 5);
|
|
183
|
+
type(b, " by b");
|
|
184
|
+
sync();
|
|
185
|
+
caretAt(a, 1);
|
|
186
|
+
type(a, "A:");
|
|
187
|
+
sync();
|
|
188
|
+
undo(b.state, b.dispatch);
|
|
189
|
+
sync();
|
|
190
|
+
expect(text(a)).toBe("A:base");
|
|
191
|
+
expect(text(b)).toBe("A:base");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProseMirrorSyncPlugin: binds a ProseMirror editor to a ProseMirror field
|
|
3
|
+
* of a row's Loro doc. A drop-in replacement for loro-prosemirror's
|
|
4
|
+
* LoroSyncPlugin (same plugin key and state, so LoroUndoPlugin works with
|
|
5
|
+
* it), with two fixes for people typing at the same time:
|
|
6
|
+
*
|
|
7
|
+
* - A remote change is applied as one replace of the range that changed,
|
|
8
|
+
* not of the whole document. ProseMirror maps the selection, decorations
|
|
9
|
+
* and plugin state through it.
|
|
10
|
+
* - The caret is kept as Loro cursors, taken after every editor update,
|
|
11
|
+
* when the editor and the doc agree. LoroSyncPlugin took them after the
|
|
12
|
+
* import had already changed the doc, replaced the whole document (which
|
|
13
|
+
* moves the caret to its end), and put the caret back in a setTimeout:
|
|
14
|
+
* text typed before that timer ran went to the end of the document, and
|
|
15
|
+
* two people typing at one spot interleaved.
|
|
16
|
+
*
|
|
17
|
+
* The caret binds to the character on its left. A remote insert at the
|
|
18
|
+
* caret's spot then lands after it, and the next character this person
|
|
19
|
+
* types continues their own run, which is how Loro orders concurrent runs.
|
|
20
|
+
*
|
|
21
|
+
* Ported in part from loro-prosemirror 0.4.4 (MIT, Loro contributors):
|
|
22
|
+
* the cursor-to-position walk and the change bookkeeping.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type ContainerID,
|
|
26
|
+
Cursor,
|
|
27
|
+
type LoroDoc,
|
|
28
|
+
LoroList,
|
|
29
|
+
LoroMap,
|
|
30
|
+
LoroText,
|
|
31
|
+
UndoManager,
|
|
32
|
+
} from "loro-crdt";
|
|
33
|
+
import {
|
|
34
|
+
CHILDREN_KEY,
|
|
35
|
+
createNodeFromLoroObj,
|
|
36
|
+
LoroUndoPlugin,
|
|
37
|
+
loroSyncPluginKey,
|
|
38
|
+
loroUndoPluginKey,
|
|
39
|
+
updateLoroToPmState,
|
|
40
|
+
} from "loro-prosemirror";
|
|
41
|
+
import type { Node } from "prosemirror-model";
|
|
42
|
+
import { Plugin, TextSelection, type EditorState } from "prosemirror-state";
|
|
43
|
+
import type { EditorView } from "prosemirror-view";
|
|
44
|
+
|
|
45
|
+
type Mapping = Map<ContainerID, Node | Node[]>;
|
|
46
|
+
|
|
47
|
+
interface SyncState {
|
|
48
|
+
doc: LoroDoc;
|
|
49
|
+
mapping: Mapping;
|
|
50
|
+
changedBy: "local" | "import" | "checkout";
|
|
51
|
+
containerId?: ContainerID;
|
|
52
|
+
snapshot?: unknown;
|
|
53
|
+
docSubscription?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Cursors {
|
|
57
|
+
anchor: Cursor | null;
|
|
58
|
+
head: Cursor | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ProseMirrorSyncPluginProps {
|
|
62
|
+
doc: LoroDoc;
|
|
63
|
+
/** The field's container, from useProseMirrorDoc or findProseMirrorContainer. */
|
|
64
|
+
containerId: ContainerID;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ProseMirrorSyncPlugin({ doc, containerId }: ProseMirrorSyncPluginProps): Plugin {
|
|
68
|
+
return new Plugin({
|
|
69
|
+
key: loroSyncPluginKey,
|
|
70
|
+
props: {
|
|
71
|
+
editable: (state) => (loroSyncPluginKey.getState(state) as SyncState | undefined)?.snapshot == null,
|
|
72
|
+
},
|
|
73
|
+
state: {
|
|
74
|
+
init: (): SyncState => ({ doc, mapping: new Map(), changedBy: "local", containerId }),
|
|
75
|
+
apply: (tr, state: SyncState, oldEditorState, newEditorState) => {
|
|
76
|
+
const meta = tr.getMeta(loroSyncPluginKey) as { type: string; state?: Partial<SyncState> } | undefined;
|
|
77
|
+
const undoState = loroUndoPluginKey.getState(oldEditorState) as
|
|
78
|
+
| { isUndoing?: { current: boolean } }
|
|
79
|
+
| undefined;
|
|
80
|
+
state.changedBy = meta?.type === "non-local-updates" ? "import" : "local";
|
|
81
|
+
switch (meta?.type) {
|
|
82
|
+
case "doc-changed":
|
|
83
|
+
if (!undoState?.isUndoing?.current) {
|
|
84
|
+
updateLoroToPmState(doc as never, state.mapping as never, newEditorState, containerId);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
case "update-state":
|
|
88
|
+
state = { ...state, ...meta.state };
|
|
89
|
+
state.doc.commit({ origin: "sys:init", timestamp: Date.now() });
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
return state;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
// After a remote change too, as LoroSyncPlugin does: the write is a no-op
|
|
96
|
+
// when the editor and the doc agree, and it points the mapping at the
|
|
97
|
+
// editor's own nodes again.
|
|
98
|
+
appendTransaction: (transactions, _old, newState) =>
|
|
99
|
+
transactions.some((tr) => tr.docChanged)
|
|
100
|
+
? newState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" })
|
|
101
|
+
: null,
|
|
102
|
+
view: (view) => {
|
|
103
|
+
let cursors: Cursors = { anchor: null, head: null };
|
|
104
|
+
let unsubscribe: (() => void) | null = null;
|
|
105
|
+
const remember = () => {
|
|
106
|
+
cursors = selectionToCursors(view.state, doc, containerId);
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
if (view.isDestroyed) return;
|
|
110
|
+
unsubscribe = doc.getContainerById(containerId)!.subscribe((event) => {
|
|
111
|
+
// Local edits came from this editor, so it already shows them.
|
|
112
|
+
// An undo is local to Loro but new to the editor.
|
|
113
|
+
if (event.by === "local" && event.origin !== "undo") return;
|
|
114
|
+
applyRemote(view, doc, containerId, event, cursors);
|
|
115
|
+
remember();
|
|
116
|
+
});
|
|
117
|
+
loadInitial(view, doc, containerId);
|
|
118
|
+
remember();
|
|
119
|
+
}, 0);
|
|
120
|
+
return {
|
|
121
|
+
update: () => {
|
|
122
|
+
const state = loroSyncPluginKey.getState(view.state) as SyncState | undefined;
|
|
123
|
+
// Only when the editor and the doc agree: after a local change the
|
|
124
|
+
// apply above wrote it to Loro synchronously.
|
|
125
|
+
if (state) remember();
|
|
126
|
+
},
|
|
127
|
+
destroy: () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
unsubscribe?.();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Edits closer together than this are one undo step. */
|
|
137
|
+
export const UNDO_MERGE_MS = 500;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Undo for a document bound with ProseMirrorSyncPlugin: LoroUndoPlugin with
|
|
141
|
+
* typing grouped into steps (Loro's default makes every character a step),
|
|
142
|
+
* and without the binding's first load, which is not this person's edit.
|
|
143
|
+
* It undoes only this person's edits.
|
|
144
|
+
*/
|
|
145
|
+
export function ProseMirrorUndoPlugin({ doc }: { doc: LoroDoc }): Plugin {
|
|
146
|
+
const undoManager = new UndoManager(doc, {
|
|
147
|
+
mergeInterval: UNDO_MERGE_MS,
|
|
148
|
+
excludeOriginPrefixes: ["sys:init"],
|
|
149
|
+
});
|
|
150
|
+
return LoroUndoPlugin({ doc: doc as never, undoManager: undoManager as never });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function field(doc: LoroDoc, containerId: ContainerID): LoroMap {
|
|
154
|
+
return doc.getContainerById(containerId) as LoroMap;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The browser's selection in the editor, when it differs from the editor
|
|
159
|
+
* state's. A click or an arrow key moves the DOM selection first, and the
|
|
160
|
+
* editor reads it a little later; a remote change in between would put the
|
|
161
|
+
* caret back where it was.
|
|
162
|
+
*/
|
|
163
|
+
function domSelectionIfMoved(view: EditorView): { anchor: number; head: number } | null {
|
|
164
|
+
if (!view.hasFocus()) return null;
|
|
165
|
+
const sel = (view.root as Document).getSelection?.();
|
|
166
|
+
if (!sel?.anchorNode || !sel.focusNode || !view.dom.contains(sel.anchorNode)) return null;
|
|
167
|
+
try {
|
|
168
|
+
const anchor = view.posAtDOM(sel.anchorNode, sel.anchorOffset);
|
|
169
|
+
const head = view.posAtDOM(sel.focusNode, sel.focusOffset);
|
|
170
|
+
const { selection } = view.state;
|
|
171
|
+
return anchor === selection.anchor && head === selection.head ? null : { anchor, head };
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True for a document with no top node or no children yet. */
|
|
178
|
+
function isEmpty(map: LoroMap): boolean {
|
|
179
|
+
if (map.get("nodeName") == null) return true;
|
|
180
|
+
const children = map.get(CHILDREN_KEY);
|
|
181
|
+
return !(children instanceof LoroList) || children.length === 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Fill the editor from the doc on mount (what LoroSyncPlugin's init does). */
|
|
185
|
+
function loadInitial(view: EditorView, doc: LoroDoc, containerId: ContainerID): void {
|
|
186
|
+
const mapping: Mapping = new Map();
|
|
187
|
+
const map = field(doc, containerId);
|
|
188
|
+
let tr = view.state.tr;
|
|
189
|
+
const node = isEmpty(map)
|
|
190
|
+
? null
|
|
191
|
+
: (createNodeFromLoroObj(view.state.schema, map as never, mapping as never) as Node);
|
|
192
|
+
// An empty document (the server's, before anyone typed) keeps the editor's
|
|
193
|
+
// starting content; the first edit writes it into the server's containers.
|
|
194
|
+
if (node && node.childCount > 0) {
|
|
195
|
+
tr = tr.replaceWith(0, view.state.doc.content.size, node.content);
|
|
196
|
+
}
|
|
197
|
+
tr.setMeta(loroSyncPluginKey, { type: "update-state", state: { mapping, snapshot: null } });
|
|
198
|
+
tr.setMeta("addToHistory", false);
|
|
199
|
+
view.dispatch(tr);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Drop cached nodes for every container an event touched, and their ancestors. */
|
|
203
|
+
function forgetChanged(doc: LoroDoc, event: { events: Array<{ target: ContainerID }> }, mapping: Mapping): void {
|
|
204
|
+
for (const e of event.events) {
|
|
205
|
+
let obj = doc.getContainerById(e.target) as { id: ContainerID; parent(): unknown } | undefined;
|
|
206
|
+
while (obj) {
|
|
207
|
+
mapping.delete(obj.id);
|
|
208
|
+
obj = obj.parent() as typeof obj;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyRemote(
|
|
214
|
+
view: EditorView,
|
|
215
|
+
doc: LoroDoc,
|
|
216
|
+
containerId: ContainerID,
|
|
217
|
+
event: { events: Array<{ target: ContainerID }> },
|
|
218
|
+
cursors: Cursors,
|
|
219
|
+
): void {
|
|
220
|
+
if (view.isDestroyed) return;
|
|
221
|
+
const state = loroSyncPluginKey.getState(view.state) as SyncState;
|
|
222
|
+
// Read before the replace: positions in the document as it is now.
|
|
223
|
+
const moved = domSelectionIfMoved(view);
|
|
224
|
+
forgetChanged(doc, event, state.mapping);
|
|
225
|
+
const root = field(doc, containerId);
|
|
226
|
+
// Someone emptied the document: show the schema's empty document.
|
|
227
|
+
const next = isEmpty(root)
|
|
228
|
+
? view.state.schema.topNodeType.createAndFill()!
|
|
229
|
+
: (createNodeFromLoroObj(view.state.schema, root as never, state.mapping as never) as Node);
|
|
230
|
+
const current = view.state.doc;
|
|
231
|
+
const start = current.content.findDiffStart(next.content);
|
|
232
|
+
let tr = view.state.tr;
|
|
233
|
+
if (start != null) {
|
|
234
|
+
let { a: endA, b: endB } = current.content.findDiffEnd(next.content)!;
|
|
235
|
+
// A run of equal content at both ends can make the two ends cross.
|
|
236
|
+
const overlap = start - Math.min(endA, endB);
|
|
237
|
+
if (overlap > 0) {
|
|
238
|
+
endA += overlap;
|
|
239
|
+
endB += overlap;
|
|
240
|
+
}
|
|
241
|
+
tr = tr.replace(start, endA, next.slice(start, endB));
|
|
242
|
+
}
|
|
243
|
+
if (moved) {
|
|
244
|
+
// The person moved the caret after the cursors were taken: map where it
|
|
245
|
+
// is now through the replace.
|
|
246
|
+
tr = tr.setSelection(
|
|
247
|
+
TextSelection.between(tr.doc.resolve(tr.mapping.map(moved.anchor)), tr.doc.resolve(tr.mapping.map(moved.head))),
|
|
248
|
+
);
|
|
249
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
250
|
+
tr.setMeta("addToHistory", false);
|
|
251
|
+
view.dispatch(tr);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const anchor = cursors.anchor && cursorToPosition(cursors.anchor, doc, containerId, state.mapping);
|
|
255
|
+
const head = cursors.head && cursorToPosition(cursors.head, doc, containerId, state.mapping);
|
|
256
|
+
// Otherwise the selection stays where ProseMirror mapped it through the replace.
|
|
257
|
+
if (anchor != null && anchor <= tr.doc.content.size) {
|
|
258
|
+
const headPos = head != null && head <= tr.doc.content.size ? head : anchor;
|
|
259
|
+
tr = tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(headPos)));
|
|
260
|
+
}
|
|
261
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
262
|
+
tr.setMeta("addToHistory", false);
|
|
263
|
+
view.dispatch(tr);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The selection as Loro cursors, bound to the character on the left of each
|
|
268
|
+
* end. Null ends where the doc has no text to bind to.
|
|
269
|
+
*/
|
|
270
|
+
export function selectionToCursors(state: EditorState, doc: LoroDoc, containerId: ContainerID): Cursors {
|
|
271
|
+
const { anchor, head } = state.selection;
|
|
272
|
+
const a = positionToCursor(state.doc, anchor, doc, containerId);
|
|
273
|
+
return { anchor: a, head: head === anchor ? a : positionToCursor(state.doc, head, doc, containerId) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The Loro container for each child of a node, in the binding's layout: a
|
|
278
|
+
* run of text nodes is one LoroText, every other node one LoroMap.
|
|
279
|
+
*/
|
|
280
|
+
function childIndexFor(node: Node, pmIndex: number): number {
|
|
281
|
+
let group = -1;
|
|
282
|
+
let inText = false;
|
|
283
|
+
for (let i = 0; i <= pmIndex && i < node.childCount; i++) {
|
|
284
|
+
const child = node.child(i);
|
|
285
|
+
if (child.isText) {
|
|
286
|
+
if (!inText) group += 1;
|
|
287
|
+
inText = true;
|
|
288
|
+
} else {
|
|
289
|
+
group += 1;
|
|
290
|
+
inText = false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return group;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A cursor for a document position. Walks the position's path from the
|
|
298
|
+
* field's root down, so it does not depend on node identity (a replace
|
|
299
|
+
* rebuilds the nodes along its path).
|
|
300
|
+
*/
|
|
301
|
+
export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containerId: ContainerID): Cursor | null {
|
|
302
|
+
if (pos < 0 || pos > root.content.size) return null;
|
|
303
|
+
const $pos = root.resolve(pos);
|
|
304
|
+
let map = field(doc, containerId);
|
|
305
|
+
for (let depth = 0; depth < $pos.depth; depth++) {
|
|
306
|
+
const children = map.get(CHILDREN_KEY);
|
|
307
|
+
if (!(children instanceof LoroList)) return null;
|
|
308
|
+
const child = children.get(childIndexFor($pos.node(depth), $pos.index(depth)));
|
|
309
|
+
if (!(child instanceof LoroMap)) return null;
|
|
310
|
+
map = child;
|
|
311
|
+
}
|
|
312
|
+
const children = map.get(CHILDREN_KEY);
|
|
313
|
+
if (!(children instanceof LoroList)) return null;
|
|
314
|
+
if (children.length === 0) return children.getCursor(0) ?? null;
|
|
315
|
+
// Between blocks (a node selection, a gap cursor): bind to the next child.
|
|
316
|
+
if (!$pos.parent.inlineContent) {
|
|
317
|
+
return children.getCursor(childIndexFor($pos.parent, $pos.index()) + ($pos.index() >= $pos.parent.childCount ? 1 : 0)) ?? null;
|
|
318
|
+
}
|
|
319
|
+
// Walk the textblock's children: text runs count their characters, every
|
|
320
|
+
// other inline node counts one.
|
|
321
|
+
let offset = $pos.parentOffset;
|
|
322
|
+
for (let i = 0; i < children.length; i++) {
|
|
323
|
+
const child = children.get(i);
|
|
324
|
+
if (child instanceof LoroText) {
|
|
325
|
+
if (offset <= child.length) {
|
|
326
|
+
// Bind to the character on the left (side 1: just after it). At the
|
|
327
|
+
// start of a run there is none, so bind to the first character.
|
|
328
|
+
return (offset > 0 ? child.getCursor(offset - 1, 1) : child.getCursor(0, 0)) ?? null;
|
|
329
|
+
}
|
|
330
|
+
offset -= child.length;
|
|
331
|
+
} else {
|
|
332
|
+
if (offset === 0) return children.getCursor(i) ?? null;
|
|
333
|
+
offset -= 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return children.getCursor(children.length) ?? null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Size in the editor of a container's node, from the binding's mapping. */
|
|
340
|
+
function mappedSize(mapping: Mapping, id: ContainerID): number | null {
|
|
341
|
+
const mapped = mapping.get(id);
|
|
342
|
+
if (mapped == null) return null;
|
|
343
|
+
return Array.isArray(mapped) ? mapped.reduce((n, node) => n + node.nodeSize, 0) : mapped.nodeSize;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The document position a cursor points at now. Uses the mapping, which
|
|
348
|
+
* createNodeFromLoroObj has just filled for the whole tree.
|
|
349
|
+
*/
|
|
350
|
+
export function cursorToPosition(cursor: Cursor, doc: LoroDoc, containerId: ContainerID, mapping: Mapping): number | null {
|
|
351
|
+
const rootId = field(doc, containerId).id;
|
|
352
|
+
const target = cursor.containerId();
|
|
353
|
+
let index: number;
|
|
354
|
+
let childId: ContainerID;
|
|
355
|
+
let node: LoroMap | undefined;
|
|
356
|
+
if (target.endsWith("List")) {
|
|
357
|
+
const list = doc.getContainerById(target) as LoroList | undefined;
|
|
358
|
+
const pos = doc.getCursorPos(cursor);
|
|
359
|
+
const owner = list?.parent() as LoroMap | undefined;
|
|
360
|
+
if (!list || !pos || pos.update || !owner) return null;
|
|
361
|
+
// Inside a node with no text, or before an inline node: count the
|
|
362
|
+
// children before it.
|
|
363
|
+
index = 0;
|
|
364
|
+
for (let i = 0; i < pos.offset; i++) {
|
|
365
|
+
const child = list.get(i) as { id?: ContainerID } | undefined;
|
|
366
|
+
const size = child?.id ? mappedSize(mapping, child.id) : null;
|
|
367
|
+
if (size == null) return null;
|
|
368
|
+
index += size;
|
|
369
|
+
}
|
|
370
|
+
if (owner.id === rootId) return index;
|
|
371
|
+
childId = owner.id;
|
|
372
|
+
node = (owner.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
373
|
+
index += 1; // into the owner node
|
|
374
|
+
} else {
|
|
375
|
+
const text = doc.getContainerById(target) as LoroText | undefined;
|
|
376
|
+
const pos = doc.getCursorPos(cursor);
|
|
377
|
+
// `update` means the character was deleted (a split moves text into a
|
|
378
|
+
// new paragraph as a delete and an insert). ProseMirror's own mapping
|
|
379
|
+
// through the replace places the caret better then.
|
|
380
|
+
if (!text || !pos || pos.update) return null;
|
|
381
|
+
// The cursor names a character; side 1 means just after it.
|
|
382
|
+
index = pos.offset + (pos.side === 1 ? 1 : 0);
|
|
383
|
+
childId = text.id;
|
|
384
|
+
node = (text.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
385
|
+
}
|
|
386
|
+
while (node) {
|
|
387
|
+
const children = node.get(CHILDREN_KEY);
|
|
388
|
+
if (!(children instanceof LoroList)) return null;
|
|
389
|
+
for (let i = 0; i < children.length; i++) {
|
|
390
|
+
const child = children.get(i) as { id?: ContainerID } | undefined;
|
|
391
|
+
if (!child?.id || child.id === childId) break;
|
|
392
|
+
const size = mappedSize(mapping, child.id);
|
|
393
|
+
if (size == null) return null;
|
|
394
|
+
index += size;
|
|
395
|
+
}
|
|
396
|
+
if (node.id === rootId) return index;
|
|
397
|
+
childId = node.id;
|
|
398
|
+
node = (node.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
399
|
+
index += 1; // the opening of the node we came out of
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|