@echovisionlab/geul-common 0.3.0 → 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
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.3.
|
|
5
|
+
"version": "0.3.1",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"repository": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@bufbuild/protobuf": "^2.14.0",
|
|
66
66
|
"@echovisionlab/geul-proto": "^0.3.0",
|
|
67
|
+
"diff": "9.0.0",
|
|
67
68
|
"zod": "^4.4.3"
|
|
68
69
|
},
|
|
69
70
|
"peerDependencies": {
|
|
@@ -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
|
+
}
|
|
@@ -18,6 +18,12 @@ import {
|
|
|
18
18
|
type BlockRoomPayloadRef,
|
|
19
19
|
} from "./room-access.ts";
|
|
20
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";
|
|
21
27
|
|
|
22
28
|
const STABLE_ARRAY_ID_FIELDS = ["id", "rowId", "cellId", "unitId"] as const;
|
|
23
29
|
|
|
@@ -239,12 +245,7 @@ function writePayloadValue(
|
|
|
239
245
|
replaceBlockRoomPayloadArray(document, ref, value);
|
|
240
246
|
return;
|
|
241
247
|
}
|
|
242
|
-
|
|
243
|
-
if (typeof value === "string" && nodeTextPredicate(node, ref)(ref.path)) {
|
|
244
|
-
replaceBlockRoomCollaborativeText(document, ref, value);
|
|
245
|
-
} else {
|
|
246
|
-
setBlockRoomAtomicValue(document, ref, value);
|
|
247
|
-
}
|
|
248
|
+
setBlockRoomAtomicValue(document, ref, value);
|
|
248
249
|
}
|
|
249
250
|
|
|
250
251
|
function applyPayloadDiff(
|
|
@@ -253,6 +254,36 @@ function applyPayloadDiff(
|
|
|
253
254
|
before: JsonValue | undefined,
|
|
254
255
|
after: JsonValue | undefined,
|
|
255
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
|
+
}
|
|
256
287
|
if (after === undefined) {
|
|
257
288
|
deletePayloadValue(document, ref);
|
|
258
289
|
return;
|
|
@@ -4,8 +4,7 @@ export type FormSchemaPatchErrorReason =
|
|
|
4
4
|
| "invalid_schema"
|
|
5
5
|
| "schema_identity_changed"
|
|
6
6
|
| "target_topology_changed"
|
|
7
|
-
| "schema_identity_collision"
|
|
8
|
-
| "schema_add_parent_missing";
|
|
7
|
+
| "schema_identity_collision";
|
|
9
8
|
|
|
10
9
|
export class FormSchemaPatchError extends Error {
|
|
11
10
|
constructor(readonly reason: FormSchemaPatchErrorReason) {
|
|
@@ -335,6 +334,7 @@ export function applyFormSchemaPatch(
|
|
|
335
334
|
}
|
|
336
335
|
|
|
337
336
|
const added = new Set<string>();
|
|
337
|
+
const skippedAdditions = new Set<string>();
|
|
338
338
|
for (const [key, node] of next.nodes) {
|
|
339
339
|
if (previous.nodes.has(key)) continue;
|
|
340
340
|
if (result.nodes.has(key)) {
|
|
@@ -344,8 +344,15 @@ export function applyFormSchemaPatch(
|
|
|
344
344
|
}
|
|
345
345
|
continue;
|
|
346
346
|
}
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
// Schemas are indexed parent-first. A missing parent here is an observed
|
|
348
|
+
// peer deletion or a new parent already skipped beneath one.
|
|
349
|
+
if (
|
|
350
|
+
node.parent !== ROOT_PARENT &&
|
|
351
|
+
(skippedAdditions.has(node.parent) ||
|
|
352
|
+
(previous.nodes.has(node.parent) && !current.nodes.has(node.parent)))
|
|
353
|
+
) {
|
|
354
|
+
skippedAdditions.add(key);
|
|
355
|
+
continue;
|
|
349
356
|
}
|
|
350
357
|
result.nodes.set(key, structuredClone(node));
|
|
351
358
|
added.add(key);
|
|
@@ -158,6 +158,7 @@ export function replaceMenuCanonicalSource(
|
|
|
158
158
|
sourceLabels.delete(id);
|
|
159
159
|
}
|
|
160
160
|
for (const [id, item] of nextById) {
|
|
161
|
+
if (!document.getMap<string>(MENU_ITEMS_MAP_NAME).has(id)) continue;
|
|
161
162
|
const before = previousById.get(id);
|
|
162
163
|
const previouslyOwned = before
|
|
163
164
|
? menuItemOwnsLocaleLabel(before, locale)
|
|
@@ -242,6 +243,10 @@ function replaceMenuStructure(
|
|
|
242
243
|
const items = document.getMap<string>(MENU_ITEMS_MAP_NAME);
|
|
243
244
|
const parents = document.getMap<string>(MENU_PARENTS_MAP_NAME);
|
|
244
245
|
const orders = document.getMap<number>(MENU_ORDERS_MAP_NAME);
|
|
246
|
+
const liveIDs = new Set(items.keys());
|
|
247
|
+
const peerDeletedObservedIDs = new Set(
|
|
248
|
+
[...previousIDs].filter((id) => !liveIDs.has(id)),
|
|
249
|
+
);
|
|
245
250
|
const childrenByParent = new Map<string, string[]>();
|
|
246
251
|
for (const id of items.keys()) {
|
|
247
252
|
const parent = parents.get(id) ?? MENU_ROOT_PARENT;
|
|
@@ -251,11 +256,25 @@ function replaceMenuStructure(
|
|
|
251
256
|
}
|
|
252
257
|
|
|
253
258
|
const deletedIDs = new Set([...previousIDs].filter((id) => !nextIDs.has(id)));
|
|
259
|
+
for (const id of peerDeletedObservedIDs) deletedIDs.add(id);
|
|
260
|
+
const skippedNextIDs = new Set<string>();
|
|
261
|
+
const blockedNextParents = new Set(peerDeletedObservedIDs);
|
|
262
|
+
for (const { item, parent } of flattened) {
|
|
263
|
+
if (blockedNextParents.has(parent) || peerDeletedObservedIDs.has(item.id)) {
|
|
264
|
+
skippedNextIDs.add(item.id);
|
|
265
|
+
blockedNextParents.add(item.id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
254
268
|
const pendingDeletedIDs = [...deletedIDs];
|
|
255
269
|
for (let index = 0; index < pendingDeletedIDs.length; index += 1) {
|
|
256
270
|
const deletedParent = pendingDeletedIDs[index]!;
|
|
257
271
|
for (const childId of childrenByParent.get(deletedParent) ?? []) {
|
|
258
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
(nextIDs.has(childId) && !skippedNextIDs.has(childId)) ||
|
|
274
|
+
deletedIDs.has(childId)
|
|
275
|
+
) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
259
278
|
deletedIDs.add(childId);
|
|
260
279
|
pendingDeletedIDs.push(childId);
|
|
261
280
|
}
|
|
@@ -266,6 +285,7 @@ function replaceMenuStructure(
|
|
|
266
285
|
orders.delete(id);
|
|
267
286
|
}
|
|
268
287
|
for (const { item, parent, order } of flattened) {
|
|
288
|
+
if (skippedNextIDs.has(item.id)) continue;
|
|
269
289
|
const before = previousById.get(item.id);
|
|
270
290
|
const stored = menuItemStructure(item);
|
|
271
291
|
if (
|