@echovisionlab/geul-common 0.2.4 → 0.3.1
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 +4 -2
- package/src/collaboration/block-room-codec/internal.ts +39 -20
- package/src/collaboration/block-room-codec/replay-base.ts +195 -0
- package/src/collaboration/block-room-codec/replay-inline-merge.ts +341 -0
- package/src/collaboration/block-room-codec/replay-locale.ts +182 -0
- package/src/collaboration/block-room-codec/replay-payload.ts +348 -0
- package/src/collaboration/block-room-codec/replay-structure.ts +504 -0
- package/src/collaboration/block-room-codec/replay.ts +75 -0
- package/src/collaboration/block-room-codec.ts +1 -0
- package/src/collaboration/form-schema-delta.ts +447 -0
- package/src/collaboration/menu.ts +129 -19
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@echovisionlab/geul-common",
|
|
3
3
|
"description": "Shared TypeScript contracts and helpers for Geul editors, collaboration, localized content, media blocks, pages, posts, and runtime events.",
|
|
4
4
|
"author": "state303 <state303@dsub.io>",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.3.1",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"repository": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"./collaboration/document": "./src/collaboration/document.ts",
|
|
41
41
|
"./collaboration/email-layout": "./src/collaboration/email-layout.ts",
|
|
42
42
|
"./collaboration/form": "./src/collaboration/form.ts",
|
|
43
|
+
"./collaboration/form-schema-delta": "./src/collaboration/form-schema-delta.ts",
|
|
43
44
|
"./collaboration/label": "./src/collaboration/label.ts",
|
|
44
45
|
"./collaboration/map-theme": "./src/collaboration/map-theme.ts",
|
|
45
46
|
"./collaboration/menu": "./src/collaboration/menu.ts",
|
|
@@ -62,7 +63,8 @@
|
|
|
62
63
|
},
|
|
63
64
|
"dependencies": {
|
|
64
65
|
"@bufbuild/protobuf": "^2.14.0",
|
|
65
|
-
"@echovisionlab/geul-proto": "^0.
|
|
66
|
+
"@echovisionlab/geul-proto": "^0.3.0",
|
|
67
|
+
"diff": "9.0.0",
|
|
66
68
|
"zod": "^4.4.3"
|
|
67
69
|
},
|
|
68
70
|
"peerDependencies": {
|
|
@@ -354,24 +354,50 @@ function identityArray(
|
|
|
354
354
|
value: JsonValue,
|
|
355
355
|
collection: string,
|
|
356
356
|
identity: string,
|
|
357
|
-
nestedCollection?: string,
|
|
358
|
-
nestedIdentity?: string,
|
|
359
357
|
): unknown[] {
|
|
360
358
|
const object = jsonObject(value, "locale_identity:payload");
|
|
361
359
|
const children = object[collection];
|
|
362
360
|
if (!Array.isArray(children)) return [];
|
|
361
|
+
return children.map(
|
|
362
|
+
(child) => jsonObject(child, "locale_identity:item")[identity],
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const tableIdentityPattern =
|
|
367
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
368
|
+
|
|
369
|
+
function tableIdentityArray(
|
|
370
|
+
value: JsonValue,
|
|
371
|
+
collection: string,
|
|
372
|
+
identity: string,
|
|
373
|
+
nestedIdentity: string,
|
|
374
|
+
): unknown[] {
|
|
375
|
+
const object = jsonObject(value, "locale_identity:payload");
|
|
376
|
+
const children = object[collection];
|
|
377
|
+
if (!Array.isArray(children)) return [];
|
|
378
|
+
const seenRows = new Set<string>();
|
|
379
|
+
const seenCells = new Set<string>();
|
|
363
380
|
return children.map((child) => {
|
|
364
381
|
const item = jsonObject(child, "locale_identity:item");
|
|
365
|
-
|
|
366
|
-
|
|
382
|
+
const rowId = item[identity];
|
|
383
|
+
if (typeof rowId !== "string" || !tableIdentityPattern.test(rowId))
|
|
384
|
+
fail("locale_identity:table_row_id");
|
|
385
|
+
if (seenRows.has(rowId)) fail("locale_identity:table_duplicate_row_id");
|
|
386
|
+
seenRows.add(rowId);
|
|
387
|
+
const nested = item.cells;
|
|
388
|
+
const cells = Array.isArray(nested) ? nested : [];
|
|
367
389
|
return {
|
|
368
|
-
id:
|
|
369
|
-
children:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
)
|
|
374
|
-
|
|
390
|
+
id: rowId,
|
|
391
|
+
children: cells.map((entry) => {
|
|
392
|
+
const cell = jsonObject(entry, "locale_identity:nested");
|
|
393
|
+
const cellId = cell[nestedIdentity];
|
|
394
|
+
if (typeof cellId !== "string" || !tableIdentityPattern.test(cellId))
|
|
395
|
+
fail("locale_identity:table_cell_id");
|
|
396
|
+
if (seenCells.has(cellId))
|
|
397
|
+
fail("locale_identity:table_duplicate_cell_id");
|
|
398
|
+
seenCells.add(cellId);
|
|
399
|
+
return cellId;
|
|
400
|
+
}),
|
|
375
401
|
};
|
|
376
402
|
});
|
|
377
403
|
}
|
|
@@ -402,18 +428,11 @@ export function assertBlockRoomLocaleProjectionParity(yDocument: Y.Doc): void {
|
|
|
402
428
|
jsonObject(localePayload, `locale:${id}:payload`).content,
|
|
403
429
|
`locale:${id}:content`,
|
|
404
430
|
);
|
|
405
|
-
const baseIdentity =
|
|
406
|
-
|
|
407
|
-
"rows",
|
|
408
|
-
"id",
|
|
409
|
-
"cells",
|
|
410
|
-
"id",
|
|
411
|
-
);
|
|
412
|
-
const localeIdentity = identityArray(
|
|
431
|
+
const baseIdentity = tableIdentityArray(baseContent, "rows", "id", "id");
|
|
432
|
+
const localeIdentity = tableIdentityArray(
|
|
413
433
|
localeContent,
|
|
414
434
|
"rows",
|
|
415
435
|
"rowId",
|
|
416
|
-
"cells",
|
|
417
436
|
"cellId",
|
|
418
437
|
);
|
|
419
438
|
if (JSON.stringify(baseIdentity) !== JSON.stringify(localeIdentity))
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import {
|
|
3
|
+
blockRoomBaseNodes,
|
|
4
|
+
type CanonicalBlockRoomSnapshot,
|
|
5
|
+
} from "./materialization.ts";
|
|
6
|
+
import {
|
|
7
|
+
deleteBlockRoomBaseNode,
|
|
8
|
+
movePageSectionNode,
|
|
9
|
+
moveRichTextBlockNode,
|
|
10
|
+
} from "./structure-mutations.ts";
|
|
11
|
+
import { fail, type BlockRoomDocumentType } from "./internal.ts";
|
|
12
|
+
import { applyPayloadRootDiff } from "./replay-payload.ts";
|
|
13
|
+
import {
|
|
14
|
+
baseDepth,
|
|
15
|
+
currentBaseNodes,
|
|
16
|
+
currentNodeType,
|
|
17
|
+
insertBaseNode,
|
|
18
|
+
movedBaseIds,
|
|
19
|
+
nodeMap,
|
|
20
|
+
safeIndex,
|
|
21
|
+
sameContainer,
|
|
22
|
+
sameNodeType,
|
|
23
|
+
sameValue,
|
|
24
|
+
stableTreeAnchor,
|
|
25
|
+
destinationUnderDeletedObservedAncestor,
|
|
26
|
+
} from "./replay-structure.ts";
|
|
27
|
+
|
|
28
|
+
export function applyBaseAdditions(
|
|
29
|
+
document: Y.Doc,
|
|
30
|
+
type: BlockRoomDocumentType,
|
|
31
|
+
before: CanonicalBlockRoomSnapshot,
|
|
32
|
+
after: CanonicalBlockRoomSnapshot,
|
|
33
|
+
): void {
|
|
34
|
+
const beforeBase = nodeMap(before.baseNodes);
|
|
35
|
+
const afterBase = nodeMap(after.baseNodes);
|
|
36
|
+
const newlyAddedIds = new Set(
|
|
37
|
+
[...afterBase.keys()].filter((id) => !beforeBase.has(id)),
|
|
38
|
+
);
|
|
39
|
+
const currentBeforeChanges = currentBaseNodes(document, type);
|
|
40
|
+
for (const [id, node] of afterBase) {
|
|
41
|
+
if (beforeBase.has(id)) continue;
|
|
42
|
+
const current = currentBeforeChanges.get(id);
|
|
43
|
+
if (current) {
|
|
44
|
+
if (!sameNodeType(current, node)) fail(`replay:kind_changed:${id}`);
|
|
45
|
+
// A replay may be retried after its prior insert became resident. Keep
|
|
46
|
+
// that already-existing node and its later peer edits intact.
|
|
47
|
+
newlyAddedIds.delete(id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const additions = [...newlyAddedIds]
|
|
51
|
+
.map((id) => afterBase.get(id)!)
|
|
52
|
+
.sort(
|
|
53
|
+
(left, right) =>
|
|
54
|
+
baseDepth(afterBase, left.id) - baseDepth(afterBase, right.id) ||
|
|
55
|
+
left.position - right.position ||
|
|
56
|
+
left.id.localeCompare(right.id),
|
|
57
|
+
);
|
|
58
|
+
for (const node of additions) {
|
|
59
|
+
if (blockRoomBaseNodes(document).has(node.id)) continue;
|
|
60
|
+
if (
|
|
61
|
+
destinationUnderDeletedObservedAncestor(
|
|
62
|
+
node.id,
|
|
63
|
+
node.parentId,
|
|
64
|
+
beforeBase,
|
|
65
|
+
afterBase,
|
|
66
|
+
new Set(blockRoomBaseNodes(document).keys()),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
continue;
|
|
70
|
+
insertBaseNode(document, type, after, node, newlyAddedIds);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyBaseChanges(
|
|
75
|
+
document: Y.Doc,
|
|
76
|
+
before: CanonicalBlockRoomSnapshot,
|
|
77
|
+
after: CanonicalBlockRoomSnapshot,
|
|
78
|
+
): void {
|
|
79
|
+
const beforeBase = nodeMap(before.baseNodes);
|
|
80
|
+
const afterBase = nodeMap(after.baseNodes);
|
|
81
|
+
const moved = movedBaseIds(before.baseNodes, after.baseNodes);
|
|
82
|
+
const escaping = [...moved]
|
|
83
|
+
.filter((id) => {
|
|
84
|
+
const oldNode = beforeBase.get(id)!;
|
|
85
|
+
const newNode = afterBase.get(id)!;
|
|
86
|
+
let ancestor = oldNode.parentId;
|
|
87
|
+
while (ancestor !== null) {
|
|
88
|
+
if (!afterBase.has(ancestor)) return true;
|
|
89
|
+
ancestor = beforeBase.get(ancestor)?.parentId ?? null;
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
!sameContainer(oldNode, newNode) &&
|
|
93
|
+
!afterBase.has(oldNode.parentId ?? "")
|
|
94
|
+
);
|
|
95
|
+
})
|
|
96
|
+
.sort(
|
|
97
|
+
(left, right) =>
|
|
98
|
+
baseDepth(beforeBase, left) - baseDepth(beforeBase, right),
|
|
99
|
+
);
|
|
100
|
+
const moveNode = (id: string) => {
|
|
101
|
+
const desired = afterBase.get(id)!;
|
|
102
|
+
if (
|
|
103
|
+
destinationUnderDeletedObservedAncestor(
|
|
104
|
+
id,
|
|
105
|
+
desired.parentId,
|
|
106
|
+
beforeBase,
|
|
107
|
+
afterBase,
|
|
108
|
+
new Set(blockRoomBaseNodes(document).keys()),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return;
|
|
112
|
+
const existing = currentNodeType(blockRoomBaseNodes(document), id);
|
|
113
|
+
if (!existing) return;
|
|
114
|
+
const baseline = beforeBase.get(id)!;
|
|
115
|
+
if (!sameNodeType(existing, baseline)) fail(`replay:kind_changed:${id}`);
|
|
116
|
+
if (desired.family === "page_section") {
|
|
117
|
+
const anchor = stableTreeAnchor(document, after.baseNodes, desired);
|
|
118
|
+
movePageSectionNode(
|
|
119
|
+
document,
|
|
120
|
+
id,
|
|
121
|
+
{
|
|
122
|
+
...(desired.parentId ? { parentSectionId: desired.parentId } : {}),
|
|
123
|
+
...(desired.columnId ? { columnId: desired.columnId } : {}),
|
|
124
|
+
index: anchor ? desired.position : safeIndex(document, desired),
|
|
125
|
+
},
|
|
126
|
+
anchor ? { anchor } : {},
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
const anchor = stableTreeAnchor(document, after.baseNodes, desired);
|
|
130
|
+
moveRichTextBlockNode(
|
|
131
|
+
document,
|
|
132
|
+
id,
|
|
133
|
+
{
|
|
134
|
+
...(desired.parentId &&
|
|
135
|
+
afterBase.get(desired.parentId)?.family === "rich_text"
|
|
136
|
+
? { parentBlockId: desired.parentId }
|
|
137
|
+
: {}),
|
|
138
|
+
index: anchor ? desired.position : safeIndex(document, desired),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
...(desired.parentId &&
|
|
142
|
+
afterBase.get(desired.parentId)?.family === "page_section"
|
|
143
|
+
? { pageSectionId: desired.parentId }
|
|
144
|
+
: {}),
|
|
145
|
+
...(anchor ? { anchor } : {}),
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
for (const id of escaping) moveNode(id);
|
|
151
|
+
|
|
152
|
+
const deletedIds = new Set(
|
|
153
|
+
[...beforeBase.keys()].filter((id) => !afterBase.has(id)),
|
|
154
|
+
);
|
|
155
|
+
const topLevelDeletes = [...deletedIds]
|
|
156
|
+
.filter((id) => {
|
|
157
|
+
let parentId = beforeBase.get(id)!.parentId;
|
|
158
|
+
while (parentId !== null) {
|
|
159
|
+
if (deletedIds.has(parentId)) return false;
|
|
160
|
+
parentId = beforeBase.get(parentId)?.parentId ?? null;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
})
|
|
164
|
+
.sort(
|
|
165
|
+
(left, right) =>
|
|
166
|
+
baseDepth(beforeBase, left) - baseDepth(beforeBase, right),
|
|
167
|
+
);
|
|
168
|
+
for (const id of topLevelDeletes) {
|
|
169
|
+
if (blockRoomBaseNodes(document).has(id))
|
|
170
|
+
deleteBlockRoomBaseNode(document, id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const movedInAfterOrder = [...moved]
|
|
174
|
+
.filter((id) => !escaping.includes(id))
|
|
175
|
+
.sort(
|
|
176
|
+
(left, right) =>
|
|
177
|
+
afterBase.get(right)!.position - afterBase.get(left)!.position,
|
|
178
|
+
);
|
|
179
|
+
for (const id of movedInAfterOrder) moveNode(id);
|
|
180
|
+
|
|
181
|
+
for (const [id, oldNode] of beforeBase) {
|
|
182
|
+
const next = afterBase.get(id);
|
|
183
|
+
if (!next || sameValue(oldNode.payload, next.payload)) continue;
|
|
184
|
+
const existing = currentNodeType(blockRoomBaseNodes(document), id);
|
|
185
|
+
if (!existing) continue;
|
|
186
|
+
if (!sameNodeType(existing, oldNode) || !sameNodeType(next, oldNode))
|
|
187
|
+
fail(`replay:kind_changed:${id}`);
|
|
188
|
+
applyPayloadRootDiff(
|
|
189
|
+
document,
|
|
190
|
+
{ id, family: oldNode.family },
|
|
191
|
+
oldNode.payload,
|
|
192
|
+
next.payload,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import { diffArrays } from "diff";
|
|
3
|
+
import { desiredGroups, type Style } from "./inline-content-model.ts";
|
|
4
|
+
|
|
5
|
+
type Attributes = Record<string, string | boolean>;
|
|
6
|
+
type Token =
|
|
7
|
+
| { kind: "text"; value: string; attributes: Attributes }
|
|
8
|
+
| { kind: "mathInline"; attributes: Attributes }
|
|
9
|
+
| { kind: "hardBreak"; attributes: Attributes }
|
|
10
|
+
| { kind: "emptyLink"; attributes: Attributes }
|
|
11
|
+
| { kind: "linkBoundary"; attributes: Attributes };
|
|
12
|
+
|
|
13
|
+
const MAX_EXACT_EDIT_DISTANCE = 128;
|
|
14
|
+
|
|
15
|
+
interface Projection<T> {
|
|
16
|
+
readonly retained: Map<number, T>;
|
|
17
|
+
readonly inserted: Map<number, T[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tokenIdentity(token: Token): string {
|
|
21
|
+
return token.kind === "text" ? `text:${token.value}` : token.kind;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Align text by Unicode code point; style/link changes remain attribute edits. */
|
|
25
|
+
function project<T>(
|
|
26
|
+
before: readonly T[],
|
|
27
|
+
after: readonly T[],
|
|
28
|
+
identity: (token: T) => string,
|
|
29
|
+
): Projection<T> {
|
|
30
|
+
const retained = new Map<number, T>();
|
|
31
|
+
const inserted = new Map<number, T[]>();
|
|
32
|
+
let position = 0;
|
|
33
|
+
const changes = diffArrays([...before], [...after], {
|
|
34
|
+
comparator: (left, right) => identity(left) === identity(right),
|
|
35
|
+
maxEditLength: MAX_EXACT_EDIT_DISTANCE,
|
|
36
|
+
});
|
|
37
|
+
if (!changes) return projectByForwardAnchors(before, after, identity);
|
|
38
|
+
for (const change of changes) {
|
|
39
|
+
if (change.added) {
|
|
40
|
+
inserted.set(position, change.value);
|
|
41
|
+
} else if (change.removed) {
|
|
42
|
+
position += change.count;
|
|
43
|
+
} else {
|
|
44
|
+
for (const token of change.value) retained.set(position++, token);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { retained, inserted };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Large replacements do not need an optimal edit script. Forward occurrence
|
|
52
|
+
* indexes retain a monotonic common subsequence in linear time, so recovery
|
|
53
|
+
* remains bounded while still replaying insertions/deletions as author intent.
|
|
54
|
+
* Exact Myers alignment remains the normal path for ordinary editing.
|
|
55
|
+
*/
|
|
56
|
+
function projectByForwardAnchors<T>(
|
|
57
|
+
before: readonly T[],
|
|
58
|
+
after: readonly T[],
|
|
59
|
+
identity: (token: T) => string,
|
|
60
|
+
): Projection<T> {
|
|
61
|
+
const occurrences = new Map<string, { positions: number[]; next: number }>();
|
|
62
|
+
before.forEach((token, index) => {
|
|
63
|
+
const key = identity(token);
|
|
64
|
+
let entry = occurrences.get(key);
|
|
65
|
+
if (!entry) {
|
|
66
|
+
entry = { positions: [], next: 0 };
|
|
67
|
+
occurrences.set(key, entry);
|
|
68
|
+
}
|
|
69
|
+
entry.positions.push(index);
|
|
70
|
+
});
|
|
71
|
+
const retained = new Map<number, T>();
|
|
72
|
+
const inserted = new Map<number, T[]>();
|
|
73
|
+
let cursor = 0;
|
|
74
|
+
let pending: T[] = [];
|
|
75
|
+
for (const token of after) {
|
|
76
|
+
const entry = occurrences.get(identity(token));
|
|
77
|
+
if (entry) {
|
|
78
|
+
while (
|
|
79
|
+
entry.next < entry.positions.length &&
|
|
80
|
+
entry.positions[entry.next]! < cursor
|
|
81
|
+
)
|
|
82
|
+
entry.next++;
|
|
83
|
+
}
|
|
84
|
+
const position = entry?.positions[entry.next];
|
|
85
|
+
if (position === undefined) {
|
|
86
|
+
pending.push(token);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (pending.length) {
|
|
90
|
+
inserted.set(position, pending);
|
|
91
|
+
pending = [];
|
|
92
|
+
}
|
|
93
|
+
retained.set(position, token);
|
|
94
|
+
cursor = position + 1;
|
|
95
|
+
entry!.next++;
|
|
96
|
+
}
|
|
97
|
+
if (pending.length) inserted.set(before.length, pending);
|
|
98
|
+
return { retained, inserted };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mergeAttributes(
|
|
102
|
+
before: Attributes,
|
|
103
|
+
local: Attributes,
|
|
104
|
+
peer: Attributes,
|
|
105
|
+
): Attributes {
|
|
106
|
+
const result = { ...peer };
|
|
107
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(local)])) {
|
|
108
|
+
if (before[key] === local[key]) continue;
|
|
109
|
+
if (local[key] === undefined) delete result[key];
|
|
110
|
+
else result[key] = local[key];
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function withAttributes(token: Token, attributes: Attributes): Token {
|
|
116
|
+
return { ...token, attributes };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function insertionIdentity(token: Token): string {
|
|
120
|
+
if (token.kind === "mathInline") return `math:${token.attributes.source}`;
|
|
121
|
+
if (token.kind === "emptyLink") return `emptyLink:${token.attributes.href}`;
|
|
122
|
+
return tokenIdentity(token);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Preserve each newly inserted adjacent link as a semantic group. */
|
|
126
|
+
function linkedInsertionGroups(tokens: readonly Token[]): Token[][] | null {
|
|
127
|
+
if (!tokens.some((token) => token.kind === "linkBoundary")) return null;
|
|
128
|
+
if (
|
|
129
|
+
tokens.some(
|
|
130
|
+
(token) =>
|
|
131
|
+
token.kind !== "linkBoundary" &&
|
|
132
|
+
token.kind !== "emptyLink" &&
|
|
133
|
+
(token.kind !== "text" || token.attributes.href === undefined),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return null;
|
|
137
|
+
const groups: Token[][] = [[]];
|
|
138
|
+
for (const token of tokens) {
|
|
139
|
+
if (token.kind === "linkBoundary") groups.push([]);
|
|
140
|
+
else groups[groups.length - 1]!.push(token);
|
|
141
|
+
}
|
|
142
|
+
return groups;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function linkGroupIdentity(group: readonly Token[]): string {
|
|
146
|
+
return JSON.stringify(
|
|
147
|
+
group.map((token) => [insertionIdentity(token), token.attributes.href]),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An ordered union retains accepted inserts and appends only missing intent. */
|
|
152
|
+
function mergeInsertionSequences<T>(
|
|
153
|
+
peer: readonly T[],
|
|
154
|
+
local: readonly T[],
|
|
155
|
+
identity: (value: T) => string,
|
|
156
|
+
merge: (peerValue: T, localValue: T) => T,
|
|
157
|
+
): T[] {
|
|
158
|
+
if (local.length === 0) return [...peer];
|
|
159
|
+
if (peer.length === 0) return [...local];
|
|
160
|
+
const localProjection = project(peer, local, identity);
|
|
161
|
+
const result: T[] = [];
|
|
162
|
+
for (let position = 0; position <= peer.length; position++) {
|
|
163
|
+
for (const value of localProjection.inserted.get(position) ?? [])
|
|
164
|
+
result.push(value);
|
|
165
|
+
const peerValue = peer[position];
|
|
166
|
+
if (peerValue === undefined) continue;
|
|
167
|
+
const localValue = localProjection.retained.get(position);
|
|
168
|
+
result.push(
|
|
169
|
+
localValue === undefined ? peerValue : merge(peerValue, localValue),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mergeInsertions(
|
|
176
|
+
peer: readonly Token[],
|
|
177
|
+
local: readonly Token[],
|
|
178
|
+
): Token[] {
|
|
179
|
+
const peerGroups = linkedInsertionGroups(peer);
|
|
180
|
+
const localGroups = linkedInsertionGroups(local);
|
|
181
|
+
if (peerGroups && localGroups) {
|
|
182
|
+
// Matching separator tokens alone would join unrelated links from two
|
|
183
|
+
// insertion streams. Align whole link groups, then merge their run intent.
|
|
184
|
+
const groups = mergeInsertionSequences(
|
|
185
|
+
peerGroups,
|
|
186
|
+
localGroups,
|
|
187
|
+
linkGroupIdentity,
|
|
188
|
+
(peerGroup, localGroup) => mergeInsertions(peerGroup, localGroup),
|
|
189
|
+
);
|
|
190
|
+
const result: Token[] = [];
|
|
191
|
+
for (const group of groups) {
|
|
192
|
+
if (result.length) result.push({ kind: "linkBoundary", attributes: {} });
|
|
193
|
+
for (const token of group) result.push(token);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
return mergeInsertionSequences(
|
|
198
|
+
peer,
|
|
199
|
+
local,
|
|
200
|
+
insertionIdentity,
|
|
201
|
+
(peerToken, localToken) =>
|
|
202
|
+
withAttributes(
|
|
203
|
+
peerToken,
|
|
204
|
+
mergeAttributes({}, localToken.attributes, peerToken.attributes),
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mergeTokens(
|
|
210
|
+
before: readonly Token[],
|
|
211
|
+
local: readonly Token[],
|
|
212
|
+
peer: readonly Token[],
|
|
213
|
+
): Token[] {
|
|
214
|
+
const localProjection = project(before, local, tokenIdentity);
|
|
215
|
+
const peerProjection = project(before, peer, tokenIdentity);
|
|
216
|
+
const result: Token[] = [];
|
|
217
|
+
for (let position = 0; position <= before.length; position++) {
|
|
218
|
+
for (const token of mergeInsertions(
|
|
219
|
+
peerProjection.inserted.get(position) ?? [],
|
|
220
|
+
localProjection.inserted.get(position) ?? [],
|
|
221
|
+
))
|
|
222
|
+
result.push(token);
|
|
223
|
+
const localToken = localProjection.retained.get(position);
|
|
224
|
+
const peerToken = peerProjection.retained.get(position);
|
|
225
|
+
// An observed character removed on either side is never resurrected by
|
|
226
|
+
// formatting or another edit to the old projection.
|
|
227
|
+
if (localToken && peerToken) {
|
|
228
|
+
result.push(
|
|
229
|
+
withAttributes(
|
|
230
|
+
peerToken,
|
|
231
|
+
mergeAttributes(
|
|
232
|
+
before[position]!.attributes,
|
|
233
|
+
localToken.attributes,
|
|
234
|
+
peerToken.attributes,
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizedStyles(styles: Style): Attributes {
|
|
244
|
+
return Object.fromEntries(
|
|
245
|
+
Object.entries(styles).filter(
|
|
246
|
+
([, value]) => value !== false && value !== "",
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inlineTokens(content: readonly JsonValue[]): Token[] {
|
|
252
|
+
const tokens: Token[] = [];
|
|
253
|
+
let previousKind: string | undefined;
|
|
254
|
+
for (const group of desiredGroups(content)) {
|
|
255
|
+
if (group.kind === "link" && previousKind === "link")
|
|
256
|
+
tokens.push({ kind: "linkBoundary", attributes: {} });
|
|
257
|
+
previousKind = group.kind;
|
|
258
|
+
if (group.kind === "text" || group.kind === "link") {
|
|
259
|
+
if (group.kind === "link" && group.runs.length === 0)
|
|
260
|
+
tokens.push({ kind: "emptyLink", attributes: { href: group.href } });
|
|
261
|
+
for (const run of group.runs) {
|
|
262
|
+
const attributes = {
|
|
263
|
+
...normalizedStyles(run.styles),
|
|
264
|
+
...(group.kind === "link" ? { href: group.href } : {}),
|
|
265
|
+
};
|
|
266
|
+
for (const value of run.text)
|
|
267
|
+
tokens.push({ kind: "text", value, attributes });
|
|
268
|
+
}
|
|
269
|
+
} else if (group.kind === "mathInline") {
|
|
270
|
+
tokens.push({ kind: "mathInline", attributes: { source: group.source } });
|
|
271
|
+
} else {
|
|
272
|
+
tokens.push({ kind: "hardBreak", attributes: {} });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return tokens;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function inlineContent(tokens: readonly Token[]): JsonValue[] {
|
|
279
|
+
const result: JsonValue[] = [];
|
|
280
|
+
let runs: Array<{ text: string; styles?: Attributes }> | undefined;
|
|
281
|
+
let previousHref: string | boolean | undefined;
|
|
282
|
+
for (const token of tokens) {
|
|
283
|
+
if (token.kind !== "text") {
|
|
284
|
+
runs = undefined;
|
|
285
|
+
if (token.kind === "linkBoundary") continue;
|
|
286
|
+
result.push(
|
|
287
|
+
token.kind === "emptyLink"
|
|
288
|
+
? { link: { href: token.attributes.href as string, content: [] } }
|
|
289
|
+
: token.kind === "mathInline"
|
|
290
|
+
? { mathInline: { source: token.attributes.source as string } }
|
|
291
|
+
: { hardBreak: {} },
|
|
292
|
+
);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const { href, ...styles } = token.attributes;
|
|
296
|
+
if (!runs || previousHref !== href) {
|
|
297
|
+
runs = [];
|
|
298
|
+
if (href !== undefined) result.push({ link: { href, content: runs } });
|
|
299
|
+
previousHref = href;
|
|
300
|
+
}
|
|
301
|
+
const last = runs[runs.length - 1];
|
|
302
|
+
if (last && JSON.stringify(last.styles ?? {}) === JSON.stringify(styles)) {
|
|
303
|
+
last.text += token.value;
|
|
304
|
+
} else {
|
|
305
|
+
const run = {
|
|
306
|
+
text: token.value,
|
|
307
|
+
...(Object.keys(styles).length ? { styles } : {}),
|
|
308
|
+
};
|
|
309
|
+
runs.push(run);
|
|
310
|
+
if (href === undefined) result.push({ text: run });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Replay only local character/attribute intent over the canonical projection. */
|
|
317
|
+
export function mergeInlineIntent(
|
|
318
|
+
before: readonly JsonValue[],
|
|
319
|
+
local: readonly JsonValue[],
|
|
320
|
+
peer: readonly JsonValue[],
|
|
321
|
+
): JsonValue[] {
|
|
322
|
+
return inlineContent(
|
|
323
|
+
mergeTokens(inlineTokens(before), inlineTokens(local), inlineTokens(peer)),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function mergeTextIntent(
|
|
328
|
+
before: string,
|
|
329
|
+
local: string,
|
|
330
|
+
peer: string,
|
|
331
|
+
): string {
|
|
332
|
+
const tokens = (value: string): Token[] =>
|
|
333
|
+
Array.from(value, (character) => ({
|
|
334
|
+
kind: "text",
|
|
335
|
+
value: character,
|
|
336
|
+
attributes: {},
|
|
337
|
+
}));
|
|
338
|
+
return mergeTokens(tokens(before), tokens(local), tokens(peer))
|
|
339
|
+
.map((token) => (token as Extract<Token, { kind: "text" }>).value)
|
|
340
|
+
.join("");
|
|
341
|
+
}
|