@pylonsync/loro 0.22.40 → 0.22.43
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 +6 -4
- package/src/outbox.ts +18 -7
- package/src/prosemirror/cursors.test.ts +112 -0
- package/src/prosemirror/cursors.ts +172 -0
- package/src/prosemirror/index.ts +36 -1
- package/src/prosemirror/sync.test.ts +136 -6
- package/src/prosemirror/sync.ts +123 -35
- package/src/server.test.ts +138 -0
- package/src/server.ts +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.43",
|
|
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"
|
|
@@ -14,14 +14,16 @@
|
|
|
14
14
|
"./registry": "./src/registry.ts",
|
|
15
15
|
"./prosemirror": "./src/prosemirror/index.ts",
|
|
16
16
|
"./prosemirror/react": "./src/prosemirror/react.ts",
|
|
17
|
-
"./prosemirror/sync": "./src/prosemirror/sync.ts"
|
|
17
|
+
"./prosemirror/sync": "./src/prosemirror/sync.ts",
|
|
18
|
+
"./server": "./src/server.ts",
|
|
19
|
+
"./prosemirror/cursors": "./src/prosemirror/cursors.ts"
|
|
18
20
|
},
|
|
19
21
|
"scripts": {
|
|
20
22
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
23
|
-
"@pylonsync/react": "0.22.
|
|
24
|
-
"@pylonsync/sync": "0.22.
|
|
25
|
+
"@pylonsync/react": "0.22.43",
|
|
26
|
+
"@pylonsync/sync": "0.22.43",
|
|
25
27
|
"loro-crdt": "^1.10.6"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
package/src/outbox.ts
CHANGED
|
@@ -42,6 +42,22 @@ export interface OutboxOptions {
|
|
|
42
42
|
clearTimer?: (handle: unknown) => void;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** This peer's op counter in `doc` (exclusive end). */
|
|
46
|
+
export function ownCounter(doc: LoroDoc): number {
|
|
47
|
+
return doc.oplogVersion().get(doc.peerIdStr) ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* This peer's ops after counter `confirmed`, and no one else's: start from
|
|
52
|
+
* everything the doc has, then roll this peer back to `confirmed`.
|
|
53
|
+
*/
|
|
54
|
+
export function exportOwnOps(doc: LoroDoc, confirmed: number): Uint8Array {
|
|
55
|
+
const from = doc.oplogVersion().toJSON();
|
|
56
|
+
if (confirmed > 0) from.set(doc.peerIdStr, confirmed);
|
|
57
|
+
else from.delete(doc.peerIdStr);
|
|
58
|
+
return doc.export({ mode: "update", from: VersionVector.parseJSON(from) });
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
/** True for an error the same request could get past by trying again. */
|
|
46
62
|
export function isRetryable(err: unknown): boolean {
|
|
47
63
|
const status = (err as { status?: unknown } | null)?.status;
|
|
@@ -146,18 +162,13 @@ export class CrdtOutbox {
|
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
private ownCounter(): number {
|
|
149
|
-
return
|
|
165
|
+
return ownCounter(this.doc);
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/** This peer's ops after `confirmed`, or null when there are none. */
|
|
153
169
|
private unsent(): Uint8Array | null {
|
|
154
170
|
if (!this.hasUnsent()) return null;
|
|
155
|
-
|
|
156
|
-
// the server confirmed: the export is exactly this peer's new ops.
|
|
157
|
-
const from = this.doc.oplogVersion().toJSON();
|
|
158
|
-
if (this.confirmed > 0) from.set(this.doc.peerIdStr, this.confirmed);
|
|
159
|
-
else from.delete(this.doc.peerIdStr);
|
|
160
|
-
return this.doc.export({ mode: "update", from: VersionVector.parseJSON(from) });
|
|
171
|
+
return exportOwnOps(this.doc, this.confirmed);
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
private confirm(counter: number): void {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Remote carets 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, 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 { proseMirrorCursorsKey, ProseMirrorCursorsPlugin, encodeSelection, setRemoteCursors, type EncodedSelection } from "./cursors";
|
|
12
|
+
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
13
|
+
import { ProseMirrorSyncPlugin } from "./sync";
|
|
14
|
+
|
|
15
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
16
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
17
|
+
const views: EditorView[] = [];
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const v of views.splice(0)) v.destroy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function setup() {
|
|
23
|
+
const server = new LoroDoc();
|
|
24
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
25
|
+
body.setContainer("attributes", new LoroMap());
|
|
26
|
+
body.setContainer("children", new LoroList());
|
|
27
|
+
server.commit();
|
|
28
|
+
const docs = [new LoroDoc(), new LoroDoc()];
|
|
29
|
+
for (const d of docs) {
|
|
30
|
+
d.import(server.export({ mode: "snapshot" }));
|
|
31
|
+
configureProseMirrorMarks(d, schema);
|
|
32
|
+
}
|
|
33
|
+
const containerId = findProseMirrorContainer(docs[0], "body") as ContainerID;
|
|
34
|
+
const sent: EncodedSelection[] = [];
|
|
35
|
+
const make = (doc: LoroDoc, onSelection?: (s: EncodedSelection) => void) => {
|
|
36
|
+
const view = new EditorView(document.createElement("div"), {
|
|
37
|
+
state: EditorState.create({
|
|
38
|
+
schema,
|
|
39
|
+
plugins: [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorCursorsPlugin({ doc, containerId, onSelection })],
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
views.push(view);
|
|
43
|
+
return view;
|
|
44
|
+
};
|
|
45
|
+
const a = make(docs[0], (s) => sent.push(s));
|
|
46
|
+
const b = make(docs[1]);
|
|
47
|
+
const sync = () => {
|
|
48
|
+
for (const from of docs) server.import(from.export({ mode: "update", from: server.oplogVersion() }));
|
|
49
|
+
const all = server.export({ mode: "snapshot" });
|
|
50
|
+
for (const to of docs) to.import(all);
|
|
51
|
+
};
|
|
52
|
+
return { a, b, docs, containerId, sync, sent };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const type = (v: EditorView, s: string) => {
|
|
56
|
+
for (const ch of s) v.dispatch(v.state.tr.insertText(ch));
|
|
57
|
+
};
|
|
58
|
+
const caretAt = (v: EditorView, pos: number) => v.dispatch(v.state.tr.setSelection(TextSelection.create(v.state.doc, pos)));
|
|
59
|
+
function decorationsOf(v: EditorView): Array<{ from: number; to: number; spec: { key?: string } }> {
|
|
60
|
+
const plugin = v.state.plugins.find((p) => p.spec.key === proseMirrorCursorsKey)!;
|
|
61
|
+
const set = plugin.props.decorations!.call(plugin, v.state) as { find(): Array<{ from: number; to: number; spec: { key?: string } }> };
|
|
62
|
+
return set.find();
|
|
63
|
+
}
|
|
64
|
+
const caretPositions = (v: EditorView) =>
|
|
65
|
+
decorationsOf(v)
|
|
66
|
+
.filter((d) => d.spec.key?.startsWith("caret-"))
|
|
67
|
+
.map((d) => d.from);
|
|
68
|
+
|
|
69
|
+
test("a remote caret appears where the other person's caret is, and stays on its words", async () => {
|
|
70
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
71
|
+
await tick();
|
|
72
|
+
type(a, "hello world");
|
|
73
|
+
sync();
|
|
74
|
+
caretAt(a, 7); // after "hello "
|
|
75
|
+
const selection = encodeSelection(a.state, docs[0], containerId);
|
|
76
|
+
setRemoteCursors(b, [{ id: "a", name: "Ann", color: "#2563eb", selection }]);
|
|
77
|
+
expect(caretPositions(b)).toEqual([7]);
|
|
78
|
+
// B types before Ann's caret: it moves with her words.
|
|
79
|
+
caretAt(b, 1);
|
|
80
|
+
type(b, "oh ");
|
|
81
|
+
expect(caretPositions(b)).toEqual([10]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a selection is drawn as a range", async () => {
|
|
85
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
86
|
+
await tick();
|
|
87
|
+
type(a, "select me");
|
|
88
|
+
sync();
|
|
89
|
+
a.dispatch(a.state.tr.setSelection(TextSelection.create(a.state.doc, 1, 7)));
|
|
90
|
+
setRemoteCursors(b, [{ id: "a", name: "Ann", color: "#2563eb", selection: encodeSelection(a.state, docs[0], containerId) }]);
|
|
91
|
+
expect(decorationsOf(b).some((d) => d.from === 1 && d.to === 7)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("no peers, no decorations", async () => {
|
|
95
|
+
const { b } = setup();
|
|
96
|
+
await tick();
|
|
97
|
+
setRemoteCursors(b, []);
|
|
98
|
+
expect(caretPositions(b)).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a remote caret stays when the character it follows is deleted", async () => {
|
|
102
|
+
const { a, b, docs, containerId, sync } = setup();
|
|
103
|
+
await tick();
|
|
104
|
+
type(a, "hello world");
|
|
105
|
+
sync();
|
|
106
|
+
caretAt(a, 7); // after "hello "
|
|
107
|
+
setRemoteCursors(b, [{ id: "a", name: "Ann", color: "#2563eb", selection: encodeSelection(a.state, docs[0], containerId) }]);
|
|
108
|
+
expect(caretPositions(b)).toEqual([7]);
|
|
109
|
+
// B deletes the space Ann's caret follows.
|
|
110
|
+
b.dispatch(b.state.tr.delete(6, 7));
|
|
111
|
+
expect(caretPositions(b)).toEqual([6]);
|
|
112
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Other people's carets and selections in a ProseMirror editor bound with
|
|
3
|
+
* ProseMirrorSyncPlugin.
|
|
4
|
+
*
|
|
5
|
+
* Each person's selection travels as two Loro cursors (encoded, base64),
|
|
6
|
+
* which point at characters, not positions: a caret stays on its words while
|
|
7
|
+
* anyone edits before it. Send them through the room tied to the row
|
|
8
|
+
* (`useRoom({ entity, id })`), so only people who may read the document see
|
|
9
|
+
* them:
|
|
10
|
+
*
|
|
11
|
+
* const room = useRoom({ entity: "Doc", id }, me.id);
|
|
12
|
+
* const plugin = ProseMirrorCursorsPlugin({
|
|
13
|
+
* doc, containerId,
|
|
14
|
+
* onSelection: (selection) => room.setPresence({ name, color, selection }),
|
|
15
|
+
* });
|
|
16
|
+
* // whenever room.peers changes:
|
|
17
|
+
* setRemoteCursors(view, room.peers.map((p) => ({ id: p.id, ...p.presence })));
|
|
18
|
+
*/
|
|
19
|
+
import { type ContainerID, Cursor, type LoroDoc } from "loro-crdt";
|
|
20
|
+
import { loroSyncPluginKey } from "loro-prosemirror";
|
|
21
|
+
import type { Node } from "prosemirror-model";
|
|
22
|
+
import { type EditorState, Plugin, PluginKey } from "prosemirror-state";
|
|
23
|
+
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
|
|
24
|
+
import { cursorToPosition, selectionToCursors } from "./sync";
|
|
25
|
+
|
|
26
|
+
/** A selection as encoded Loro cursors. Null where there is no text to bind to. */
|
|
27
|
+
export interface EncodedSelection {
|
|
28
|
+
anchor: string | null;
|
|
29
|
+
head: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Someone else in the document. */
|
|
33
|
+
export interface RemoteCursor {
|
|
34
|
+
/** Stable per person (the room peer id). */
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
/** A CSS color for the caret and the selection. */
|
|
38
|
+
color: string;
|
|
39
|
+
selection?: EncodedSelection | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProseMirrorCursorsPluginProps {
|
|
43
|
+
doc: LoroDoc;
|
|
44
|
+
containerId: ContainerID;
|
|
45
|
+
/** Called when this person's selection moves, with its encoded cursors. */
|
|
46
|
+
onSelection?: (selection: EncodedSelection) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const proseMirrorCursorsKey = new PluginKey<RemoteCursor[]>("pylonRemoteCursors");
|
|
50
|
+
|
|
51
|
+
function toBase64(bytes: Uint8Array): string {
|
|
52
|
+
let s = "";
|
|
53
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
54
|
+
return btoa(s);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fromBase64(text: string): Uint8Array {
|
|
58
|
+
const s = atob(text);
|
|
59
|
+
const out = new Uint8Array(s.length);
|
|
60
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This editor's selection as encoded Loro cursors. */
|
|
65
|
+
export function encodeSelection(state: EditorState, doc: LoroDoc, containerId: ContainerID): EncodedSelection {
|
|
66
|
+
const { anchor, head } = selectionToCursors(state, doc, containerId);
|
|
67
|
+
return {
|
|
68
|
+
anchor: anchor ? toBase64(anchor.encode()) : null,
|
|
69
|
+
head: head ? toBase64(head.encode()) : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Show these people's carets and selections. Replaces the previous list. */
|
|
74
|
+
export function setRemoteCursors(view: EditorView, cursors: RemoteCursor[]): void {
|
|
75
|
+
view.dispatch(view.state.tr.setMeta(proseMirrorCursorsKey, cursors).setMeta("addToHistory", false));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function caret(cursor: RemoteCursor): HTMLElement {
|
|
79
|
+
const el = document.createElement("span");
|
|
80
|
+
el.className = "pylon-remote-caret";
|
|
81
|
+
el.style.borderLeft = `2px solid ${cursor.color}`;
|
|
82
|
+
el.style.marginLeft = "-1px";
|
|
83
|
+
el.style.marginRight = "-1px";
|
|
84
|
+
el.style.position = "relative";
|
|
85
|
+
el.setAttribute("aria-hidden", "true");
|
|
86
|
+
const label = document.createElement("span");
|
|
87
|
+
label.className = "pylon-remote-caret-label";
|
|
88
|
+
label.textContent = cursor.name;
|
|
89
|
+
label.style.position = "absolute";
|
|
90
|
+
label.style.left = "-2px";
|
|
91
|
+
label.style.bottom = "100%";
|
|
92
|
+
label.style.whiteSpace = "nowrap";
|
|
93
|
+
label.style.background = cursor.color;
|
|
94
|
+
label.style.color = "white";
|
|
95
|
+
label.style.fontSize = "11px";
|
|
96
|
+
label.style.lineHeight = "1.4";
|
|
97
|
+
label.style.padding = "0 4px";
|
|
98
|
+
label.style.borderRadius = "3px 3px 3px 0";
|
|
99
|
+
label.style.pointerEvents = "none";
|
|
100
|
+
el.appendChild(label);
|
|
101
|
+
return el;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolve(encoded: string | null | undefined, doc: LoroDoc, containerId: ContainerID, state: EditorState): number | null {
|
|
105
|
+
if (!encoded) return null;
|
|
106
|
+
const sync = loroSyncPluginKey.getState(state) as { mapping?: Map<ContainerID, Node | Node[]> } | undefined;
|
|
107
|
+
if (!sync?.mapping) return null;
|
|
108
|
+
try {
|
|
109
|
+
// A deleted character's cursor moves to Loro's nearest position, so the
|
|
110
|
+
// caret stays visible until its owner sends a new selection.
|
|
111
|
+
const pos = cursorToPosition(Cursor.decode(fromBase64(encoded)), doc, containerId, sync.mapping, {
|
|
112
|
+
acceptMoved: true,
|
|
113
|
+
});
|
|
114
|
+
return pos != null && pos >= 0 && pos <= state.doc.content.size ? pos : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decorations(state: EditorState, cursors: RemoteCursor[], doc: LoroDoc, containerId: ContainerID): DecorationSet {
|
|
121
|
+
const decos: Decoration[] = [];
|
|
122
|
+
for (const cursor of cursors) {
|
|
123
|
+
const head = resolve(cursor.selection?.head, doc, containerId, state);
|
|
124
|
+
if (head == null) continue;
|
|
125
|
+
const anchor = resolve(cursor.selection?.anchor, doc, containerId, state) ?? head;
|
|
126
|
+
if (anchor !== head) {
|
|
127
|
+
decos.push(
|
|
128
|
+
Decoration.inline(Math.min(anchor, head), Math.max(anchor, head), {
|
|
129
|
+
style: `background-color: color-mix(in srgb, ${cursor.color} 22%, transparent)`,
|
|
130
|
+
class: "pylon-remote-selection",
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
decos.push(Decoration.widget(head, () => caret(cursor), { key: `caret-${cursor.id}-${cursor.color}-${cursor.name}`, side: -1 }));
|
|
135
|
+
}
|
|
136
|
+
return DecorationSet.create(state.doc, decos);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function ProseMirrorCursorsPlugin({ doc, containerId, onSelection }: ProseMirrorCursorsPluginProps): Plugin {
|
|
140
|
+
// Decorations are recomputed for each new document or peer list.
|
|
141
|
+
let cache: { doc: Node; cursors: RemoteCursor[]; set: DecorationSet } | null = null;
|
|
142
|
+
return new Plugin<RemoteCursor[]>({
|
|
143
|
+
key: proseMirrorCursorsKey,
|
|
144
|
+
state: {
|
|
145
|
+
init: () => [],
|
|
146
|
+
apply: (tr, value) => (tr.getMeta(proseMirrorCursorsKey) as RemoteCursor[] | undefined) ?? value,
|
|
147
|
+
},
|
|
148
|
+
props: {
|
|
149
|
+
decorations(state) {
|
|
150
|
+
const cursors = proseMirrorCursorsKey.getState(state) ?? [];
|
|
151
|
+
if (cursors.length === 0) return DecorationSet.empty;
|
|
152
|
+
if (cache && cache.doc === state.doc && cache.cursors === cursors) return cache.set;
|
|
153
|
+
const set = decorations(state, cursors, doc, containerId);
|
|
154
|
+
cache = { doc: state.doc, cursors, set };
|
|
155
|
+
return set;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
view: () => {
|
|
159
|
+
let last = "";
|
|
160
|
+
return {
|
|
161
|
+
update(view) {
|
|
162
|
+
if (!onSelection || !view.hasFocus()) return;
|
|
163
|
+
const encoded = encodeSelection(view.state, doc, containerId);
|
|
164
|
+
const key = `${encoded.anchor}|${encoded.head}`;
|
|
165
|
+
if (key === last) return;
|
|
166
|
+
last = key;
|
|
167
|
+
onSelection(encoded);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/prosemirror/index.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import type { ContainerID, LoroDoc, LoroMap } from "loro-crdt";
|
|
28
28
|
import { createNodeFromLoroObj, updateLoroToPmState } from "loro-prosemirror";
|
|
29
29
|
import type { Node, Schema } from "prosemirror-model";
|
|
30
|
-
import { EditorState } from "prosemirror-state";
|
|
30
|
+
import { EditorState, Transaction } from "prosemirror-state";
|
|
31
31
|
|
|
32
32
|
/** The root map every Pylon row doc keeps its fields in (crates/crdt). */
|
|
33
33
|
const ROOT_MAP = "row";
|
|
@@ -113,3 +113,38 @@ export function writeProseMirror(doc: LoroDoc, field: string, schema: Schema, no
|
|
|
113
113
|
configureProseMirrorMarks(doc, schema);
|
|
114
114
|
updateLoroToPmState(doc as never, new Map(), EditorState.create({ schema, doc: node }), containerId);
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/** What {@link editProseMirror} needs of a live doc (`LiveDoc` from `@pylonsync/loro/server`). */
|
|
118
|
+
export interface LiveProseMirrorDoc {
|
|
119
|
+
readonly doc: LoroDoc;
|
|
120
|
+
refresh(): Promise<void>;
|
|
121
|
+
commit(): Promise<boolean>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Change `field`'s document from a server function and send the change to
|
|
126
|
+
* everyone editing. `change` gets an editor state on the document as it is
|
|
127
|
+
* now (an empty document when there is none) and returns a transaction or
|
|
128
|
+
* a new document; returning nothing changes nothing. The write is a diff,
|
|
129
|
+
* so edits people make at the same time still merge. Returns the document
|
|
130
|
+
* written, or null when nothing changed.
|
|
131
|
+
*
|
|
132
|
+
* await editProseMirror(live, "body", schema, (state) =>
|
|
133
|
+
* state.tr.insertText("Summary: ", 1));
|
|
134
|
+
*/
|
|
135
|
+
export async function editProseMirror(
|
|
136
|
+
live: LiveProseMirrorDoc,
|
|
137
|
+
field: string,
|
|
138
|
+
schema: Schema,
|
|
139
|
+
change: (state: EditorState) => Transaction | Node | null | undefined | void,
|
|
140
|
+
): Promise<Node | null> {
|
|
141
|
+
await live.refresh();
|
|
142
|
+
const current = readProseMirror(live.doc, field, schema) ?? schema.topNodeType.createAndFill();
|
|
143
|
+
if (!current) throw new Error(`The schema's ${schema.topNodeType.name} has no empty form to start from.`);
|
|
144
|
+
const result = change(EditorState.create({ schema, doc: current }));
|
|
145
|
+
const next = result instanceof Transaction ? result.doc : result;
|
|
146
|
+
if (!next || next.eq(current)) return null;
|
|
147
|
+
writeProseMirror(live.doc, field, schema, next);
|
|
148
|
+
await live.commit();
|
|
149
|
+
return next;
|
|
150
|
+
}
|
|
@@ -14,7 +14,7 @@ import { EditorView } from "prosemirror-view";
|
|
|
14
14
|
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
15
15
|
import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const defaultSchema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
18
18
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
19
19
|
|
|
20
20
|
const views: EditorView[] = [];
|
|
@@ -22,12 +22,14 @@ afterEach(() => {
|
|
|
22
22
|
for (const v of views.splice(0)) v.destroy();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* A server doc with the field's container, and two editors synced to it:
|
|
27
|
+
* on two client docs, or with `shared` on one (two editors in one tab).
|
|
28
|
+
*/
|
|
29
|
+
function setup({ schema = defaultSchema, shared = false }: { schema?: Schema; shared?: boolean } = {}) {
|
|
27
30
|
// The empty document the server makes on insert (crates/crdt prosemirror.rs).
|
|
28
31
|
const server = new LoroDoc();
|
|
29
32
|
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
30
|
-
body.set("nodeName", "doc");
|
|
31
33
|
body.setContainer("attributes", new LoroMap());
|
|
32
34
|
body.setContainer("children", new LoroList());
|
|
33
35
|
server.commit();
|
|
@@ -47,7 +49,7 @@ function setup() {
|
|
|
47
49
|
views.push(view);
|
|
48
50
|
return view;
|
|
49
51
|
};
|
|
50
|
-
const [a, b] = [make(docs[0]), make(docs[1])];
|
|
52
|
+
const [a, b] = [make(docs[0]), make(shared ? docs[0] : docs[1])];
|
|
51
53
|
const sync = () => {
|
|
52
54
|
for (const from of docs) {
|
|
53
55
|
const bytes = from.export({ mode: "update", from: server.oplogVersion() });
|
|
@@ -56,7 +58,7 @@ function setup() {
|
|
|
56
58
|
const all = server.export({ mode: "snapshot" });
|
|
57
59
|
for (const to of docs) to.import(all);
|
|
58
60
|
};
|
|
59
|
-
return { a, b, docs, sync };
|
|
61
|
+
return { a, b, docs, server, sync };
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const text = (v: EditorView) => v.state.doc.textBetween(0, v.state.doc.content.size, "\n");
|
|
@@ -191,3 +193,131 @@ describe("ProseMirrorSyncPlugin", () => {
|
|
|
191
193
|
expect(text(b)).toBe("A:base");
|
|
192
194
|
});
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
// Regressions from the Codex review of 2026-10-17.
|
|
198
|
+
describe("ProseMirrorSyncPlugin review fixes", () => {
|
|
199
|
+
test("the first edit in a new document can be undone", async () => {
|
|
200
|
+
const { a, b, sync } = setup();
|
|
201
|
+
await tick();
|
|
202
|
+
type(a, "first words");
|
|
203
|
+
sync();
|
|
204
|
+
expect(text(b)).toBe("first words");
|
|
205
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
206
|
+
type(a, " more");
|
|
207
|
+
undo(a.state, a.dispatch);
|
|
208
|
+
undo(a.state, a.dispatch);
|
|
209
|
+
sync();
|
|
210
|
+
expect(text(a)).toBe("");
|
|
211
|
+
expect(text(b)).toBe("");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("two editors on one doc see each other's edits", async () => {
|
|
215
|
+
const { a, b, server, sync } = setup({ shared: true });
|
|
216
|
+
await tick();
|
|
217
|
+
type(a, "base");
|
|
218
|
+
expect(text(b)).toBe("base");
|
|
219
|
+
caretAt(b, 5);
|
|
220
|
+
type(b, "B");
|
|
221
|
+
expect(text(a)).toBe("baseB");
|
|
222
|
+
sync();
|
|
223
|
+
expect(JSON.stringify(server.getMap("row").get("body"))).toBeDefined();
|
|
224
|
+
expect(text(a)).toBe("baseB");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("the top node's attributes load and are not overwritten", async () => {
|
|
228
|
+
const langSchema = new Schema({
|
|
229
|
+
nodes: defaultSchema.spec.nodes.update("doc", { ...defaultSchema.spec.nodes.get("doc")!, attrs: { lang: { default: "en" } } }),
|
|
230
|
+
marks: defaultSchema.spec.marks,
|
|
231
|
+
});
|
|
232
|
+
const { a, b, docs, sync } = setup({ schema: langSchema });
|
|
233
|
+
await tick();
|
|
234
|
+
a.dispatch(a.state.tr.setDocAttribute("lang", "fr"));
|
|
235
|
+
type(a, "bonjour");
|
|
236
|
+
sync();
|
|
237
|
+
expect(b.state.doc.attrs.lang).toBe("fr");
|
|
238
|
+
type(b, "!");
|
|
239
|
+
sync();
|
|
240
|
+
expect(a.state.doc.attrs.lang).toBe("fr");
|
|
241
|
+
const attrs = (docs[0].getMap("row").get("body") as LoroMap).get("attributes") as LoroMap;
|
|
242
|
+
expect(attrs.get("lang")).toBe("fr");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("clearing the document does not add a paragraph per editor", async () => {
|
|
246
|
+
const { a, b, docs, server, sync } = setup();
|
|
247
|
+
await tick();
|
|
248
|
+
type(a, "text");
|
|
249
|
+
sync();
|
|
250
|
+
const children = (server.getMap("row").get("body") as LoroMap).get("children") as LoroList;
|
|
251
|
+
children.delete(0, children.length);
|
|
252
|
+
server.commit();
|
|
253
|
+
for (const d of docs) d.import(server.export({ mode: "snapshot" }));
|
|
254
|
+
sync();
|
|
255
|
+
expect(children.length).toBe(0);
|
|
256
|
+
expect(text(a)).toBe("");
|
|
257
|
+
expect(text(b)).toBe("");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a schema whose top node is not doc can edit a new document", async () => {
|
|
261
|
+
const custom = new Schema({
|
|
262
|
+
topNode: "document",
|
|
263
|
+
nodes: defaultSchema.spec.nodes.remove("doc").addToStart("document", { content: "block+" }),
|
|
264
|
+
marks: defaultSchema.spec.marks,
|
|
265
|
+
});
|
|
266
|
+
const { a, b, sync } = setup({ schema: custom });
|
|
267
|
+
await tick();
|
|
268
|
+
type(a, "hello");
|
|
269
|
+
sync();
|
|
270
|
+
expect(text(b)).toBe("hello");
|
|
271
|
+
expect(b.state.doc.type.name).toBe("document");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a caret after an emoji stays after it", async () => {
|
|
275
|
+
const { a, b, sync } = setup();
|
|
276
|
+
await tick();
|
|
277
|
+
type(a, "x😀");
|
|
278
|
+
sync();
|
|
279
|
+
caretAt(b, 4); // after the emoji (1 + 1 + 2)
|
|
280
|
+
caretAt(a, 1);
|
|
281
|
+
type(a, "AB");
|
|
282
|
+
sync();
|
|
283
|
+
expect(text(b)).toBe("ABx😀");
|
|
284
|
+
expect(b.state.selection.head).toBe(6);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a caret after an inline node with content stays put", async () => {
|
|
288
|
+
const nodes = defaultSchema.spec.nodes.addToEnd("chip", {
|
|
289
|
+
group: "inline",
|
|
290
|
+
inline: true,
|
|
291
|
+
content: "text*",
|
|
292
|
+
toDOM: () => ["span", { class: "chip" }, 0],
|
|
293
|
+
});
|
|
294
|
+
const chipSchema = new Schema({ nodes, marks: defaultSchema.spec.marks });
|
|
295
|
+
const { a, b, sync } = setup({ schema: chipSchema });
|
|
296
|
+
await tick();
|
|
297
|
+
type(a, "ab");
|
|
298
|
+
a.dispatch(a.state.tr.insert(3, chipSchema.node("chip", null, [chipSchema.text("XX")])));
|
|
299
|
+
caretAt(a, 7); // after the chip
|
|
300
|
+
type(a, "cd");
|
|
301
|
+
sync();
|
|
302
|
+
caretAt(b, 9); // after "cd"
|
|
303
|
+
caretAt(a, 1);
|
|
304
|
+
type(a, "Z");
|
|
305
|
+
sync();
|
|
306
|
+
expect(b.state.selection.head).toBe(10);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a selected node stays selected through a remote edit", async () => {
|
|
310
|
+
const { a, b, sync } = setup();
|
|
311
|
+
await tick();
|
|
312
|
+
type(a, "pic ");
|
|
313
|
+
a.dispatch(a.state.tr.insert(5, defaultSchema.node("image", { src: "x.png" })));
|
|
314
|
+
sync();
|
|
315
|
+
const { NodeSelection } = await import("prosemirror-state");
|
|
316
|
+
b.dispatch(b.state.tr.setSelection(NodeSelection.create(b.state.doc, 5)));
|
|
317
|
+
caretAt(a, 1);
|
|
318
|
+
type(a, "A ");
|
|
319
|
+
sync();
|
|
320
|
+
expect(b.state.selection).toBeInstanceOf(NodeSelection);
|
|
321
|
+
expect((b.state.selection as InstanceType<typeof NodeSelection>).node.type.name).toBe("image");
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/prosemirror/sync.ts
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
updateLoroToPmState,
|
|
40
40
|
} from "loro-prosemirror";
|
|
41
41
|
import type { Node } from "prosemirror-model";
|
|
42
|
-
import { Plugin, TextSelection, type EditorState } from "prosemirror-state";
|
|
42
|
+
import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state";
|
|
43
43
|
import type { EditorView } from "prosemirror-view";
|
|
44
44
|
|
|
45
45
|
type Mapping = Map<ContainerID, Node | Node[]>;
|
|
@@ -65,6 +65,19 @@ export interface ProseMirrorSyncPluginProps {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export function ProseMirrorSyncPlugin({ doc, containerId }: ProseMirrorSyncPluginProps): Plugin {
|
|
68
|
+
// True while this editor writes to the doc. Loro reports a commit to
|
|
69
|
+
// subscribers synchronously, in the middle of this editor's own dispatch;
|
|
70
|
+
// those events are this editor's own edits. Every other change (another
|
|
71
|
+
// editor on the same doc, app code, an undo, the server) is applied.
|
|
72
|
+
let writing = false;
|
|
73
|
+
const write = (fn: () => void) => {
|
|
74
|
+
writing = true;
|
|
75
|
+
try {
|
|
76
|
+
fn();
|
|
77
|
+
} finally {
|
|
78
|
+
writing = false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
68
81
|
return new Plugin({
|
|
69
82
|
key: loroSyncPluginKey,
|
|
70
83
|
props: {
|
|
@@ -81,22 +94,27 @@ export function ProseMirrorSyncPlugin({ doc, containerId }: ProseMirrorSyncPlugi
|
|
|
81
94
|
switch (meta?.type) {
|
|
82
95
|
case "doc-changed":
|
|
83
96
|
if (!undoState?.isUndoing?.current) {
|
|
84
|
-
updateLoroToPmState(doc as never, state.mapping as never, newEditorState, containerId);
|
|
97
|
+
write(() => updateLoroToPmState(doc as never, state.mapping as never, newEditorState, containerId));
|
|
85
98
|
}
|
|
86
99
|
break;
|
|
87
|
-
case "update-state":
|
|
100
|
+
case "update-state": {
|
|
88
101
|
state = { ...state, ...meta.state };
|
|
89
|
-
|
|
102
|
+
const committing = state.doc;
|
|
103
|
+
write(() => committing.commit({ origin: "sys:init", timestamp: Date.now() }));
|
|
90
104
|
break;
|
|
105
|
+
}
|
|
91
106
|
}
|
|
92
107
|
return state;
|
|
93
108
|
},
|
|
94
109
|
},
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
110
|
+
// Only this editor's own changes are written to the doc. A change that
|
|
111
|
+
// came from the doc is there already, and the empty document an editor
|
|
112
|
+
// shows for an emptied field must not be written back: every editor
|
|
113
|
+
// would add its own.
|
|
98
114
|
appendTransaction: (transactions, _old, newState) =>
|
|
99
|
-
transactions.some(
|
|
115
|
+
transactions.some(
|
|
116
|
+
(tr) => tr.docChanged && (tr.getMeta(loroSyncPluginKey) as { type?: string } | undefined)?.type !== "non-local-updates",
|
|
117
|
+
)
|
|
100
118
|
? newState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" })
|
|
101
119
|
: null,
|
|
102
120
|
view: (view) => {
|
|
@@ -108,12 +126,20 @@ export function ProseMirrorSyncPlugin({ doc, containerId }: ProseMirrorSyncPlugi
|
|
|
108
126
|
const timer = setTimeout(() => {
|
|
109
127
|
if (view.isDestroyed) return;
|
|
110
128
|
unsubscribe = doc.getContainerById(containerId)!.subscribe((event) => {
|
|
111
|
-
|
|
112
|
-
// An undo is local to Loro but new to the editor.
|
|
113
|
-
if (event.by === "local" && event.origin !== "undo") return;
|
|
129
|
+
if (writing) return;
|
|
114
130
|
applyRemote(view, doc, containerId, event, cursors);
|
|
115
131
|
remember();
|
|
116
132
|
});
|
|
133
|
+
// A new document has no top node name yet; the server leaves it to
|
|
134
|
+
// the schema. Write it now, as setup (`sys:init`, which undo skips),
|
|
135
|
+
// so the person's first edit is an edit they can undo.
|
|
136
|
+
const root = doc.getContainerById(containerId) as LoroMap;
|
|
137
|
+
if (root.get("nodeName") == null) {
|
|
138
|
+
write(() => {
|
|
139
|
+
root.set("nodeName", view.state.schema.topNodeType.name);
|
|
140
|
+
doc.commit({ origin: "sys:init" });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
117
143
|
loadInitial(view, doc, containerId);
|
|
118
144
|
remember();
|
|
119
145
|
}, 0);
|
|
@@ -156,12 +182,14 @@ function field(doc: LoroDoc, containerId: ContainerID): LoroMap {
|
|
|
156
182
|
|
|
157
183
|
/**
|
|
158
184
|
* 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
|
|
160
|
-
* editor reads it a little later; a remote change
|
|
161
|
-
* caret back where it was.
|
|
185
|
+
* state's and the window has focus. A click or an arrow key moves the DOM
|
|
186
|
+
* selection first, and the editor reads it a little later; a remote change
|
|
187
|
+
* in between would put the caret back where it was.
|
|
162
188
|
*/
|
|
163
189
|
function domSelectionIfMoved(view: EditorView): { anchor: number; head: number } | null {
|
|
164
|
-
|
|
190
|
+
// A window without focus keeps a stale DOM selection: the editor does not
|
|
191
|
+
// write its selection to the DOM while it is in the background.
|
|
192
|
+
if (!view.hasFocus() || !view.dom.ownerDocument.hasFocus()) return null;
|
|
165
193
|
const sel = (view.root as Document).getSelection?.();
|
|
166
194
|
if (!sel?.anchorNode || !sel.focusNode || !view.dom.contains(sel.anchorNode)) return null;
|
|
167
195
|
try {
|
|
@@ -194,11 +222,21 @@ function loadInitial(view: EditorView, doc: LoroDoc, containerId: ContainerID):
|
|
|
194
222
|
if (node && node.childCount > 0) {
|
|
195
223
|
tr = tr.replaceWith(0, view.state.doc.content.size, node.content);
|
|
196
224
|
}
|
|
225
|
+
if (node) tr = withTopAttrs(tr, node);
|
|
197
226
|
tr.setMeta(loroSyncPluginKey, { type: "update-state", state: { mapping, snapshot: null } });
|
|
198
227
|
tr.setMeta("addToHistory", false);
|
|
199
228
|
view.dispatch(tr);
|
|
200
229
|
}
|
|
201
230
|
|
|
231
|
+
/** Set the top node's attributes to `node`'s where they differ. */
|
|
232
|
+
function withTopAttrs(tr: Transaction, node: Node): Transaction {
|
|
233
|
+
const current = tr.doc.attrs;
|
|
234
|
+
for (const [name, value] of Object.entries(node.attrs)) {
|
|
235
|
+
if (current[name] !== value) tr = tr.setDocAttribute(name, value);
|
|
236
|
+
}
|
|
237
|
+
return tr;
|
|
238
|
+
}
|
|
239
|
+
|
|
202
240
|
/** Drop cached nodes for every container an event touched, and their ancestors. */
|
|
203
241
|
function forgetChanged(doc: LoroDoc, event: { events: Array<{ target: ContainerID }> }, mapping: Mapping): void {
|
|
204
242
|
for (const e of event.events) {
|
|
@@ -240,6 +278,17 @@ function applyRemote(
|
|
|
240
278
|
}
|
|
241
279
|
tr = tr.replace(start, endA, next.slice(start, endB));
|
|
242
280
|
}
|
|
281
|
+
tr = withTopAttrs(tr, next);
|
|
282
|
+
// Nothing this editor shows changed (an echo, a change elsewhere in the row).
|
|
283
|
+
if (!tr.docChanged) return;
|
|
284
|
+
// A node selection, a cell selection: ProseMirror maps it through the
|
|
285
|
+
// replace and keeps its kind; a caret rebuilt from cursors would not.
|
|
286
|
+
if (!(view.state.selection instanceof TextSelection)) {
|
|
287
|
+
tr.setMeta(loroSyncPluginKey, { type: "non-local-updates" });
|
|
288
|
+
tr.setMeta("addToHistory", false);
|
|
289
|
+
view.dispatch(tr);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
243
292
|
if (moved) {
|
|
244
293
|
// The person moved the caret after the cursors were taken: map where it
|
|
245
294
|
// is now through the replace.
|
|
@@ -316,26 +365,54 @@ export function positionToCursor(root: Node, pos: number, doc: LoroDoc, containe
|
|
|
316
365
|
if (!$pos.parent.inlineContent) {
|
|
317
366
|
return children.getCursor(childIndexFor($pos.parent, $pos.index()) + ($pos.index() >= $pos.parent.childCount ? 1 : 0)) ?? null;
|
|
318
367
|
}
|
|
319
|
-
// Walk the textblock's children
|
|
320
|
-
// other inline node
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
368
|
+
// Walk the textblock's children alongside the editor's: a run of text
|
|
369
|
+
// nodes is one LoroText, every other inline node one child of its size.
|
|
370
|
+
const parent = $pos.parent;
|
|
371
|
+
const offset = $pos.parentOffset;
|
|
372
|
+
let group = -1;
|
|
373
|
+
let pmOffset = 0;
|
|
374
|
+
let runStart = -1;
|
|
375
|
+
for (let i = 0; i < parent.childCount; i++) {
|
|
376
|
+
const child = parent.child(i);
|
|
377
|
+
if (child.isText) {
|
|
378
|
+
if (runStart < 0) {
|
|
379
|
+
group += 1;
|
|
380
|
+
runStart = pmOffset;
|
|
329
381
|
}
|
|
330
|
-
offset -= child.length;
|
|
331
382
|
} else {
|
|
332
|
-
|
|
333
|
-
|
|
383
|
+
// The caret at the end of a text run binds to that run, so typing
|
|
384
|
+
// there continues it.
|
|
385
|
+
if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
|
|
386
|
+
runStart = -1;
|
|
387
|
+
group += 1;
|
|
388
|
+
if (offset === pmOffset) return children.getCursor(group) ?? null;
|
|
334
389
|
}
|
|
390
|
+
pmOffset += child.nodeSize;
|
|
335
391
|
}
|
|
392
|
+
if (runStart >= 0 && offset <= pmOffset) return textCursor(children.get(group), offset - runStart);
|
|
336
393
|
return children.getCursor(children.length) ?? null;
|
|
337
394
|
}
|
|
338
395
|
|
|
396
|
+
/** True for the second half of a UTF-16 surrogate pair (an emoji's, say). */
|
|
397
|
+
function isLowSurrogate(text: string): boolean {
|
|
398
|
+
const code = text.charCodeAt(0);
|
|
399
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A cursor `offset` UTF-16 units into a text run, bound to the character on
|
|
404
|
+
* its left (side 1: just after it), the whole character when it is a
|
|
405
|
+
* surrogate pair. At the start of the run there is none, so it binds to the
|
|
406
|
+
* first character.
|
|
407
|
+
*/
|
|
408
|
+
function textCursor(text: unknown, offset: number): Cursor | null {
|
|
409
|
+
if (!(text instanceof LoroText)) return null;
|
|
410
|
+
if (offset <= 0) return text.getCursor(0, 0) ?? null;
|
|
411
|
+
let start = Math.min(offset, text.length) - 1;
|
|
412
|
+
if (start > 0 && isLowSurrogate(text.slice(start, start + 1))) start -= 1;
|
|
413
|
+
return text.getCursor(start, 1) ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
339
416
|
/** Size in the editor of a container's node, from the binding's mapping. */
|
|
340
417
|
function mappedSize(mapping: Mapping, id: ContainerID): number | null {
|
|
341
418
|
const mapped = mapping.get(id);
|
|
@@ -347,7 +424,13 @@ function mappedSize(mapping: Mapping, id: ContainerID): number | null {
|
|
|
347
424
|
* The document position a cursor points at now. Uses the mapping, which
|
|
348
425
|
* createNodeFromLoroObj has just filled for the whole tree.
|
|
349
426
|
*/
|
|
350
|
-
export function cursorToPosition(
|
|
427
|
+
export function cursorToPosition(
|
|
428
|
+
cursor: Cursor,
|
|
429
|
+
doc: LoroDoc,
|
|
430
|
+
containerId: ContainerID,
|
|
431
|
+
mapping: Mapping,
|
|
432
|
+
options: { acceptMoved?: boolean } = {},
|
|
433
|
+
): number | null {
|
|
351
434
|
const rootId = field(doc, containerId).id;
|
|
352
435
|
const target = cursor.containerId();
|
|
353
436
|
let index: number;
|
|
@@ -357,7 +440,7 @@ export function cursorToPosition(cursor: Cursor, doc: LoroDoc, containerId: Cont
|
|
|
357
440
|
const list = doc.getContainerById(target) as LoroList | undefined;
|
|
358
441
|
const pos = doc.getCursorPos(cursor);
|
|
359
442
|
const owner = list?.parent() as LoroMap | undefined;
|
|
360
|
-
if (!list || !pos || pos.update || !owner) return null;
|
|
443
|
+
if (!list || !pos || (pos.update && !options.acceptMoved) || !owner) return null;
|
|
361
444
|
// Inside a node with no text, or before an inline node: count the
|
|
362
445
|
// children before it.
|
|
363
446
|
index = 0;
|
|
@@ -375,11 +458,16 @@ export function cursorToPosition(cursor: Cursor, doc: LoroDoc, containerId: Cont
|
|
|
375
458
|
const text = doc.getContainerById(target) as LoroText | undefined;
|
|
376
459
|
const pos = doc.getCursorPos(cursor);
|
|
377
460
|
// `update` means the character was deleted (a split moves text into a
|
|
378
|
-
// new paragraph as a delete and an insert).
|
|
379
|
-
// through the replace places
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
461
|
+
// new paragraph as a delete and an insert). For this editor's own caret,
|
|
462
|
+
// ProseMirror's mapping through the replace places it better; another
|
|
463
|
+
// person's caret has no mapping, so it takes Loro's nearest position
|
|
464
|
+
// (`acceptMoved`).
|
|
465
|
+
if (!text || !pos || (pos.update && !options.acceptMoved)) return null;
|
|
466
|
+
// The cursor names a character; side 1 means just after it, which is
|
|
467
|
+
// two UTF-16 units for a surrogate pair.
|
|
468
|
+
const width =
|
|
469
|
+
pos.side === 1 ? (pos.offset + 1 < text.length && isLowSurrogate(text.slice(pos.offset + 1, pos.offset + 2)) ? 2 : 1) : 0;
|
|
470
|
+
index = pos.offset + width;
|
|
383
471
|
childId = text.id;
|
|
384
472
|
node = (text.parent() as LoroList | undefined)?.parent() as LoroMap | undefined;
|
|
385
473
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// LiveDoc and editProseMirror against a fake ctx.crdt whose "server" is a
|
|
2
|
+
// LoroDoc, the way the host's store merges and serves a row's document.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { LoroDoc, LoroList, LoroMap, VersionVector } from "loro-crdt";
|
|
5
|
+
import { Schema } from "prosemirror-model";
|
|
6
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
7
|
+
import { editProseMirror, readProseMirror, writeProseMirror, configureProseMirrorMarks } from "./prosemirror/index";
|
|
8
|
+
import { openLiveDoc, type CrdtOps, type LiveDocContext } from "./server";
|
|
9
|
+
|
|
10
|
+
function fakeHost(server: LoroDoc | null) {
|
|
11
|
+
const applied: Uint8Array[] = [];
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
const ops = (label: string): CrdtOps => ({
|
|
14
|
+
async snapshot() {
|
|
15
|
+
calls.push(`${label}snapshot`);
|
|
16
|
+
return server ? server.export({ mode: "snapshot" }) : null;
|
|
17
|
+
},
|
|
18
|
+
async since(_e, _i, version) {
|
|
19
|
+
calls.push(`${label}since`);
|
|
20
|
+
return server ? server.export({ mode: "update", from: VersionVector.decode(version) }) : null;
|
|
21
|
+
},
|
|
22
|
+
async apply(_e, _i, update) {
|
|
23
|
+
calls.push(`${label}apply`);
|
|
24
|
+
applied.push(update);
|
|
25
|
+
server!.import(update);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const ctx: LiveDocContext = { crdt: { ...ops(""), unsafe: ops("unsafe:") } };
|
|
29
|
+
return { ctx, applied, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowDoc(): LoroDoc {
|
|
33
|
+
const d = new LoroDoc();
|
|
34
|
+
d.getMap("row").set("title", "start");
|
|
35
|
+
d.commit();
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("LiveDoc", () => {
|
|
40
|
+
test("an edit reaches the server", async () => {
|
|
41
|
+
const server = rowDoc();
|
|
42
|
+
const { ctx } = fakeHost(server);
|
|
43
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
44
|
+
await live.edit((doc) => doc.getMap("row").set("status", "done"));
|
|
45
|
+
expect(server.getMap("row").get("status")).toBe("done");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("edit pulls in what others wrote after the doc was opened", async () => {
|
|
49
|
+
const server = rowDoc();
|
|
50
|
+
const text = server.getMap("row").setContainer("body", new (await import("loro-crdt")).LoroText());
|
|
51
|
+
server.commit();
|
|
52
|
+
const { ctx } = fakeHost(server);
|
|
53
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
54
|
+
// Someone types at the server's copy meanwhile.
|
|
55
|
+
text.insert(0, "person ");
|
|
56
|
+
server.commit();
|
|
57
|
+
await live.edit((doc) => {
|
|
58
|
+
const t = doc.getMap("row").get("body") as { insert(i: number, s: string): void; length: number };
|
|
59
|
+
t.insert(t.length, "function");
|
|
60
|
+
});
|
|
61
|
+
expect(text.toString()).toBe("person function");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("commit sends only this function's ops, once", async () => {
|
|
65
|
+
const server = rowDoc();
|
|
66
|
+
const big = new LoroDoc();
|
|
67
|
+
big.import(server.export({ mode: "snapshot" }));
|
|
68
|
+
big.getMap("row").set("blob", "x".repeat(5000));
|
|
69
|
+
big.commit();
|
|
70
|
+
server.import(big.export({ mode: "update" }));
|
|
71
|
+
const { ctx, applied } = fakeHost(server);
|
|
72
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
73
|
+
live.doc.getMap("row").set("n", 1);
|
|
74
|
+
expect(await live.commit()).toBe(true);
|
|
75
|
+
expect(await live.commit()).toBe(false);
|
|
76
|
+
expect(applied.length).toBe(1);
|
|
77
|
+
expect(applied[0].length).toBeLessThan(200);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a row without a document is NOT_FOUND", async () => {
|
|
81
|
+
const { ctx } = fakeHost(null);
|
|
82
|
+
await expect(openLiveDoc(ctx, "Doc", "nope")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unsafe goes through ctx.crdt.unsafe", async () => {
|
|
86
|
+
const server = rowDoc();
|
|
87
|
+
const { ctx, calls } = fakeHost(server);
|
|
88
|
+
const live = await openLiveDoc(ctx, "Doc", "d1", { unsafe: true });
|
|
89
|
+
await live.edit((doc) => doc.getMap("row").set("k", 1));
|
|
90
|
+
expect(calls).toEqual(["unsafe:snapshot", "unsafe:since", "unsafe:apply"]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("editProseMirror", () => {
|
|
95
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
96
|
+
const para = (s: string) => schema.node("paragraph", null, s ? [schema.text(s)] : []);
|
|
97
|
+
|
|
98
|
+
function serverWithBody(): LoroDoc {
|
|
99
|
+
const server = new LoroDoc();
|
|
100
|
+
const body = server.getMap("row").setContainer("body", new LoroMap());
|
|
101
|
+
body.setContainer("attributes", new LoroMap());
|
|
102
|
+
body.setContainer("children", new LoroList());
|
|
103
|
+
server.commit();
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("a function's edit and a person's edit at the same time both land", async () => {
|
|
108
|
+
const server = serverWithBody();
|
|
109
|
+
configureProseMirrorMarks(server, schema);
|
|
110
|
+
writeProseMirror(server, "body", schema, schema.node("doc", null, [para("Hello")]));
|
|
111
|
+
const { ctx } = fakeHost(server);
|
|
112
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
113
|
+
// A person adds a paragraph after the function opened the doc.
|
|
114
|
+
const now = readProseMirror(server, "body", schema)!;
|
|
115
|
+
writeProseMirror(server, "body", schema, now.copy(now.content.append(schema.node("doc", null, [para("from a person")]).content)));
|
|
116
|
+
const written = await editProseMirror(live, "body", schema, (state) => state.tr.insertText("Summary: ", 1));
|
|
117
|
+
expect(written).not.toBeNull();
|
|
118
|
+
const result: string[] = [];
|
|
119
|
+
readProseMirror(server, "body", schema)!.forEach((n) => result.push(n.textContent));
|
|
120
|
+
expect(result).toEqual(["Summary: Hello", "from a person"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an empty document starts from the schema's empty document", async () => {
|
|
124
|
+
const server = serverWithBody();
|
|
125
|
+
const { ctx } = fakeHost(server);
|
|
126
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
127
|
+
await editProseMirror(live, "body", schema, (state) => state.tr.insertText("first", 1));
|
|
128
|
+
expect(readProseMirror(server, "body", schema)!.textContent).toBe("first");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returning nothing changes nothing", async () => {
|
|
132
|
+
const server = serverWithBody();
|
|
133
|
+
const { ctx, applied } = fakeHost(server);
|
|
134
|
+
const live = await openLiveDoc(ctx, "Doc", "d1");
|
|
135
|
+
expect(await editProseMirror(live, "body", schema, () => undefined)).toBeNull();
|
|
136
|
+
expect(applied.length).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row's live CRDT document in a server function (an action), through
|
|
3
|
+
* `ctx.crdt`:
|
|
4
|
+
*
|
|
5
|
+
* import { openLiveDoc } from "@pylonsync/loro/server";
|
|
6
|
+
*
|
|
7
|
+
* const live = await openLiveDoc(ctx, "Doc", id);
|
|
8
|
+
* await live.edit((doc) => doc.getMap("row").set("status", "done"));
|
|
9
|
+
*
|
|
10
|
+
* `edit` pulls in what others wrote since the doc was opened, runs the
|
|
11
|
+
* change, and sends only this function's ops. Everyone editing sees them as
|
|
12
|
+
* they land. For a ProseMirror field, `editProseMirror` in
|
|
13
|
+
* `@pylonsync/loro/prosemirror` does the same with a ProseMirror document.
|
|
14
|
+
*
|
|
15
|
+
* Each `openLiveDoc` is a new Loro peer, so two functions editing one row
|
|
16
|
+
* at once merge like two people do.
|
|
17
|
+
*/
|
|
18
|
+
import { LoroDoc } from "loro-crdt";
|
|
19
|
+
import { exportOwnOps, ownCounter } from "./outbox";
|
|
20
|
+
|
|
21
|
+
/** The byte operations `ctx.crdt` provides (`@pylonsync/functions` `Crdt`). */
|
|
22
|
+
export interface CrdtOps {
|
|
23
|
+
snapshot(entity: string, id: string): Promise<Uint8Array | null>;
|
|
24
|
+
since(entity: string, id: string, version: Uint8Array): Promise<Uint8Array | null>;
|
|
25
|
+
apply(entity: string, id: string, update: Uint8Array): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LiveDocContext {
|
|
29
|
+
crdt: CrdtOps & { unsafe: CrdtOps };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OpenLiveDocOptions {
|
|
33
|
+
/** Server trust even under strict function policies (`ctx.crdt.unsafe`). */
|
|
34
|
+
unsafe?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class LiveDoc {
|
|
38
|
+
readonly doc: LoroDoc;
|
|
39
|
+
readonly entity: string;
|
|
40
|
+
readonly id: string;
|
|
41
|
+
private readonly ops: CrdtOps;
|
|
42
|
+
/** This peer's op counter the server has merged (exclusive end). */
|
|
43
|
+
private confirmed = 0;
|
|
44
|
+
|
|
45
|
+
private constructor(ops: CrdtOps, entity: string, id: string, doc: LoroDoc) {
|
|
46
|
+
this.ops = ops;
|
|
47
|
+
this.entity = entity;
|
|
48
|
+
this.id = id;
|
|
49
|
+
this.doc = doc;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Open the row's document. Throws NOT_FOUND when there is none to open. */
|
|
53
|
+
static async open(ctx: LiveDocContext, entity: string, id: string, options: OpenLiveDocOptions = {}): Promise<LiveDoc> {
|
|
54
|
+
const ops = options.unsafe ? ctx.crdt.unsafe : ctx.crdt;
|
|
55
|
+
const snapshot = await ops.snapshot(entity, id);
|
|
56
|
+
if (!snapshot) {
|
|
57
|
+
throw Object.assign(new Error(`${entity} ${id} has no document to open`), { code: "NOT_FOUND" });
|
|
58
|
+
}
|
|
59
|
+
const doc = new LoroDoc();
|
|
60
|
+
doc.import(snapshot);
|
|
61
|
+
return new LiveDoc(ops, entity, id, doc);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pull in what others wrote since this doc last heard from the server. */
|
|
65
|
+
async refresh(): Promise<void> {
|
|
66
|
+
const update = await this.ops.since(this.entity, this.id, this.doc.oplogVersion().encode());
|
|
67
|
+
if (update && update.length > 0) this.doc.import(update);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Send this function's ops the server has not merged yet. Commits pending
|
|
72
|
+
* changes first. Returns false when there was nothing to send.
|
|
73
|
+
*/
|
|
74
|
+
async commit(): Promise<boolean> {
|
|
75
|
+
this.doc.commit();
|
|
76
|
+
const target = ownCounter(this.doc);
|
|
77
|
+
if (target <= this.confirmed) return false;
|
|
78
|
+
await this.ops.apply(this.entity, this.id, exportOwnOps(this.doc, this.confirmed));
|
|
79
|
+
this.confirmed = target;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Refresh, run `change` on the doc, and send the result. */
|
|
84
|
+
async edit(change: (doc: LoroDoc) => void | Promise<void>): Promise<void> {
|
|
85
|
+
await this.refresh();
|
|
86
|
+
await change(this.doc);
|
|
87
|
+
await this.commit();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** See {@link LiveDoc.open}. */
|
|
92
|
+
export function openLiveDoc(
|
|
93
|
+
ctx: LiveDocContext,
|
|
94
|
+
entity: string,
|
|
95
|
+
id: string,
|
|
96
|
+
options: OpenLiveDocOptions = {},
|
|
97
|
+
): Promise<LiveDoc> {
|
|
98
|
+
return LiveDoc.open(ctx, entity, id, options);
|
|
99
|
+
}
|