@jxsuite/collab 0.5.0 → 0.6.0
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 +2 -2
- package/src/op-bridge.ts +128 -6
- package/src/schema.ts +158 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:coverage": "bun test --isolate --coverage"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@jxsuite/schema": "^1.
|
|
35
|
+
"@jxsuite/schema": "^1.5.0",
|
|
36
36
|
"lib0": "^0.2.99",
|
|
37
37
|
"y-protocols": "^1.0.6",
|
|
38
38
|
"yjs": "13.6.27"
|
package/src/op-bridge.ts
CHANGED
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
import * as Y from "yjs";
|
|
15
15
|
import type { JxPath } from "@jxsuite/schema/types";
|
|
16
16
|
import type { JxDocOp } from "./ops.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
applyTextEdit,
|
|
19
|
+
GRANULAR_OBJECT_KEYS,
|
|
20
|
+
mergeYObject,
|
|
21
|
+
resolveYPath,
|
|
22
|
+
TEXT_KEYS,
|
|
23
|
+
toYChild,
|
|
24
|
+
toYChildren,
|
|
25
|
+
toYObject,
|
|
26
|
+
yValueToJson,
|
|
27
|
+
} from "./schema.ts";
|
|
18
28
|
|
|
19
29
|
/** Origin of Y transactions produced by mirroring the local tab's ops. */
|
|
20
30
|
export const LOCAL_ORIGIN = "jx-local";
|
|
@@ -59,8 +69,47 @@ function cloneJson<T>(value: T): T {
|
|
|
59
69
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
function
|
|
63
|
-
return typeof
|
|
72
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Write a node key, preserving CRDT identity wherever the schema stores it granularly.
|
|
78
|
+
*
|
|
79
|
+
* Studio's mutators record whole-value `set-key` ops — `mutateUpdateStyle` replaces the entire
|
|
80
|
+
* style object, an inline-edit commit replaces the entire `textContent`. Writing those straight
|
|
81
|
+
* through with `Y.Map.set` mints a fresh Y type each time and throws away whatever a peer
|
|
82
|
+
* concurrently did to the old one, which is exactly how "two people typing in one paragraph" became
|
|
83
|
+
* last-writer-wins.
|
|
84
|
+
*
|
|
85
|
+
* So a whole-value op is diffed down onto the existing structure: text through {@link applyTextEdit}
|
|
86
|
+
* (character-level), granular objects through {@link mergeYObject} (per-property). Only a TYPE
|
|
87
|
+
* change — string text becoming a `$ref`, an object becoming a scalar — falls back to replacement,
|
|
88
|
+
* because then there is no shared structure left to preserve.
|
|
89
|
+
*/
|
|
90
|
+
function setNodeKey(node: Y.Map<unknown>, key: string, value: unknown): void {
|
|
91
|
+
const existing = node.get(key);
|
|
92
|
+
if (key === "children" && Array.isArray(value)) {
|
|
93
|
+
node.set(key, toYChildren(value));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (TEXT_KEYS.has(key) && typeof value === "string") {
|
|
97
|
+
if (existing instanceof Y.Text) {
|
|
98
|
+
applyTextEdit(existing, value);
|
|
99
|
+
} else {
|
|
100
|
+
node.set(key, new Y.Text(value));
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
|
|
105
|
+
if (existing instanceof Y.Map) {
|
|
106
|
+
mergeYObject(existing, value);
|
|
107
|
+
} else {
|
|
108
|
+
node.set(key, toYObject(value));
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
node.set(key, cloneJson(value));
|
|
64
113
|
}
|
|
65
114
|
|
|
66
115
|
function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
@@ -69,10 +118,8 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
|
69
118
|
const node = requireNode(doc, op.path);
|
|
70
119
|
if (op.value === undefined) {
|
|
71
120
|
node.delete(op.key);
|
|
72
|
-
} else if (op.key === "children" && Array.isArray(op.value)) {
|
|
73
|
-
node.set(op.key, toYChildren(op.value));
|
|
74
121
|
} else {
|
|
75
|
-
node
|
|
122
|
+
setNodeKey(node, op.key, op.value);
|
|
76
123
|
}
|
|
77
124
|
return;
|
|
78
125
|
}
|
|
@@ -97,6 +144,14 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
|
97
144
|
if (op.index < 0 || op.index >= children.length) {
|
|
98
145
|
throw new CollabPathError([...op.parentPath, "children", op.index]);
|
|
99
146
|
}
|
|
147
|
+
// Text child → text child: edit the existing Y.Text in place. A delete+insert would replace the
|
|
148
|
+
// Whole element and discard a peer's concurrent characters, which is the same clobber
|
|
149
|
+
// `setNodeKey` avoids for `textContent` — bare-string children are the other half of prose.
|
|
150
|
+
const existing = children.get(op.index);
|
|
151
|
+
if (existing instanceof Y.Text && typeof op.node === "string") {
|
|
152
|
+
applyTextEdit(existing, op.node);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
100
155
|
children.delete(op.index, 1);
|
|
101
156
|
children.insert(op.index, [toYChild(op.node)]);
|
|
102
157
|
return;
|
|
@@ -139,6 +194,34 @@ export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: unkn
|
|
|
139
194
|
}, origin);
|
|
140
195
|
}
|
|
141
196
|
|
|
197
|
+
/**
|
|
198
|
+
* When `path` addresses a granular container (or something nested inside one), the node that owns
|
|
199
|
+
* it and the key it sits under; otherwise null.
|
|
200
|
+
*
|
|
201
|
+
* The event path is relative to the structure map, so the first segment matching a granular key
|
|
202
|
+
* marks the boundary: everything before it is the node path, and anything after it is interior
|
|
203
|
+
* detail the op log does not model.
|
|
204
|
+
*
|
|
205
|
+
* `children`/index segments need no special handling: they are not granular keys, so the scan
|
|
206
|
+
* passes over them and the returned node path keeps the full prefix — `["children", 0, "style"]`
|
|
207
|
+
* resolves to the style of the node at `children/0`, not of the root.
|
|
208
|
+
*/
|
|
209
|
+
function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
|
|
210
|
+
for (let i = 0; i < path.length; i++) {
|
|
211
|
+
const segment = path[i];
|
|
212
|
+
if (typeof segment !== "string") {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (GRANULAR_OBJECT_KEYS.has(segment)) {
|
|
216
|
+
return { key: segment, nodePath: path.slice(0, i) };
|
|
217
|
+
}
|
|
218
|
+
if (TEXT_KEYS.has(segment)) {
|
|
219
|
+
return { key: segment, nodePath: path.slice(0, i) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
142
225
|
function isStructurePath(segments: readonly (string | number)[]): segments is JxPath {
|
|
143
226
|
return segments.every((seg) => typeof seg === "string" || typeof seg === "number");
|
|
144
227
|
}
|
|
@@ -179,6 +262,45 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
179
262
|
const ops: JxDocOp[] = [];
|
|
180
263
|
for (const event of events) {
|
|
181
264
|
const path = event.path as JxPath;
|
|
265
|
+
/*
|
|
266
|
+
* A remote edit inside a granular container (a `Y.Text` for prose, a nested `Y.Map` for
|
|
267
|
+
* style/attributes/$props) reports its event at the CONTAINER's path, not the node's. Studio's op
|
|
268
|
+
* vocabulary addresses nodes, so those collapse back to one whole-value op for the owning key —
|
|
269
|
+
* the same shape a local mutator would have produced. Granularity buys merge; it deliberately does
|
|
270
|
+
* not leak into the op log, the canvas patcher, or the history ring.
|
|
271
|
+
*/
|
|
272
|
+
const granular = granularOwner(path);
|
|
273
|
+
if (granular) {
|
|
274
|
+
const { doc } = event.target as { doc?: Y.Doc | null };
|
|
275
|
+
if (!doc) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const container = resolveYPath(doc, [...granular.nodePath, granular.key]);
|
|
279
|
+
if (container === undefined) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
ops.push({
|
|
283
|
+
key: granular.key,
|
|
284
|
+
op: "set-key",
|
|
285
|
+
path: granular.nodePath,
|
|
286
|
+
value: yValueToJson(container),
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (event instanceof Y.YTextEvent) {
|
|
291
|
+
// A bare-string child: the text IS the child, so it round-trips as a child replacement.
|
|
292
|
+
const index = path.at(-1);
|
|
293
|
+
if (path.at(-2) === "children" && typeof index === "number") {
|
|
294
|
+
ops.push({
|
|
295
|
+
index,
|
|
296
|
+
node: (event.target as Y.Text).toString(),
|
|
297
|
+
op: "set-child",
|
|
298
|
+
parentPath: path.slice(0, -2),
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
182
304
|
if (event instanceof Y.YMapEvent) {
|
|
183
305
|
const target = event.target as Y.Map<unknown>;
|
|
184
306
|
for (const [key, change] of event.changes.keys) {
|
package/src/schema.ts
CHANGED
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
* Layout (all top-level types, created on demand): - `meta: Y.Map` — schemaVersion,
|
|
5
5
|
* structureSeeded, canonical ("structure" | "source"), canonicalRev (representation-flip counter —
|
|
6
6
|
* distinct from the provider's wire docEpoch), sourceFormat. - `frontmatter: Y.Map` — per-field
|
|
7
|
-
* plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode.
|
|
8
|
-
* Y.
|
|
9
|
-
* every other key holds a plain deep-JSON value merged whole (LWW). The CRDT granularity
|
|
10
|
-
* deliberately equals Studio's op-log granularity — mutators emit whole-value set-keys and children
|
|
11
|
-
* splices, so deeper Y types would add merge surface nothing can target. - `source: Y.Text` — the
|
|
12
|
-
* serialized source. THE authoritative file content: providers persist
|
|
7
|
+
* plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode. - `source:
|
|
8
|
+
* Y.Text` — the serialized source. THE authoritative file content: providers persist
|
|
13
9
|
* `getText("source").toString()` and never parse or serialize Jx documents themselves; the
|
|
14
10
|
* structure tree is a Studio-side projection kept in sync by the bridge.
|
|
15
11
|
*
|
|
12
|
+
* **CRDT granularity is deliberately FINER than Studio's op-log granularity.** Every node is a
|
|
13
|
+
* Y.Map; within it, "children" is a Y.Array, {@link TEXT_KEYS} are `Y.Text`,
|
|
14
|
+
* {@link GRANULAR_OBJECT_KEYS} are nested Y.Maps, and everything else is a whole-JSON LWW value.
|
|
15
|
+
*
|
|
16
|
+
* The two granularities used to match, on the reasoning that mutators emit whole-value set-keys so
|
|
17
|
+
* deeper Y types would add merge surface nothing targets. That reasoning was wrong: it conflated
|
|
18
|
+
* what an EDIT addresses with what a MERGE must resolve. Two authors typing in one paragraph, or
|
|
19
|
+
* setting `color` and `padding` on one element, produce whole-value ops that a whole-value CRDT can
|
|
20
|
+
* only resolve by discarding one of them. The bridge therefore diffs a whole-value op down onto the
|
|
21
|
+
* granular representation ({@link applyTextEdit}, {@link mergeYObject}) and {@link yValueToJson}
|
|
22
|
+
* reassembles whole values on the way out — so `JxDocOp`, the canvas patcher and the history ring
|
|
23
|
+
* are unchanged, while concurrent edits actually merge.
|
|
24
|
+
*
|
|
16
25
|
* Seeding contract: providers seed only `source` (from file bytes). Clients derive `structure` on
|
|
17
26
|
* first sync via {@link seedStructure}, which is convergence-safe under concurrent seeders because
|
|
18
27
|
* it only performs whole-key Y.Map sets (per-key LWW picks one seeder's subtrees wholesale) — never
|
|
@@ -27,7 +36,16 @@ export const FRONTMATTER_KEY = "frontmatter";
|
|
|
27
36
|
export const STRUCTURE_KEY = "structure";
|
|
28
37
|
export const SOURCE_KEY = "source";
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Bumped to 2 when prose moved to `Y.Text` and style/attributes/$props to nested `Y.Map`s.
|
|
41
|
+
*
|
|
42
|
+
* Written into `meta` at seed time and not currently validated on load. A room persisted under 1
|
|
43
|
+
* still READS correctly ({@link yValueToJson} passes plain values through) and self-heals on the
|
|
44
|
+
* first edit to a given key, since the bridge replaces a non-granular container. Two clients
|
|
45
|
+
* running DIFFERENT versions in one room would disagree about merge granularity — see §5 of
|
|
46
|
+
* specs/collab.md on version skew, which this does not attempt to solve.
|
|
47
|
+
*/
|
|
48
|
+
export const COLLAB_SCHEMA_VERSION = 2;
|
|
31
49
|
|
|
32
50
|
export function metaMap(doc: Y.Doc): Y.Map<unknown> {
|
|
33
51
|
return doc.getMap(META_KEY);
|
|
@@ -55,17 +73,137 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
55
73
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Node keys whose STRING value is stored as a `Y.Text` rather than an opaque string.
|
|
78
|
+
*
|
|
79
|
+
* This is the character-level-collaboration decision. Whole-paragraph last-writer-wins was never a
|
|
80
|
+
* Yjs limitation — it was this schema storing prose as a plain LWW value, so two people typing in
|
|
81
|
+
* one paragraph clobbered each other and only `source` (which the canvas does not edit) merged per
|
|
82
|
+
* character.
|
|
83
|
+
*
|
|
84
|
+
* `innerHTML` is deliberately absent: it is an opaque markup blob, never caret-edited.
|
|
85
|
+
*/
|
|
86
|
+
export const TEXT_KEYS: ReadonlySet<string> = new Set(["textContent"]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Node keys stored as a nested `Y.Map` so concurrent edits to DIFFERENT properties both survive.
|
|
90
|
+
*
|
|
91
|
+
* `mutateUpdateStyle` and friends record a whole-object `set-key`, so writing the object as one LWW
|
|
92
|
+
* value meant A's `color` and B's `padding` raced and one lost. Per-property merge fixes that
|
|
93
|
+
* without touching the op vocabulary: {@link yValueToJson} reassembles the whole object on the way
|
|
94
|
+
* out, so `JxDocOp`, the canvas patcher and the history ring keep seeing whole values.
|
|
95
|
+
*
|
|
96
|
+
* Declaration VALUES stay plain (a CSS value is never partially edited); only the key space is
|
|
97
|
+
* granular. Nesting recurses, which covers `style`'s media (`@--sm`) and pseudo (`&:hover`)
|
|
98
|
+
* blocks.
|
|
99
|
+
*/
|
|
100
|
+
export const GRANULAR_OBJECT_KEYS: ReadonlySet<string> = new Set(["style", "attributes", "$props"]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rewrite a `Y.Text` to `next` with a minimal edit (common prefix/suffix trimmed).
|
|
104
|
+
*
|
|
105
|
+
* Minimal rather than replace-all for two reasons: the wire carries only the delta, and — the
|
|
106
|
+
* load-bearing one — the surviving characters keep their identity, so a concurrent remote insert
|
|
107
|
+
* inside the untouched region merges instead of being clobbered. Returns false when already equal.
|
|
108
|
+
*/
|
|
109
|
+
export function applyTextEdit(text: Y.Text, next: string): boolean {
|
|
110
|
+
const current = text.toString();
|
|
111
|
+
if (current === next) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
let start = 0;
|
|
115
|
+
const minLen = Math.min(current.length, next.length);
|
|
116
|
+
while (start < minLen && current[start] === next[start]) {
|
|
117
|
+
start += 1;
|
|
118
|
+
}
|
|
119
|
+
let endCurrent = current.length;
|
|
120
|
+
let endNext = next.length;
|
|
121
|
+
while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
|
|
122
|
+
endCurrent -= 1;
|
|
123
|
+
endNext -= 1;
|
|
124
|
+
}
|
|
125
|
+
if (endCurrent > start) {
|
|
126
|
+
text.delete(start, endCurrent - start);
|
|
127
|
+
}
|
|
128
|
+
if (endNext > start) {
|
|
129
|
+
text.insert(start, next.slice(start, endNext));
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A plain object becomes a Y.Map, recursing into nested objects; other values stay plain JSON. */
|
|
135
|
+
export function toYObject(value: Record<string, unknown>): Y.Map<unknown> {
|
|
136
|
+
const map = new Y.Map<unknown>();
|
|
137
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
138
|
+
if (entry === undefined) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
map.set(key, isPlainObject(entry) ? toYObject(entry) : cloneJson(entry));
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge a plain object into an existing Y.Map per key: set changed keys, delete removed ones,
|
|
148
|
+
* recurse into nested maps. Whole-value replacement would mint a new Y.Map and discard a peer's
|
|
149
|
+
* concurrent edit to an untouched sibling property — the very thing granularity exists to prevent.
|
|
150
|
+
*/
|
|
151
|
+
export function mergeYObject(map: Y.Map<unknown>, value: Record<string, unknown>): void {
|
|
152
|
+
// Detached key list: deleting from a Y.Map while iterating its live keys is unsafe.
|
|
153
|
+
// oxlint-disable-next-line unicorn/no-useless-spread -- the copy is the point (mutation during iteration)
|
|
154
|
+
for (const key of [...map.keys()]) {
|
|
155
|
+
if (!(key in value) || value[key] === undefined) {
|
|
156
|
+
map.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
160
|
+
if (entry === undefined) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const existing = map.get(key);
|
|
164
|
+
if (isPlainObject(entry)) {
|
|
165
|
+
if (existing instanceof Y.Map) {
|
|
166
|
+
mergeYObject(existing, entry);
|
|
167
|
+
} else {
|
|
168
|
+
map.set(key, toYObject(entry));
|
|
169
|
+
}
|
|
170
|
+
} else if (!deepEqualJson(existing, entry)) {
|
|
171
|
+
map.set(key, cloneJson(entry));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Structural equality over plain JSON, used to skip no-op key writes. */
|
|
177
|
+
function deepEqualJson(a: unknown, b: unknown): boolean {
|
|
178
|
+
if (a === b) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (a instanceof Y.Map || a instanceof Y.Array || a instanceof Y.Text) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A child value: a bare string becomes a Y.Text (per-character merge), a node becomes a Y.Map. */
|
|
188
|
+
export function toYChild(child: unknown): unknown {
|
|
189
|
+
return typeof child === "string" ? new Y.Text(child) : toYNode(child);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A children array becomes a Y.Array of node Y.Maps and Y.Texts. */
|
|
59
193
|
export function toYChildren(children: readonly unknown[]): Y.Array<unknown> {
|
|
60
194
|
const arr = new Y.Array<unknown>();
|
|
61
195
|
arr.insert(
|
|
62
196
|
0,
|
|
63
|
-
children.map((child) => (
|
|
197
|
+
children.map((child) => toYChild(child)),
|
|
64
198
|
);
|
|
65
199
|
return arr;
|
|
66
200
|
}
|
|
67
201
|
|
|
68
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* A node object becomes a Y.Map. Nesting is no longer limited to `children`: text keys become
|
|
204
|
+
* `Y.Text` and {@link GRANULAR_OBJECT_KEYS} become nested `Y.Map`s, so the CRDT merges at the
|
|
205
|
+
* granularity edits actually happen at. Everything else stays a whole-JSON LWW value.
|
|
206
|
+
*/
|
|
69
207
|
export function toYNode(node: unknown): Y.Map<unknown> {
|
|
70
208
|
if (!isPlainObject(node)) {
|
|
71
209
|
throw new TypeError("collab-schema: a document node must be a plain object");
|
|
@@ -77,6 +215,10 @@ export function toYNode(node: unknown): Y.Map<unknown> {
|
|
|
77
215
|
}
|
|
78
216
|
if (key === "children" && Array.isArray(value)) {
|
|
79
217
|
map.set(key, toYChildren(value));
|
|
218
|
+
} else if (TEXT_KEYS.has(key) && typeof value === "string") {
|
|
219
|
+
map.set(key, new Y.Text(value));
|
|
220
|
+
} else if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
|
|
221
|
+
map.set(key, toYObject(value));
|
|
80
222
|
} else {
|
|
81
223
|
map.set(key, cloneJson(value));
|
|
82
224
|
}
|
|
@@ -84,7 +226,7 @@ export function toYNode(node: unknown): Y.Map<unknown> {
|
|
|
84
226
|
return map;
|
|
85
227
|
}
|
|
86
228
|
|
|
87
|
-
/** Convert any structure value (Y.Map
|
|
229
|
+
/** Convert any structure value (Y.Map, Y.Array, Y.Text, or plain leaf) back to JSON. */
|
|
88
230
|
export function yValueToJson(value: unknown): unknown {
|
|
89
231
|
if (value instanceof Y.Map) {
|
|
90
232
|
const out: Record<string, unknown> = {};
|
|
@@ -96,6 +238,9 @@ export function yValueToJson(value: unknown): unknown {
|
|
|
96
238
|
if (value instanceof Y.Array) {
|
|
97
239
|
return value.toArray().map((entry) => yValueToJson(entry));
|
|
98
240
|
}
|
|
241
|
+
if (value instanceof Y.Text) {
|
|
242
|
+
return value.toString();
|
|
243
|
+
}
|
|
99
244
|
return value;
|
|
100
245
|
}
|
|
101
246
|
|
|
@@ -132,28 +277,11 @@ export function resolveYPath(doc: Y.Doc, path: JxPath): unknown {
|
|
|
132
277
|
*/
|
|
133
278
|
export function updateSourceText(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
134
279
|
const text = sourceText(doc);
|
|
135
|
-
|
|
136
|
-
if (current === next) {
|
|
280
|
+
if (text.toString() === next) {
|
|
137
281
|
return false;
|
|
138
282
|
}
|
|
139
|
-
let start = 0;
|
|
140
|
-
const minLen = Math.min(current.length, next.length);
|
|
141
|
-
while (start < minLen && current[start] === next[start]) {
|
|
142
|
-
start += 1;
|
|
143
|
-
}
|
|
144
|
-
let endCurrent = current.length;
|
|
145
|
-
let endNext = next.length;
|
|
146
|
-
while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
|
|
147
|
-
endCurrent -= 1;
|
|
148
|
-
endNext -= 1;
|
|
149
|
-
}
|
|
150
283
|
doc.transact(() => {
|
|
151
|
-
|
|
152
|
-
text.delete(start, endCurrent - start);
|
|
153
|
-
}
|
|
154
|
-
if (endNext > start) {
|
|
155
|
-
text.insert(start, next.slice(start, endNext));
|
|
156
|
-
}
|
|
284
|
+
applyTextEdit(text, next);
|
|
157
285
|
}, origin);
|
|
158
286
|
return true;
|
|
159
287
|
}
|