@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import {
|
|
4
|
+
allBlockRoomLocaleValueTargets,
|
|
5
|
+
blockRoomLocaleValueIsEncoded,
|
|
6
|
+
markBlockRoomLocaleValuePresent,
|
|
7
|
+
} from "./locale-presence.ts";
|
|
8
|
+
import {
|
|
9
|
+
blockRoomBaseNodes,
|
|
10
|
+
blockRoomLocaleOverlay,
|
|
11
|
+
type CanonicalBlockRoomSnapshot,
|
|
12
|
+
} from "./materialization.ts";
|
|
13
|
+
import { applyPayloadRootDiff } from "./replay-payload.ts";
|
|
14
|
+
import {
|
|
15
|
+
currentNodeType,
|
|
16
|
+
insertLocaleNode,
|
|
17
|
+
nodeMap,
|
|
18
|
+
sameNodeType,
|
|
19
|
+
sameValue,
|
|
20
|
+
} from "./replay-structure.ts";
|
|
21
|
+
import { fail } from "./internal.ts";
|
|
22
|
+
|
|
23
|
+
interface LocaleIntentTarget {
|
|
24
|
+
target: ReturnType<typeof allBlockRoomLocaleValueTargets>[number];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function jsonObject(
|
|
28
|
+
value: JsonValue | undefined,
|
|
29
|
+
): Record<string, JsonValue> | undefined {
|
|
30
|
+
return value === undefined ? undefined : (value as Record<string, JsonValue>);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function targetPath(
|
|
34
|
+
target: LocaleIntentTarget["target"],
|
|
35
|
+
): Array<readonly ["field" | "item", string]> {
|
|
36
|
+
return target.path.map(({ selector }) => {
|
|
37
|
+
if (selector.case === "fieldHandle")
|
|
38
|
+
return ["field", selector.value] as const;
|
|
39
|
+
// Locale targets are generated by allBlockRoomLocaleValueTargets from
|
|
40
|
+
// canonical field/item handles, so the only other selector is itemHandle.
|
|
41
|
+
return ["item", selector.value as string] as const;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rawLocaleValue(
|
|
46
|
+
snapshot: CanonicalBlockRoomSnapshot,
|
|
47
|
+
target: LocaleIntentTarget["target"],
|
|
48
|
+
): JsonValue | undefined {
|
|
49
|
+
const ownerId = target.owner.value as string;
|
|
50
|
+
const overlay = snapshot.localeOverlay.find((node) => node.id === ownerId);
|
|
51
|
+
if (!overlay) return undefined;
|
|
52
|
+
const payload = jsonObject(overlay.payload)!;
|
|
53
|
+
const path = targetPath(target);
|
|
54
|
+
if (overlay.family === "rich_text") {
|
|
55
|
+
if (target.fieldHandle === "content") return payload.content;
|
|
56
|
+
if (target.fieldHandle === "tableContent") {
|
|
57
|
+
const rows = jsonObject(payload.content)!.rows as JsonValue[];
|
|
58
|
+
const row = rows.find(
|
|
59
|
+
(candidate) => jsonObject(candidate)!.rowId === path[1]?.[1],
|
|
60
|
+
);
|
|
61
|
+
if (row === undefined) return undefined;
|
|
62
|
+
const cells = jsonObject(row)!.cells as JsonValue[];
|
|
63
|
+
const cell = cells.find(
|
|
64
|
+
(candidate) => jsonObject(candidate)!.cellId === path[3]?.[1],
|
|
65
|
+
);
|
|
66
|
+
if (cell === undefined) return undefined;
|
|
67
|
+
return jsonObject(cell)!.content;
|
|
68
|
+
}
|
|
69
|
+
return jsonObject(payload.props)?.[target.fieldHandle];
|
|
70
|
+
}
|
|
71
|
+
if (path[0]?.[1] === "props") return jsonObject(payload.props)?.[path[1]![1]];
|
|
72
|
+
const units = payload.units as JsonValue[];
|
|
73
|
+
const unit = units.find(
|
|
74
|
+
(candidate) => jsonObject(candidate)!.unitId === path[1]![1],
|
|
75
|
+
);
|
|
76
|
+
if (unit === undefined) return undefined;
|
|
77
|
+
return jsonObject(jsonObject(unit)!.props)?.[path[3]![1]];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function changedLocaleIntentTargets(
|
|
81
|
+
document: Y.Doc,
|
|
82
|
+
before: CanonicalBlockRoomSnapshot,
|
|
83
|
+
after: CanonicalBlockRoomSnapshot,
|
|
84
|
+
): LocaleIntentTarget[] {
|
|
85
|
+
const oldNodes = nodeMap(before.localeOverlay);
|
|
86
|
+
const nextNodes = nodeMap(after.localeOverlay);
|
|
87
|
+
const changedNodeIds = new Set<string>();
|
|
88
|
+
for (const id of new Set([...oldNodes.keys(), ...nextNodes.keys()])) {
|
|
89
|
+
const oldNode = oldNodes.get(id);
|
|
90
|
+
const nextNode = nextNodes.get(id);
|
|
91
|
+
if (
|
|
92
|
+
!oldNode ||
|
|
93
|
+
!nextNode ||
|
|
94
|
+
!sameNodeType(oldNode, nextNode) ||
|
|
95
|
+
!sameValue(oldNode.payload, nextNode.payload)
|
|
96
|
+
)
|
|
97
|
+
changedNodeIds.add(id);
|
|
98
|
+
}
|
|
99
|
+
if (changedNodeIds.size === 0) return [];
|
|
100
|
+
const changed: LocaleIntentTarget[] = [];
|
|
101
|
+
for (const target of allBlockRoomLocaleValueTargets(document)) {
|
|
102
|
+
if (!changedNodeIds.has(target.owner.value as string)) continue;
|
|
103
|
+
const nextValue = rawLocaleValue(after, target);
|
|
104
|
+
const oldValue = rawLocaleValue(before, target);
|
|
105
|
+
if (
|
|
106
|
+
!sameValue(oldValue, nextValue) &&
|
|
107
|
+
blockRoomLocaleValueIsEncoded(document, target)
|
|
108
|
+
)
|
|
109
|
+
changed.push({ target });
|
|
110
|
+
}
|
|
111
|
+
return changed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function applyLocaleAdditions(
|
|
115
|
+
document: Y.Doc,
|
|
116
|
+
before: CanonicalBlockRoomSnapshot,
|
|
117
|
+
after: CanonicalBlockRoomSnapshot,
|
|
118
|
+
isTarget: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
// Canonical source rooms add shared base nodes and their locale projections
|
|
121
|
+
// together. Target rooms cannot add base nodes (replay.ts checks parity).
|
|
122
|
+
if (isTarget) return;
|
|
123
|
+
|
|
124
|
+
const beforeLocale = nodeMap(before.localeOverlay);
|
|
125
|
+
const afterLocale = nodeMap(after.localeOverlay);
|
|
126
|
+
const newlyAddedLocaleIds = new Set(
|
|
127
|
+
[...afterLocale.keys()].filter((id) => !beforeLocale.has(id)),
|
|
128
|
+
);
|
|
129
|
+
for (const id of afterLocale.keys()) {
|
|
130
|
+
if (beforeLocale.has(id)) continue;
|
|
131
|
+
// Base insertion may have been skipped because a peer deleted an observed
|
|
132
|
+
// ancestor. Keep that subtree absent in both projections.
|
|
133
|
+
if (!blockRoomBaseNodes(document).has(id)) continue;
|
|
134
|
+
if (blockRoomLocaleOverlay(document).has(id)) {
|
|
135
|
+
newlyAddedLocaleIds.delete(id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const localeAdds = [...newlyAddedLocaleIds]
|
|
139
|
+
.map((id) => afterLocale.get(id)!)
|
|
140
|
+
.sort(
|
|
141
|
+
(left, right) =>
|
|
142
|
+
(left.family === "page_section" ? 0 : 1) -
|
|
143
|
+
(right.family === "page_section" ? 0 : 1),
|
|
144
|
+
);
|
|
145
|
+
for (const node of localeAdds) {
|
|
146
|
+
// Inserting a Page section can include all newly-added descendant locale
|
|
147
|
+
// nodes, so a later entry in this same pass may already be resident.
|
|
148
|
+
if (blockRoomLocaleOverlay(document).has(node.id)) continue;
|
|
149
|
+
insertLocaleNode(document, after, node, newlyAddedLocaleIds);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function applyLocaleChanges(
|
|
154
|
+
document: Y.Doc,
|
|
155
|
+
before: CanonicalBlockRoomSnapshot,
|
|
156
|
+
after: CanonicalBlockRoomSnapshot,
|
|
157
|
+
): void {
|
|
158
|
+
const beforeLocale = nodeMap(before.localeOverlay);
|
|
159
|
+
const afterLocale = nodeMap(after.localeOverlay);
|
|
160
|
+
const currentBaseIds = new Set(blockRoomBaseNodes(document).keys());
|
|
161
|
+
// Canonical snapshots have one locale overlay per base node. Source base
|
|
162
|
+
// deletion already removes that projection; target snapshots cannot delete
|
|
163
|
+
// it without changing the base, which replay.ts rejects.
|
|
164
|
+
|
|
165
|
+
for (const [id, oldNode] of beforeLocale) {
|
|
166
|
+
const next = afterLocale.get(id);
|
|
167
|
+
if (!next || sameValue(oldNode.payload, next.payload)) continue;
|
|
168
|
+
if (!currentBaseIds.has(id)) continue;
|
|
169
|
+
const existing = currentNodeType(blockRoomLocaleOverlay(document), id)!;
|
|
170
|
+
if (!sameNodeType(existing, oldNode) || !sameNodeType(next, oldNode))
|
|
171
|
+
fail(`replay:kind_changed:${id}`);
|
|
172
|
+
applyPayloadRootDiff(
|
|
173
|
+
document,
|
|
174
|
+
{ id, family: oldNode.family, locale: true },
|
|
175
|
+
oldNode.payload,
|
|
176
|
+
next.payload,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const { target } of changedLocaleIntentTargets(document, before, after))
|
|
181
|
+
markBlockRoomLocaleValuePresent(document, target);
|
|
182
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import {
|
|
4
|
+
deleteBlockRoomPayloadArrayItem,
|
|
5
|
+
insertBlockRoomPayloadArrayItem,
|
|
6
|
+
moveBlockRoomPayloadArrayItem,
|
|
7
|
+
replaceBlockRoomCollaborativeText,
|
|
8
|
+
replaceBlockRoomPayloadArray,
|
|
9
|
+
setBlockRoomAtomicValue,
|
|
10
|
+
} from "./payload-mutations.ts";
|
|
11
|
+
import { fail, fromYValue } from "./internal.ts";
|
|
12
|
+
import {
|
|
13
|
+
nodeTextPredicate,
|
|
14
|
+
payloadParent,
|
|
15
|
+
payloadValue,
|
|
16
|
+
roomNode,
|
|
17
|
+
type BlockRoomNodeRef,
|
|
18
|
+
type BlockRoomPayloadRef,
|
|
19
|
+
} from "./room-access.ts";
|
|
20
|
+
import { longestCommonSubsequence } from "./replay-structure.ts";
|
|
21
|
+
import {
|
|
22
|
+
isBlockRoomInlineContentRef,
|
|
23
|
+
reconcileBlockRoomInlineContent,
|
|
24
|
+
} from "./inline-content-mutations.ts";
|
|
25
|
+
import { fromYInlineContent } from "./inline-content-projection.ts";
|
|
26
|
+
import { mergeInlineIntent, mergeTextIntent } from "./replay-inline-merge.ts";
|
|
27
|
+
|
|
28
|
+
const STABLE_ARRAY_ID_FIELDS = ["id", "rowId", "cellId", "unitId"] as const;
|
|
29
|
+
|
|
30
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
31
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jsonObject(
|
|
35
|
+
value: JsonValue | undefined,
|
|
36
|
+
): Record<string, JsonValue> | undefined {
|
|
37
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
38
|
+
? (value as Record<string, JsonValue>)
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function payloadRef(node: BlockRoomNodeRef, path: string): BlockRoomPayloadRef {
|
|
43
|
+
return { ...node, path };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readPayload(document: Y.Doc, ref: BlockRoomPayloadRef): unknown {
|
|
47
|
+
return payloadValue(
|
|
48
|
+
roomNode(document, ref),
|
|
49
|
+
ref.path,
|
|
50
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isJsonArray(value: unknown): value is JsonValue[] {
|
|
55
|
+
return Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stableArrayIdentityField(
|
|
59
|
+
before: readonly JsonValue[],
|
|
60
|
+
after: readonly JsonValue[],
|
|
61
|
+
): (typeof STABLE_ARRAY_ID_FIELDS)[number] | undefined {
|
|
62
|
+
const compatible = (values: readonly JsonValue[], field: string) => {
|
|
63
|
+
const ids = values.map((value) => jsonObject(value)?.[field]);
|
|
64
|
+
return (
|
|
65
|
+
ids.every((id) => typeof id === "string" && id.length > 0) &&
|
|
66
|
+
new Set(ids).size === ids.length
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
for (const field of STABLE_ARRAY_ID_FIELDS) {
|
|
70
|
+
if (compatible(before, field) && compatible(after, field)) return field;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function identityOf(value: JsonValue, field: string): string {
|
|
76
|
+
const identity = jsonObject(value)?.[field];
|
|
77
|
+
return typeof identity === "string" && identity.length > 0
|
|
78
|
+
? identity
|
|
79
|
+
: fail(`replay:stable_array_identity:${field}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function currentArrayItems(
|
|
83
|
+
array: Y.Array<unknown>,
|
|
84
|
+
field: string,
|
|
85
|
+
): JsonValue[] {
|
|
86
|
+
const values = array.toArray().map((value) => fromYValue(value));
|
|
87
|
+
const ids = values.map((value) => identityOf(value, field));
|
|
88
|
+
if (new Set(ids).size !== ids.length)
|
|
89
|
+
fail(`replay:stable_array_duplicate:${field}`);
|
|
90
|
+
return values;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function indexOfIdentity(
|
|
94
|
+
values: readonly JsonValue[],
|
|
95
|
+
field: string,
|
|
96
|
+
id: string,
|
|
97
|
+
): number {
|
|
98
|
+
return values.findIndex((value) => identityOf(value, field) === id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function arrayPathItem(path: string, index: number): string {
|
|
102
|
+
return `${path}[${index}]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mergeStableArray(
|
|
106
|
+
document: Y.Doc,
|
|
107
|
+
ref: BlockRoomNodeRef,
|
|
108
|
+
path: string,
|
|
109
|
+
before: readonly JsonValue[],
|
|
110
|
+
after: readonly JsonValue[],
|
|
111
|
+
identityField: string,
|
|
112
|
+
): void {
|
|
113
|
+
const initial = readPayload(document, payloadRef(ref, path));
|
|
114
|
+
if (initial === undefined) {
|
|
115
|
+
replaceBlockRoomPayloadArray(document, payloadRef(ref, path), []);
|
|
116
|
+
}
|
|
117
|
+
const inserted = readPayload(document, payloadRef(ref, path));
|
|
118
|
+
if (!(inserted instanceof Y.Array))
|
|
119
|
+
fail(`replay:stable_array_shape:${ref.id}:${path}`);
|
|
120
|
+
const raw: Y.Array<unknown> = inserted;
|
|
121
|
+
const beforeIds = before.map((value) => identityOf(value, identityField));
|
|
122
|
+
const afterIds = after.map((value) => identityOf(value, identityField));
|
|
123
|
+
const beforeSet = new Set(beforeIds);
|
|
124
|
+
const afterSet = new Set(afterIds);
|
|
125
|
+
const beforeById = new Map(
|
|
126
|
+
before.map((value) => [identityOf(value, identityField), value]),
|
|
127
|
+
);
|
|
128
|
+
const afterById = new Map(
|
|
129
|
+
after.map((value) => [identityOf(value, identityField), value]),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
for (const id of beforeIds) {
|
|
133
|
+
if (afterSet.has(id)) continue;
|
|
134
|
+
const values = currentArrayItems(raw, identityField);
|
|
135
|
+
const index = indexOfIdentity(values, identityField, id);
|
|
136
|
+
if (index >= 0)
|
|
137
|
+
deleteBlockRoomPayloadArrayItem(document, payloadRef(ref, path), index);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const id of beforeIds) {
|
|
141
|
+
if (!afterSet.has(id)) continue;
|
|
142
|
+
const values = currentArrayItems(raw, identityField);
|
|
143
|
+
const index = indexOfIdentity(values, identityField, id);
|
|
144
|
+
if (index < 0) continue;
|
|
145
|
+
const oldValue = beforeById.get(id)!;
|
|
146
|
+
const nextValue = afterById.get(id)!;
|
|
147
|
+
if (!sameValue(oldValue, nextValue)) {
|
|
148
|
+
applyPayloadDiff(
|
|
149
|
+
document,
|
|
150
|
+
payloadRef(ref, arrayPathItem(path, index)),
|
|
151
|
+
oldValue,
|
|
152
|
+
nextValue,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (let afterIndex = 0; afterIndex < afterIds.length; afterIndex += 1) {
|
|
158
|
+
const id = afterIds[afterIndex]!;
|
|
159
|
+
if (beforeSet.has(id)) continue;
|
|
160
|
+
const values = currentArrayItems(raw, identityField);
|
|
161
|
+
if (indexOfIdentity(values, identityField, id) >= 0) continue;
|
|
162
|
+
const nextId = afterIds
|
|
163
|
+
.slice(afterIndex + 1)
|
|
164
|
+
.find(
|
|
165
|
+
(candidate) => indexOfIdentity(values, identityField, candidate) >= 0,
|
|
166
|
+
);
|
|
167
|
+
const insertionIndex = nextId
|
|
168
|
+
? indexOfIdentity(values, identityField, nextId)
|
|
169
|
+
: values.length;
|
|
170
|
+
insertBlockRoomPayloadArrayItem(
|
|
171
|
+
document,
|
|
172
|
+
payloadRef(ref, path),
|
|
173
|
+
insertionIndex,
|
|
174
|
+
afterById.get(id)!,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const common = beforeIds.filter((id) => afterSet.has(id));
|
|
179
|
+
const afterCommon = afterIds.filter((id) => beforeSet.has(id));
|
|
180
|
+
const retained = longestCommonSubsequence(common, afterCommon);
|
|
181
|
+
for (const id of [...afterCommon].reverse()) {
|
|
182
|
+
if (retained.has(id)) continue;
|
|
183
|
+
const values = currentArrayItems(raw, identityField);
|
|
184
|
+
const fromIndex = indexOfIdentity(values, identityField, id);
|
|
185
|
+
if (fromIndex < 0) continue;
|
|
186
|
+
const afterPosition = afterIds.indexOf(id);
|
|
187
|
+
const nextId = afterIds
|
|
188
|
+
.slice(afterPosition + 1)
|
|
189
|
+
.find(
|
|
190
|
+
(candidate) => indexOfIdentity(values, identityField, candidate) >= 0,
|
|
191
|
+
);
|
|
192
|
+
const nextIndex = nextId
|
|
193
|
+
? indexOfIdentity(values, identityField, nextId)
|
|
194
|
+
: values.length;
|
|
195
|
+
const toIndex = nextIndex - (fromIndex < nextIndex ? 1 : 0);
|
|
196
|
+
if (fromIndex === toIndex) continue;
|
|
197
|
+
moveBlockRoomPayloadArrayItem(
|
|
198
|
+
document,
|
|
199
|
+
payloadRef(ref, path),
|
|
200
|
+
fromIndex,
|
|
201
|
+
Math.max(0, toIndex),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ensureObjectAtPath(
|
|
207
|
+
document: Y.Doc,
|
|
208
|
+
ref: BlockRoomPayloadRef,
|
|
209
|
+
): Y.Map<unknown> {
|
|
210
|
+
const value = readPayload(document, ref);
|
|
211
|
+
if (value instanceof Y.Map) return value;
|
|
212
|
+
const { parent, key } = payloadParent(
|
|
213
|
+
roomNode(document, ref),
|
|
214
|
+
ref.path,
|
|
215
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
216
|
+
);
|
|
217
|
+
// Object diffs recurse only after ensuring their parent object path.
|
|
218
|
+
const objectParent = parent as Y.Map<unknown>;
|
|
219
|
+
const objectKey = key as string;
|
|
220
|
+
const map = new Y.Map<unknown>();
|
|
221
|
+
objectParent.set(objectKey, map);
|
|
222
|
+
return map;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function deletePayloadValue(document: Y.Doc, ref: BlockRoomPayloadRef): void {
|
|
226
|
+
const value = readPayload(document, ref);
|
|
227
|
+
if (value === undefined) return;
|
|
228
|
+
const { parent, key } = payloadParent(
|
|
229
|
+
roomNode(document, ref),
|
|
230
|
+
ref.path,
|
|
231
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
232
|
+
);
|
|
233
|
+
// Deletion diffs are emitted only for object keys, never array indexes.
|
|
234
|
+
const objectParent = parent as Y.Map<unknown>;
|
|
235
|
+
const objectKey = key as string;
|
|
236
|
+
objectParent.delete(objectKey);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function writePayloadValue(
|
|
240
|
+
document: Y.Doc,
|
|
241
|
+
ref: BlockRoomPayloadRef,
|
|
242
|
+
value: JsonValue[] | string | number | boolean | null,
|
|
243
|
+
): void {
|
|
244
|
+
if (Array.isArray(value)) {
|
|
245
|
+
replaceBlockRoomPayloadArray(document, ref, value);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
setBlockRoomAtomicValue(document, ref, value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function applyPayloadDiff(
|
|
252
|
+
document: Y.Doc,
|
|
253
|
+
ref: BlockRoomPayloadRef,
|
|
254
|
+
before: JsonValue | undefined,
|
|
255
|
+
after: JsonValue | undefined,
|
|
256
|
+
): void {
|
|
257
|
+
if (
|
|
258
|
+
isBlockRoomInlineContentRef(ref) &&
|
|
259
|
+
(before === undefined || isJsonArray(before)) &&
|
|
260
|
+
(after === undefined || isJsonArray(after))
|
|
261
|
+
) {
|
|
262
|
+
const current = readPayload(document, ref);
|
|
263
|
+
const peer = current instanceof Y.Array ? fromYInlineContent(current) : [];
|
|
264
|
+
reconcileBlockRoomInlineContent(
|
|
265
|
+
document,
|
|
266
|
+
ref,
|
|
267
|
+
peer,
|
|
268
|
+
mergeInlineIntent(before ?? [], after ?? [], peer),
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
(before === undefined || typeof before === "string") &&
|
|
274
|
+
(after === undefined || typeof after === "string") &&
|
|
275
|
+
nodeTextPredicate(roomNode(document, ref), ref)(ref.path)
|
|
276
|
+
) {
|
|
277
|
+
const current = readPayload(document, ref);
|
|
278
|
+
if (current !== undefined && !(current instanceof Y.Text))
|
|
279
|
+
fail(`replay:payload_shape:${ref.id}:${ref.path}`);
|
|
280
|
+
replaceBlockRoomCollaborativeText(
|
|
281
|
+
document,
|
|
282
|
+
ref,
|
|
283
|
+
mergeTextIntent(before ?? "", after ?? "", current?.toString() ?? ""),
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (after === undefined) {
|
|
288
|
+
deletePayloadValue(document, ref);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const beforeObject = jsonObject(before);
|
|
292
|
+
if (after !== null && typeof after === "object" && !Array.isArray(after)) {
|
|
293
|
+
const afterObject = after as Record<string, JsonValue>;
|
|
294
|
+
const current = (() => {
|
|
295
|
+
try {
|
|
296
|
+
return readPayload(document, ref);
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
})();
|
|
301
|
+
if (current !== undefined && !(current instanceof Y.Map))
|
|
302
|
+
fail(`replay:payload_shape:${ref.id}:${ref.path}`);
|
|
303
|
+
ensureObjectAtPath(document, ref);
|
|
304
|
+
for (const key of new Set([
|
|
305
|
+
...Object.keys(beforeObject ?? {}),
|
|
306
|
+
...Object.keys(afterObject),
|
|
307
|
+
])) {
|
|
308
|
+
const oldValue = beforeObject?.[key];
|
|
309
|
+
const nextValue = afterObject[key];
|
|
310
|
+
if (sameValue(oldValue, nextValue)) continue;
|
|
311
|
+
applyPayloadDiff(
|
|
312
|
+
document,
|
|
313
|
+
payloadRef(ref, `${ref.path}.${key}`),
|
|
314
|
+
oldValue,
|
|
315
|
+
nextValue,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (isJsonArray(before) && isJsonArray(after)) {
|
|
321
|
+
const identityField = stableArrayIdentityField(before, after);
|
|
322
|
+
if (identityField) {
|
|
323
|
+
mergeStableArray(document, ref, ref.path, before, after, identityField);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
writePayloadValue(document, ref, after);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function applyPayloadRootDiff(
|
|
331
|
+
document: Y.Doc,
|
|
332
|
+
ref: BlockRoomNodeRef,
|
|
333
|
+
before: JsonValue,
|
|
334
|
+
after: JsonValue,
|
|
335
|
+
): void {
|
|
336
|
+
const beforeObject = jsonObject(before);
|
|
337
|
+
const afterObject = jsonObject(after);
|
|
338
|
+
if (!beforeObject || !afterObject) fail(`replay:payload_root:${ref.id}`);
|
|
339
|
+
for (const key of new Set([
|
|
340
|
+
...Object.keys(beforeObject),
|
|
341
|
+
...Object.keys(afterObject),
|
|
342
|
+
])) {
|
|
343
|
+
const oldValue = beforeObject[key];
|
|
344
|
+
const nextValue = afterObject[key];
|
|
345
|
+
if (sameValue(oldValue, nextValue)) continue;
|
|
346
|
+
applyPayloadDiff(document, payloadRef(ref, key), oldValue, nextValue);
|
|
347
|
+
}
|
|
348
|
+
}
|