@echovisionlab/geul-common 0.2.4 → 0.3.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 +3 -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-locale.ts +182 -0
- package/src/collaboration/block-room-codec/replay-payload.ts +317 -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 +440 -0
- package/src/collaboration/menu.ts +109 -19
- package/src/index.ts +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
|
|
22
|
+
const STABLE_ARRAY_ID_FIELDS = ["id", "rowId", "cellId", "unitId"] as const;
|
|
23
|
+
|
|
24
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
25
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsonObject(
|
|
29
|
+
value: JsonValue | undefined,
|
|
30
|
+
): Record<string, JsonValue> | undefined {
|
|
31
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
32
|
+
? (value as Record<string, JsonValue>)
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function payloadRef(node: BlockRoomNodeRef, path: string): BlockRoomPayloadRef {
|
|
37
|
+
return { ...node, path };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readPayload(document: Y.Doc, ref: BlockRoomPayloadRef): unknown {
|
|
41
|
+
return payloadValue(
|
|
42
|
+
roomNode(document, ref),
|
|
43
|
+
ref.path,
|
|
44
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isJsonArray(value: unknown): value is JsonValue[] {
|
|
49
|
+
return Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stableArrayIdentityField(
|
|
53
|
+
before: readonly JsonValue[],
|
|
54
|
+
after: readonly JsonValue[],
|
|
55
|
+
): (typeof STABLE_ARRAY_ID_FIELDS)[number] | undefined {
|
|
56
|
+
const compatible = (values: readonly JsonValue[], field: string) => {
|
|
57
|
+
const ids = values.map((value) => jsonObject(value)?.[field]);
|
|
58
|
+
return (
|
|
59
|
+
ids.every((id) => typeof id === "string" && id.length > 0) &&
|
|
60
|
+
new Set(ids).size === ids.length
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
for (const field of STABLE_ARRAY_ID_FIELDS) {
|
|
64
|
+
if (compatible(before, field) && compatible(after, field)) return field;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function identityOf(value: JsonValue, field: string): string {
|
|
70
|
+
const identity = jsonObject(value)?.[field];
|
|
71
|
+
return typeof identity === "string" && identity.length > 0
|
|
72
|
+
? identity
|
|
73
|
+
: fail(`replay:stable_array_identity:${field}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function currentArrayItems(
|
|
77
|
+
array: Y.Array<unknown>,
|
|
78
|
+
field: string,
|
|
79
|
+
): JsonValue[] {
|
|
80
|
+
const values = array.toArray().map((value) => fromYValue(value));
|
|
81
|
+
const ids = values.map((value) => identityOf(value, field));
|
|
82
|
+
if (new Set(ids).size !== ids.length)
|
|
83
|
+
fail(`replay:stable_array_duplicate:${field}`);
|
|
84
|
+
return values;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function indexOfIdentity(
|
|
88
|
+
values: readonly JsonValue[],
|
|
89
|
+
field: string,
|
|
90
|
+
id: string,
|
|
91
|
+
): number {
|
|
92
|
+
return values.findIndex((value) => identityOf(value, field) === id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function arrayPathItem(path: string, index: number): string {
|
|
96
|
+
return `${path}[${index}]`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function mergeStableArray(
|
|
100
|
+
document: Y.Doc,
|
|
101
|
+
ref: BlockRoomNodeRef,
|
|
102
|
+
path: string,
|
|
103
|
+
before: readonly JsonValue[],
|
|
104
|
+
after: readonly JsonValue[],
|
|
105
|
+
identityField: string,
|
|
106
|
+
): void {
|
|
107
|
+
const initial = readPayload(document, payloadRef(ref, path));
|
|
108
|
+
if (initial === undefined) {
|
|
109
|
+
replaceBlockRoomPayloadArray(document, payloadRef(ref, path), []);
|
|
110
|
+
}
|
|
111
|
+
const inserted = readPayload(document, payloadRef(ref, path));
|
|
112
|
+
if (!(inserted instanceof Y.Array))
|
|
113
|
+
fail(`replay:stable_array_shape:${ref.id}:${path}`);
|
|
114
|
+
const raw: Y.Array<unknown> = inserted;
|
|
115
|
+
const beforeIds = before.map((value) => identityOf(value, identityField));
|
|
116
|
+
const afterIds = after.map((value) => identityOf(value, identityField));
|
|
117
|
+
const beforeSet = new Set(beforeIds);
|
|
118
|
+
const afterSet = new Set(afterIds);
|
|
119
|
+
const beforeById = new Map(
|
|
120
|
+
before.map((value) => [identityOf(value, identityField), value]),
|
|
121
|
+
);
|
|
122
|
+
const afterById = new Map(
|
|
123
|
+
after.map((value) => [identityOf(value, identityField), value]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const id of beforeIds) {
|
|
127
|
+
if (afterSet.has(id)) continue;
|
|
128
|
+
const values = currentArrayItems(raw, identityField);
|
|
129
|
+
const index = indexOfIdentity(values, identityField, id);
|
|
130
|
+
if (index >= 0)
|
|
131
|
+
deleteBlockRoomPayloadArrayItem(document, payloadRef(ref, path), index);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const id of beforeIds) {
|
|
135
|
+
if (!afterSet.has(id)) continue;
|
|
136
|
+
const values = currentArrayItems(raw, identityField);
|
|
137
|
+
const index = indexOfIdentity(values, identityField, id);
|
|
138
|
+
if (index < 0) continue;
|
|
139
|
+
const oldValue = beforeById.get(id)!;
|
|
140
|
+
const nextValue = afterById.get(id)!;
|
|
141
|
+
if (!sameValue(oldValue, nextValue)) {
|
|
142
|
+
applyPayloadDiff(
|
|
143
|
+
document,
|
|
144
|
+
payloadRef(ref, arrayPathItem(path, index)),
|
|
145
|
+
oldValue,
|
|
146
|
+
nextValue,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let afterIndex = 0; afterIndex < afterIds.length; afterIndex += 1) {
|
|
152
|
+
const id = afterIds[afterIndex]!;
|
|
153
|
+
if (beforeSet.has(id)) continue;
|
|
154
|
+
const values = currentArrayItems(raw, identityField);
|
|
155
|
+
if (indexOfIdentity(values, identityField, id) >= 0) continue;
|
|
156
|
+
const nextId = afterIds
|
|
157
|
+
.slice(afterIndex + 1)
|
|
158
|
+
.find(
|
|
159
|
+
(candidate) => indexOfIdentity(values, identityField, candidate) >= 0,
|
|
160
|
+
);
|
|
161
|
+
const insertionIndex = nextId
|
|
162
|
+
? indexOfIdentity(values, identityField, nextId)
|
|
163
|
+
: values.length;
|
|
164
|
+
insertBlockRoomPayloadArrayItem(
|
|
165
|
+
document,
|
|
166
|
+
payloadRef(ref, path),
|
|
167
|
+
insertionIndex,
|
|
168
|
+
afterById.get(id)!,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const common = beforeIds.filter((id) => afterSet.has(id));
|
|
173
|
+
const afterCommon = afterIds.filter((id) => beforeSet.has(id));
|
|
174
|
+
const retained = longestCommonSubsequence(common, afterCommon);
|
|
175
|
+
for (const id of [...afterCommon].reverse()) {
|
|
176
|
+
if (retained.has(id)) continue;
|
|
177
|
+
const values = currentArrayItems(raw, identityField);
|
|
178
|
+
const fromIndex = indexOfIdentity(values, identityField, id);
|
|
179
|
+
if (fromIndex < 0) continue;
|
|
180
|
+
const afterPosition = afterIds.indexOf(id);
|
|
181
|
+
const nextId = afterIds
|
|
182
|
+
.slice(afterPosition + 1)
|
|
183
|
+
.find(
|
|
184
|
+
(candidate) => indexOfIdentity(values, identityField, candidate) >= 0,
|
|
185
|
+
);
|
|
186
|
+
const nextIndex = nextId
|
|
187
|
+
? indexOfIdentity(values, identityField, nextId)
|
|
188
|
+
: values.length;
|
|
189
|
+
const toIndex = nextIndex - (fromIndex < nextIndex ? 1 : 0);
|
|
190
|
+
if (fromIndex === toIndex) continue;
|
|
191
|
+
moveBlockRoomPayloadArrayItem(
|
|
192
|
+
document,
|
|
193
|
+
payloadRef(ref, path),
|
|
194
|
+
fromIndex,
|
|
195
|
+
Math.max(0, toIndex),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ensureObjectAtPath(
|
|
201
|
+
document: Y.Doc,
|
|
202
|
+
ref: BlockRoomPayloadRef,
|
|
203
|
+
): Y.Map<unknown> {
|
|
204
|
+
const value = readPayload(document, ref);
|
|
205
|
+
if (value instanceof Y.Map) return value;
|
|
206
|
+
const { parent, key } = payloadParent(
|
|
207
|
+
roomNode(document, ref),
|
|
208
|
+
ref.path,
|
|
209
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
210
|
+
);
|
|
211
|
+
// Object diffs recurse only after ensuring their parent object path.
|
|
212
|
+
const objectParent = parent as Y.Map<unknown>;
|
|
213
|
+
const objectKey = key as string;
|
|
214
|
+
const map = new Y.Map<unknown>();
|
|
215
|
+
objectParent.set(objectKey, map);
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function deletePayloadValue(document: Y.Doc, ref: BlockRoomPayloadRef): void {
|
|
220
|
+
const value = readPayload(document, ref);
|
|
221
|
+
if (value === undefined) return;
|
|
222
|
+
const { parent, key } = payloadParent(
|
|
223
|
+
roomNode(document, ref),
|
|
224
|
+
ref.path,
|
|
225
|
+
`replay:payload:${ref.id}:${ref.path}`,
|
|
226
|
+
);
|
|
227
|
+
// Deletion diffs are emitted only for object keys, never array indexes.
|
|
228
|
+
const objectParent = parent as Y.Map<unknown>;
|
|
229
|
+
const objectKey = key as string;
|
|
230
|
+
objectParent.delete(objectKey);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function writePayloadValue(
|
|
234
|
+
document: Y.Doc,
|
|
235
|
+
ref: BlockRoomPayloadRef,
|
|
236
|
+
value: JsonValue[] | string | number | boolean | null,
|
|
237
|
+
): void {
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
replaceBlockRoomPayloadArray(document, ref, value);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const node = roomNode(document, ref);
|
|
243
|
+
if (typeof value === "string" && nodeTextPredicate(node, ref)(ref.path)) {
|
|
244
|
+
replaceBlockRoomCollaborativeText(document, ref, value);
|
|
245
|
+
} else {
|
|
246
|
+
setBlockRoomAtomicValue(document, ref, value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function applyPayloadDiff(
|
|
251
|
+
document: Y.Doc,
|
|
252
|
+
ref: BlockRoomPayloadRef,
|
|
253
|
+
before: JsonValue | undefined,
|
|
254
|
+
after: JsonValue | undefined,
|
|
255
|
+
): void {
|
|
256
|
+
if (after === undefined) {
|
|
257
|
+
deletePayloadValue(document, ref);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const beforeObject = jsonObject(before);
|
|
261
|
+
if (after !== null && typeof after === "object" && !Array.isArray(after)) {
|
|
262
|
+
const afterObject = after as Record<string, JsonValue>;
|
|
263
|
+
const current = (() => {
|
|
264
|
+
try {
|
|
265
|
+
return readPayload(document, ref);
|
|
266
|
+
} catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
if (current !== undefined && !(current instanceof Y.Map))
|
|
271
|
+
fail(`replay:payload_shape:${ref.id}:${ref.path}`);
|
|
272
|
+
ensureObjectAtPath(document, ref);
|
|
273
|
+
for (const key of new Set([
|
|
274
|
+
...Object.keys(beforeObject ?? {}),
|
|
275
|
+
...Object.keys(afterObject),
|
|
276
|
+
])) {
|
|
277
|
+
const oldValue = beforeObject?.[key];
|
|
278
|
+
const nextValue = afterObject[key];
|
|
279
|
+
if (sameValue(oldValue, nextValue)) continue;
|
|
280
|
+
applyPayloadDiff(
|
|
281
|
+
document,
|
|
282
|
+
payloadRef(ref, `${ref.path}.${key}`),
|
|
283
|
+
oldValue,
|
|
284
|
+
nextValue,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (isJsonArray(before) && isJsonArray(after)) {
|
|
290
|
+
const identityField = stableArrayIdentityField(before, after);
|
|
291
|
+
if (identityField) {
|
|
292
|
+
mergeStableArray(document, ref, ref.path, before, after, identityField);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
writePayloadValue(document, ref, after);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function applyPayloadRootDiff(
|
|
300
|
+
document: Y.Doc,
|
|
301
|
+
ref: BlockRoomNodeRef,
|
|
302
|
+
before: JsonValue,
|
|
303
|
+
after: JsonValue,
|
|
304
|
+
): void {
|
|
305
|
+
const beforeObject = jsonObject(before);
|
|
306
|
+
const afterObject = jsonObject(after);
|
|
307
|
+
if (!beforeObject || !afterObject) fail(`replay:payload_root:${ref.id}`);
|
|
308
|
+
for (const key of new Set([
|
|
309
|
+
...Object.keys(beforeObject),
|
|
310
|
+
...Object.keys(afterObject),
|
|
311
|
+
])) {
|
|
312
|
+
const oldValue = beforeObject[key];
|
|
313
|
+
const nextValue = afterObject[key];
|
|
314
|
+
if (sameValue(oldValue, nextValue)) continue;
|
|
315
|
+
applyPayloadDiff(document, payloadRef(ref, key), oldValue, nextValue);
|
|
316
|
+
}
|
|
317
|
+
}
|