@pylonsync/loro 0.22.40 → 0.22.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/loro",
3
- "version": "0.22.40",
3
+ "version": "0.22.41",
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.40",
24
- "@pylonsync/sync": "0.22.40",
25
+ "@pylonsync/react": "0.22.41",
26
+ "@pylonsync/sync": "0.22.41",
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 this.doc.oplogVersion().get(this.doc.peerIdStr) ?? 0;
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
- // Start from everything the doc has, then roll this peer back to what
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
+ }
@@ -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 schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
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
- /** A server doc with the field's container, and two clients synced to it. */
26
- function setup() {
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
+ });
@@ -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
- state.doc.commit({ origin: "sys:init", timestamp: Date.now() });
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
- // 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.
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((tr) => tr.docChanged)
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
- // 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;
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 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.
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
- if (!view.hasFocus()) return null;
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: 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;
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
- if (offset === 0) return children.getCursor(i) ?? null;
333
- offset -= 1;
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(cursor: Cursor, doc: LoroDoc, containerId: ContainerID, mapping: Mapping): number | null {
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). 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);
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
+ }