@pylonsync/loro 0.22.39 → 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.39",
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"
@@ -13,21 +13,25 @@
13
13
  "./wire": "./src/wire.ts",
14
14
  "./registry": "./src/registry.ts",
15
15
  "./prosemirror": "./src/prosemirror/index.ts",
16
- "./prosemirror/react": "./src/prosemirror/react.ts"
16
+ "./prosemirror/react": "./src/prosemirror/react.ts",
17
+ "./prosemirror/sync": "./src/prosemirror/sync.ts",
18
+ "./server": "./src/server.ts",
19
+ "./prosemirror/cursors": "./src/prosemirror/cursors.ts"
17
20
  },
18
21
  "scripts": {
19
22
  "check": "tsc -p tsconfig.json --noEmit"
20
23
  },
21
24
  "dependencies": {
22
- "@pylonsync/react": "0.22.39",
23
- "@pylonsync/sync": "0.22.39",
25
+ "@pylonsync/react": "0.22.41",
26
+ "@pylonsync/sync": "0.22.41",
24
27
  "loro-crdt": "^1.10.6"
25
28
  },
26
29
  "peerDependencies": {
27
30
  "react": ">=19.0.0",
28
31
  "loro-prosemirror": ">=0.4.4 <1.0.0",
29
32
  "prosemirror-model": "^1.22.0",
30
- "prosemirror-state": "^1.4.0"
33
+ "prosemirror-state": "^1.4.0",
34
+ "prosemirror-view": "^1.33.0"
31
35
  },
32
36
  "devDependencies": {
33
37
  "@types/react": "^19.0.0",
@@ -35,7 +39,9 @@
35
39
  "loro-prosemirror": "^0.4.4",
36
40
  "prosemirror-model": "^1.25.0",
37
41
  "prosemirror-state": "^1.4.3",
38
- "prosemirror-schema-basic": "^1.2.4"
42
+ "prosemirror-schema-basic": "^1.2.4",
43
+ "prosemirror-view": "^1.42.3",
44
+ "@happy-dom/global-registrator": "^20.10.0"
39
45
  },
40
46
  "peerDependenciesMeta": {
41
47
  "loro-prosemirror": {
@@ -46,6 +52,9 @@
46
52
  },
47
53
  "prosemirror-state": {
48
54
  "optional": true
55
+ },
56
+ "prosemirror-view": {
57
+ "optional": true
49
58
  }
50
59
  },
51
60
  "license": "MIT"
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
+ }
@@ -15,7 +15,7 @@
15
15
  * const doc = useLoroDoc("Doc", id);
16
16
  * configureProseMirrorMarks(doc, schema);
17
17
  * const containerId = await waitForProseMirrorContainer(doc, "body");
18
- * const plugin = LoroSyncPlugin({ doc, containerId });
18
+ * const plugin = ProseMirrorSyncPlugin({ doc, containerId }); // ./sync
19
19
  *
20
20
  * In a server function, read and change the document without an editor
21
21
  * with `readProseMirror` and `writeProseMirror`.
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
5
5
  * if (!containerId) return <Loading />;
6
- * const plugins = [LoroSyncPlugin({ doc, containerId }), LoroUndoPlugin({ doc })];
6
+ * const plugins = [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })];
7
7
  *
8
8
  * `containerId` is null until the server's snapshot brings the field's
9
9
  * container; mount the editor after that (see ./index.ts for why a client