@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 +15 -6
- 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 +37 -2
- package/src/prosemirror/react.ts +1 -1
- package/src/prosemirror/sync.test.ts +323 -0
- package/src/prosemirror/sync.ts +490 -0
- 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.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.
|
|
23
|
-
"@pylonsync/sync": "0.22.
|
|
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
|
|
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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* const doc = useLoroDoc("Doc", id);
|
|
16
16
|
* configureProseMirrorMarks(doc, schema);
|
|
17
17
|
* const containerId = await waitForProseMirrorContainer(doc, "body");
|
|
18
|
-
* const plugin =
|
|
18
|
+
* const plugin = ProseMirrorSyncPlugin({ doc, containerId }); // ./sync
|
|
19
19
|
*
|
|
20
20
|
* In a server function, read and change the document without an editor
|
|
21
21
|
* with `readProseMirror` and `writeProseMirror`.
|
|
@@ -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
|
+
}
|
package/src/prosemirror/react.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
|
|
5
5
|
* if (!containerId) return <Loading />;
|
|
6
|
-
* const plugins = [
|
|
6
|
+
* const plugins = [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })];
|
|
7
7
|
*
|
|
8
8
|
* `containerId` is null until the server's snapshot brings the field's
|
|
9
9
|
* container; mount the editor after that (see ./index.ts for why a client
|