@pylonsync/loro 0.22.34 → 0.22.35
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/LICENSE +21 -0
- package/package.json +27 -7
- package/src/prosemirror.test.ts +134 -0
- package/src/prosemirror.ts +115 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Campbell Venture Studio, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.35",
|
|
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"
|
|
@@ -11,21 +11,41 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./src/index.ts",
|
|
13
13
|
"./wire": "./src/wire.ts",
|
|
14
|
-
"./registry": "./src/registry.ts"
|
|
14
|
+
"./registry": "./src/registry.ts",
|
|
15
|
+
"./prosemirror": "./src/prosemirror.ts"
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
18
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
|
-
"@pylonsync/react": "0.22.
|
|
21
|
-
"@pylonsync/sync": "0.22.
|
|
21
|
+
"@pylonsync/react": "0.22.35",
|
|
22
|
+
"@pylonsync/sync": "0.22.35",
|
|
22
23
|
"loro-crdt": "^1.10.6"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
|
-
"react": ">=19.0.0"
|
|
26
|
+
"react": ">=19.0.0",
|
|
27
|
+
"loro-prosemirror": ">=0.4.4 <1.0.0",
|
|
28
|
+
"prosemirror-model": "^1.22.0",
|
|
29
|
+
"prosemirror-state": "^1.4.0"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"@types/react": "^19.0.0",
|
|
29
|
-
"typescript": "^5.5"
|
|
30
|
-
|
|
33
|
+
"typescript": "^5.5",
|
|
34
|
+
"loro-prosemirror": "^0.4.4",
|
|
35
|
+
"prosemirror-model": "^1.25.0",
|
|
36
|
+
"prosemirror-state": "^1.4.3",
|
|
37
|
+
"prosemirror-schema-basic": "^1.2.4"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"loro-prosemirror": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"prosemirror-model": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"prosemirror-state": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT"
|
|
31
51
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The ProseMirror helpers against the row-doc contract: the server creates
|
|
2
|
+
// a prosemirror field's container under the "row" map when the row is
|
|
3
|
+
// inserted (crates/crdt/src/prosemirror.rs); clients only bind to it.
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { LoroDoc, LoroMap } from "loro-crdt";
|
|
6
|
+
import { Schema } from "prosemirror-model";
|
|
7
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
8
|
+
import {
|
|
9
|
+
configureProseMirrorMarks,
|
|
10
|
+
findProseMirrorContainer,
|
|
11
|
+
readProseMirror,
|
|
12
|
+
waitForProseMirrorContainer,
|
|
13
|
+
writeProseMirror,
|
|
14
|
+
} from "./prosemirror";
|
|
15
|
+
|
|
16
|
+
// The basic schema plus a mark with an id, like a suggestion.
|
|
17
|
+
const schema = new Schema({
|
|
18
|
+
nodes: basic.spec.nodes,
|
|
19
|
+
marks: basic.spec.marks.addToEnd("suggestion", {
|
|
20
|
+
attrs: { id: { default: null } },
|
|
21
|
+
inclusive: false,
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const doc = (...paragraphs: Array<Array<{ text: string; marks?: string[] }>>) =>
|
|
26
|
+
schema.node(
|
|
27
|
+
"doc",
|
|
28
|
+
null,
|
|
29
|
+
paragraphs.map((runs) =>
|
|
30
|
+
schema.node(
|
|
31
|
+
"paragraph",
|
|
32
|
+
null,
|
|
33
|
+
runs.map((r) =>
|
|
34
|
+
schema.text(
|
|
35
|
+
r.text,
|
|
36
|
+
(r.marks ?? []).map((m) => (m === "suggestion" ? schema.mark("suggestion", { id: "s1" }) : schema.mark(m))),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function peer(id: bigint): LoroDoc {
|
|
44
|
+
const d = new LoroDoc();
|
|
45
|
+
d.setPeerId(id);
|
|
46
|
+
configureProseMirrorMarks(d, schema);
|
|
47
|
+
return d;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A row doc as the server makes it on insert: an empty container under row.body. */
|
|
51
|
+
function serverRow(id: bigint): LoroDoc {
|
|
52
|
+
const d = peer(id);
|
|
53
|
+
d.getMap("row").set("title", "t");
|
|
54
|
+
d.getMap("row").setContainer("body", new LoroMap());
|
|
55
|
+
d.commit();
|
|
56
|
+
return d;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("ProseMirror documents in a row", () => {
|
|
60
|
+
test("the lookup never creates the container", () => {
|
|
61
|
+
const d = peer(1n);
|
|
62
|
+
expect(findProseMirrorContainer(d, "body")).toBeNull();
|
|
63
|
+
expect(d.getMap("row").get("body")).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The case a review reproduced: useLoroDoc hands the editor an empty doc
|
|
67
|
+
// before the server's snapshot arrives. A container made then (cid:0@1)
|
|
68
|
+
// loses the key to the server's (cid:0@2), and the editor's edits never
|
|
69
|
+
// reach the row. Waiting binds to the server's container instead.
|
|
70
|
+
test("an editor that loads before the snapshot binds to the server's container", async () => {
|
|
71
|
+
const client = peer(1n);
|
|
72
|
+
const bound = waitForProseMirrorContainer(client, "body");
|
|
73
|
+
const server = serverRow(2n);
|
|
74
|
+
client.import(server.export({ mode: "snapshot" }));
|
|
75
|
+
const id = await bound;
|
|
76
|
+
expect(id).toBe(findProseMirrorContainer(server, "body")!);
|
|
77
|
+
expect(id).toContain("@2");
|
|
78
|
+
|
|
79
|
+
// The editor's first write lands in the container the server reads.
|
|
80
|
+
writeProseMirror(client, "body", schema, doc([{ text: "Typed " }, { text: "here", marks: ["strong"] }]));
|
|
81
|
+
server.import(client.export({ mode: "update", from: server.oplogVersion() }));
|
|
82
|
+
expect(readProseMirror(server, "body", schema)?.toJSON()).toEqual(
|
|
83
|
+
doc([{ text: "Typed " }, { text: "here", marks: ["strong"] }]).toJSON(),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("waiting resolves at once when the container is there, and can be stopped", async () => {
|
|
88
|
+
const d = serverRow(2n);
|
|
89
|
+
expect(await waitForProseMirrorContainer(d, "body")).toBe(findProseMirrorContainer(d, "body")!);
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const pending = waitForProseMirrorContainer(peer(1n), "body", { signal: controller.signal });
|
|
92
|
+
controller.abort(new Error("closed"));
|
|
93
|
+
await expect(pending).rejects.toThrow("closed");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a write into a doc without the container throws instead of creating it", () => {
|
|
97
|
+
const d = peer(1n);
|
|
98
|
+
expect(() => writeProseMirror(d, "body", schema, doc([{ text: "x" }]))).toThrow("the server creates it");
|
|
99
|
+
expect(d.getMap("row").get("body")).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("the server's empty container reads as null and takes a document", () => {
|
|
103
|
+
const d = serverRow(2n);
|
|
104
|
+
expect(readProseMirror(d, "body", schema)).toBeNull();
|
|
105
|
+
const source = doc([{ text: "Hello " }, { text: "world", marks: ["strong", "suggestion"] }], [{ text: "Second" }]);
|
|
106
|
+
writeProseMirror(d, "body", schema, source);
|
|
107
|
+
expect(readProseMirror(d, "body", schema)?.toJSON()).toEqual(source.toJSON());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("concurrent edits from two peers converge", () => {
|
|
111
|
+
const a = serverRow(1n);
|
|
112
|
+
writeProseMirror(a, "body", schema, doc([{ text: "One" }], [{ text: "Two" }]));
|
|
113
|
+
const b = peer(2n);
|
|
114
|
+
b.import(a.export({ mode: "snapshot" }));
|
|
115
|
+
writeProseMirror(a, "body", schema, doc([{ text: "One!" }], [{ text: "Two" }]));
|
|
116
|
+
writeProseMirror(b, "body", schema, doc([{ text: "One" }], [{ text: "Two?" }]));
|
|
117
|
+
a.import(b.export({ mode: "update" }));
|
|
118
|
+
b.import(a.export({ mode: "update" }));
|
|
119
|
+
const left = readProseMirror(a, "body", schema)?.textContent;
|
|
120
|
+
expect(left).toBe(readProseMirror(b, "body", schema)?.textContent);
|
|
121
|
+
expect(left).toBe("One!Two?");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a server write reaches an editor's copy", () => {
|
|
125
|
+
const editor = serverRow(1n);
|
|
126
|
+
writeProseMirror(editor, "body", schema, doc([{ text: "Draft" }]));
|
|
127
|
+
const server = peer(2n);
|
|
128
|
+
server.import(editor.export({ mode: "snapshot" }));
|
|
129
|
+
const current = readProseMirror(server, "body", schema)!;
|
|
130
|
+
writeProseMirror(server, "body", schema, current.copy(current.content.append(doc([{ text: "Added by the server" }]).content)));
|
|
131
|
+
editor.import(server.export({ mode: "update", from: editor.oplogVersion() }));
|
|
132
|
+
expect(readProseMirror(editor, "body", schema)?.textContent).toBe("DraftAdded by the server");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProseMirror (Tiptap) documents in a Pylon row.
|
|
3
|
+
*
|
|
4
|
+
* A field declared `field.richtext().crdt("prosemirror")` keeps its
|
|
5
|
+
* document under its key of the row's `"row"` map, in the layout the
|
|
6
|
+
* `loro-prosemirror` binding writes. The server reads that tree and
|
|
7
|
+
* projects it to the row as ProseMirror JSON (crates/crdt).
|
|
8
|
+
*
|
|
9
|
+
* The server creates the field's container when the row is inserted.
|
|
10
|
+
* Clients never create it: `useLoroDoc` returns an empty doc before the
|
|
11
|
+
* server's snapshot arrives, and a container made in it would lose the
|
|
12
|
+
* key to the server's, leaving the editor bound to a container nothing
|
|
13
|
+
* reads. Wait for the server's instead, then bind:
|
|
14
|
+
*
|
|
15
|
+
* const doc = useLoroDoc("Doc", id);
|
|
16
|
+
* configureProseMirrorMarks(doc, schema);
|
|
17
|
+
* const containerId = await waitForProseMirrorContainer(doc, "body");
|
|
18
|
+
* const plugin = LoroSyncPlugin({ doc, containerId });
|
|
19
|
+
*
|
|
20
|
+
* In a server function, read and change the document without an editor
|
|
21
|
+
* with `readProseMirror` and `writeProseMirror`.
|
|
22
|
+
*
|
|
23
|
+
* A mark type holds one value per character: two marks of one type
|
|
24
|
+
* cannot overlap. Keep overlapping anchors (comment threads) as Loro
|
|
25
|
+
* cursors next to the document and draw them as decorations.
|
|
26
|
+
*/
|
|
27
|
+
import type { ContainerID, LoroDoc, LoroMap } from "loro-crdt";
|
|
28
|
+
import { createNodeFromLoroObj, updateLoroToPmState } from "loro-prosemirror";
|
|
29
|
+
import type { Node, Schema } from "prosemirror-model";
|
|
30
|
+
import { EditorState } from "prosemirror-state";
|
|
31
|
+
|
|
32
|
+
/** The root map every Pylon row doc keeps its fields in (crates/crdt). */
|
|
33
|
+
const ROOT_MAP = "row";
|
|
34
|
+
|
|
35
|
+
function fieldMap(doc: LoroDoc, field: string): LoroMap | null {
|
|
36
|
+
const value = doc.getMap(ROOT_MAP).get(field) as { kind?: () => string } | undefined;
|
|
37
|
+
return value && typeof value.kind === "function" && value.kind() === "Map"
|
|
38
|
+
? (value as unknown as LoroMap)
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The container of `field`'s document, or null when the doc does not hold
|
|
44
|
+
* it yet (the server's snapshot has not arrived). Never creates it.
|
|
45
|
+
*/
|
|
46
|
+
export function findProseMirrorContainer(doc: LoroDoc, field: string): ContainerID | null {
|
|
47
|
+
return fieldMap(doc, field)?.id ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The container of `field`'s document, once the doc holds it: at once when
|
|
52
|
+
* it is there, else when an import or edit brings it. Pass `signal` to stop
|
|
53
|
+
* waiting (the promise then rejects with the signal's reason).
|
|
54
|
+
*/
|
|
55
|
+
export function waitForProseMirrorContainer(
|
|
56
|
+
doc: LoroDoc,
|
|
57
|
+
field: string,
|
|
58
|
+
options: { signal?: AbortSignal } = {},
|
|
59
|
+
): Promise<ContainerID> {
|
|
60
|
+
const found = findProseMirrorContainer(doc, field);
|
|
61
|
+
if (found) return Promise.resolve(found);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const { signal } = options;
|
|
64
|
+
const stop = doc.subscribe(() => {
|
|
65
|
+
const id = findProseMirrorContainer(doc, field);
|
|
66
|
+
if (!id) return;
|
|
67
|
+
stop();
|
|
68
|
+
signal?.removeEventListener("abort", onAbort);
|
|
69
|
+
resolve(id);
|
|
70
|
+
});
|
|
71
|
+
function onAbort() {
|
|
72
|
+
stop();
|
|
73
|
+
reject(signal?.reason ?? new Error("stopped waiting for the document"));
|
|
74
|
+
}
|
|
75
|
+
if (signal?.aborted) onAbort();
|
|
76
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tell this copy of the doc how each mark of `schema` behaves at its
|
|
82
|
+
* edges, as the binding expects: a mark that is `inclusive` (the default)
|
|
83
|
+
* extends to text typed right after it. Every copy that writes marks
|
|
84
|
+
* needs this; Loro refuses a mark it has no style for.
|
|
85
|
+
*/
|
|
86
|
+
export function configureProseMirrorMarks(doc: LoroDoc, schema: Schema): void {
|
|
87
|
+
const styles: Record<string, { expand: "after" | "none" }> = {};
|
|
88
|
+
for (const [name, type] of Object.entries(schema.marks)) {
|
|
89
|
+
styles[name] = { expand: type.spec.inclusive !== false ? "after" : "none" };
|
|
90
|
+
}
|
|
91
|
+
doc.configTextStyle(styles);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The document in `field` as a ProseMirror node, or null when there is none. */
|
|
95
|
+
export function readProseMirror(doc: LoroDoc, field: string, schema: Schema): Node | null {
|
|
96
|
+
const map = fieldMap(doc, field);
|
|
97
|
+
if (!map || map.get("nodeName") == null) return null;
|
|
98
|
+
return createNodeFromLoroObj(schema, map as never, new Map()) as Node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write `node` into `field` as a diff against what the doc holds, so edits
|
|
103
|
+
* other peers make at the same time still merge. Commits. Configures the
|
|
104
|
+
* schema's marks first. Throws when the doc has no container for the field
|
|
105
|
+
* (a row inserted by a server older than the field, or a doc that has not
|
|
106
|
+
* loaded); it never creates one.
|
|
107
|
+
*/
|
|
108
|
+
export function writeProseMirror(doc: LoroDoc, field: string, schema: Schema, node: Node): void {
|
|
109
|
+
const containerId = findProseMirrorContainer(doc, field);
|
|
110
|
+
if (!containerId) {
|
|
111
|
+
throw new Error(`The doc has no container for ${field}; the server creates it when the row is inserted.`);
|
|
112
|
+
}
|
|
113
|
+
configureProseMirrorMarks(doc, schema);
|
|
114
|
+
updateLoroToPmState(doc as never, new Map(), EditorState.create({ schema, doc: node }), containerId);
|
|
115
|
+
}
|