@panphora/clayjs 1.6.1 → 1.8.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/README.md +5 -4
- package/dist/clay.standalone.js +9578 -6063
- package/entries/clay-data.js +8 -6
- package/package.json +2 -1
- package/src/core/conflict-notice.js +2 -4
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/page-data.js +321 -0
- package/src/core/save.js +32 -16
- package/src/lib/content-dom.js +3 -3
- package/src/lib/root-attrs.js +14 -0
- package/src/loader-logic.js +2 -0
- package/src/loader.js +7 -2
- package/src/plugins/ai-edit.js +307 -89
- package/src/plugins/wire.js +4 -4
- package/src/sync/conflict-footprints.js +278 -0
- package/src/sync/conflict-revert.js +18 -5
- package/src/sync/conflicts.js +30 -1
- package/src/sync/live-sync.js +331 -38
- package/src/sync/stream.js +17 -4
- package/src/ui/bevel-controls.js +8 -2
- package/src/ui/bevel-dialog.js +2 -4
- package/src/ui/bevel.js +22 -2
- package/src/ui/dialogs.js +1 -1
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/skins/quickcrop.js +1 -1
- package/src/ui/skins/richclay.js +2 -2
- package/src/ui/toast.js +7 -15
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/hypercms.vendor.js +12 -12
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +27 -19
package/src/plugins/wire.js
CHANGED
|
@@ -506,10 +506,10 @@ async function flushSave() {
|
|
|
506
506
|
// surface is an opt-in satellite script, absent on every page the loader
|
|
507
507
|
// builds, so reading it here made "is a save on the wire" permanently false.
|
|
508
508
|
const { isSaveInProgress } = await import("../core/save-core.js");
|
|
509
|
-
// A held save
|
|
510
|
-
// nothing to save: posting now
|
|
511
|
-
const { savesHeld } = await import("../core/save.js");
|
|
512
|
-
if (savesHeld()) throw new Error(
|
|
509
|
+
// A held save means another operation owns the page's persisted state, not a
|
|
510
|
+
// page with nothing to save: posting now could hand the agent stale bytes.
|
|
511
|
+
const { savesHeld, saveHoldReason } = await import("../core/save.js");
|
|
512
|
+
if (savesHeld()) throw new Error(saveHoldReason());
|
|
513
513
|
const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
|
|
514
514
|
|
|
515
515
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
const states = new WeakMap();
|
|
2
|
+
const released = new WeakSet();
|
|
3
|
+
const pending = new Set();
|
|
4
|
+
let generation = 0;
|
|
5
|
+
let sequence = 0;
|
|
6
|
+
|
|
7
|
+
const TEXT_BLOCKS = new Set('P H1 H2 H3 H4 H5 H6 DIV LI DD DT BLOCKQUOTE FIGCAPTION SUMMARY ADDRESS'.split(' '));
|
|
8
|
+
const isElement = (node) => node?.nodeType === 1;
|
|
9
|
+
const onPage = (node) => {
|
|
10
|
+
try {
|
|
11
|
+
return !!node && node.ownerDocument === document && document.documentElement.contains(node);
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const rawOf = (record) => record.rawReports?.length ? record.rawReports : [record];
|
|
17
|
+
const rawSet = (records) => new Set(records.flatMap(rawOf).filter((raw) => !released.has(raw)));
|
|
18
|
+
|
|
19
|
+
function childrenOf(node) {
|
|
20
|
+
return [...node.childNodes, ...(node.nodeType === 1 && node.localName === 'template' && node.content ? [node.content] : [])];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function attributesOf(node) {
|
|
24
|
+
return isElement(node) ? [...node.attributes].map((a) => [a.namespaceURI, a.name, a.value]) : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fieldsOf(node) {
|
|
28
|
+
const names = {
|
|
29
|
+
input: ['HTMLInputElement', 'value', 'checked'],
|
|
30
|
+
textarea: ['HTMLTextAreaElement', 'value'],
|
|
31
|
+
select: ['HTMLSelectElement', 'selectedIndex'],
|
|
32
|
+
option: ['HTMLOptionElement', 'selected'],
|
|
33
|
+
}[node.localName];
|
|
34
|
+
const prototype = names && document.defaultView[names[0]]?.prototype;
|
|
35
|
+
if (!prototype?.isPrototypeOf(node)) return [];
|
|
36
|
+
return names.slice(1).map((name) => Object.getOwnPropertyDescriptor(prototype, name).get.call(node));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function witness(node) {
|
|
40
|
+
const parents = [];
|
|
41
|
+
for (let p = node.parentNode; p; p = p.parentNode) parents.push(p);
|
|
42
|
+
const rows = [];
|
|
43
|
+
const visit = (n) => {
|
|
44
|
+
const children = childrenOf(n);
|
|
45
|
+
rows.push({ node: n, value: n.nodeValue, attributes: attributesOf(n), fields: fieldsOf(n), children });
|
|
46
|
+
for (const child of children) visit(child);
|
|
47
|
+
};
|
|
48
|
+
visit(node);
|
|
49
|
+
return { node, parents, rows };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function valid(w, topologyOnly = false) {
|
|
53
|
+
if (!onPage(w.node)) return false;
|
|
54
|
+
let p = w.node.parentNode;
|
|
55
|
+
for (const parent of w.parents) {
|
|
56
|
+
if (p !== parent) return false;
|
|
57
|
+
p = p.parentNode;
|
|
58
|
+
}
|
|
59
|
+
if (p) return false;
|
|
60
|
+
for (const row of w.rows) {
|
|
61
|
+
const children = childrenOf(row.node);
|
|
62
|
+
if (children.length !== row.children.length || children.some((n, i) => n !== row.children[i])) return false;
|
|
63
|
+
if (topologyOnly) continue;
|
|
64
|
+
if (row.node.nodeValue !== row.value) return false;
|
|
65
|
+
const attrs = attributesOf(row.node);
|
|
66
|
+
if (attrs.length !== row.attributes.length || attrs.some((a, i) => a.some((v, j) => v !== row.attributes[i][j]))) return false;
|
|
67
|
+
if (fieldsOf(row.node).some((value, i) => value !== row.fields[i])) return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const captureFootprintWitnesses = (nodes) => [...new Set(nodes)].map(witness);
|
|
73
|
+
export const footprintWitnessesValid = (witnesses) => witnesses.every((w) => valid(w));
|
|
74
|
+
export const protectionGeneration = () => generation;
|
|
75
|
+
|
|
76
|
+
function put(raw, nodes, mode, status = 'proved') {
|
|
77
|
+
if (released.has(raw)) return null;
|
|
78
|
+
const unique = [...new Set(nodes)];
|
|
79
|
+
const state = {
|
|
80
|
+
nodes: unique,
|
|
81
|
+
mode,
|
|
82
|
+
status,
|
|
83
|
+
generation: ++generation,
|
|
84
|
+
witnesses: mode === 'document' ? [] : unique.map(witness),
|
|
85
|
+
};
|
|
86
|
+
states.set(raw, state);
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const broad = (raw) => put(raw, [document.documentElement], 'document', 'unknown');
|
|
91
|
+
|
|
92
|
+
function spanNode(span) {
|
|
93
|
+
if (!onPage(span?.startContainer) || !onPage(span?.endContainer)) return null;
|
|
94
|
+
const above = new Set();
|
|
95
|
+
for (let n = span.startContainer; n; n = n.parentNode) above.add(n);
|
|
96
|
+
let node = span.endContainer;
|
|
97
|
+
while (node && !above.has(node)) node = node.parentNode;
|
|
98
|
+
if (!isElement(node)) node = node?.parentElement;
|
|
99
|
+
for (let n = node; n; n = n.parentElement) if (TEXT_BLOCKS.has(n.tagName)) return n;
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function seed(raw) {
|
|
104
|
+
const recovery = raw.recovery;
|
|
105
|
+
for (const span of [recovery?.text?.liveSpan, recovery?.text?.liveScope]) {
|
|
106
|
+
const node = spanNode(span);
|
|
107
|
+
if (node && node !== document.documentElement) return put(raw, [node], 'source');
|
|
108
|
+
}
|
|
109
|
+
const live = recovery?.subject?.live || [];
|
|
110
|
+
const nodes = live.map((n) => isElement(n) ? n : n?.parentElement);
|
|
111
|
+
if (nodes.length && nodes.every((n) => onPage(n) && n !== document.documentElement)) return put(raw, nodes, 'source');
|
|
112
|
+
return broad(raw);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function current(raw, create = false) {
|
|
116
|
+
let state = states.get(raw);
|
|
117
|
+
if (!state) return create ? seed(raw) : null;
|
|
118
|
+
if (state.mode === 'document') return state.nodes[0] === document.documentElement ? state : broad(raw);
|
|
119
|
+
if (state.witnesses.every((w) => valid(w))) return state;
|
|
120
|
+
// Original live identity still locates a loss after ordinary value edits.
|
|
121
|
+
// A transferred or broadened region has no such independent recovery anchor.
|
|
122
|
+
if (state.mode === 'source' && state.witnesses.every((w) => valid(w, true))) {
|
|
123
|
+
return put(raw, state.nodes, 'source');
|
|
124
|
+
}
|
|
125
|
+
return broad(raw);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function protectionOf(record) {
|
|
129
|
+
if (released.has(record)) return null;
|
|
130
|
+
const raws = [...rawSet([record])];
|
|
131
|
+
const values = raws.map((raw) => current(raw));
|
|
132
|
+
if (!values.some(Boolean)) return null;
|
|
133
|
+
if (values.some((state) => !state)) {
|
|
134
|
+
for (const raw of raws) if (!states.has(raw)) broad(raw);
|
|
135
|
+
return protectionOf(record);
|
|
136
|
+
}
|
|
137
|
+
const union = [...new Set(values.flatMap((state) => state.nodes))];
|
|
138
|
+
if (values.every((state) => state.mode === 'source') && union.length === 1) return null;
|
|
139
|
+
const nodes = values.some((state) => state.mode === 'document')
|
|
140
|
+
? [document.documentElement]
|
|
141
|
+
: union;
|
|
142
|
+
return { nodes, status: values.some((state) => state.status === 'unknown') ? 'unknown' : 'proved', generation };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function releaseProtection(record) {
|
|
146
|
+
for (const raw of rawOf(record)) {
|
|
147
|
+
released.add(raw);
|
|
148
|
+
states.delete(raw);
|
|
149
|
+
}
|
|
150
|
+
released.add(record);
|
|
151
|
+
states.delete(record);
|
|
152
|
+
generation++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function allRaw(records) {
|
|
156
|
+
const raws = rawSet(records);
|
|
157
|
+
for (const frame of pending) {
|
|
158
|
+
for (const raw of [...frame.tracked, ...frame.raws]) if (!released.has(raw)) raws.add(raw);
|
|
159
|
+
}
|
|
160
|
+
return raws;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function positive(entry) {
|
|
164
|
+
if (entry?.complete !== true || !Array.isArray(entry.to) || entry.to.length !== 1) return null;
|
|
165
|
+
const target = entry.to[0];
|
|
166
|
+
if (!isElement(target) || !onPage(target)) return null;
|
|
167
|
+
if (entry.kind === 'retained' && target === entry.from) return target;
|
|
168
|
+
if (entry.kind === 'replaced' && target !== entry.from && !onPage(entry.from)) return target;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function beginLineageCapture(records) {
|
|
173
|
+
const frame = {
|
|
174
|
+
serial: ++sequence,
|
|
175
|
+
tracked: allRaw(records),
|
|
176
|
+
raws: new Set(),
|
|
177
|
+
before: new Map(),
|
|
178
|
+
delivered: false,
|
|
179
|
+
invalid: false,
|
|
180
|
+
report: null,
|
|
181
|
+
finished: false,
|
|
182
|
+
};
|
|
183
|
+
generation++;
|
|
184
|
+
const elements = new Set();
|
|
185
|
+
for (const raw of frame.tracked) {
|
|
186
|
+
const state = current(raw, true);
|
|
187
|
+
const chains = state.mode === 'document' ? [] : state.nodes.map((node) => {
|
|
188
|
+
const chain = [];
|
|
189
|
+
for (let n = node; n && n !== document.documentElement; n = n.parentElement) {
|
|
190
|
+
if (onPage(n)) { chain.push(n); elements.add(n); }
|
|
191
|
+
}
|
|
192
|
+
return chain;
|
|
193
|
+
});
|
|
194
|
+
frame.before.set(raw, { state, chains });
|
|
195
|
+
}
|
|
196
|
+
pending.add(frame);
|
|
197
|
+
|
|
198
|
+
const invalidate = (openRecords = []) => {
|
|
199
|
+
frame.invalid = true;
|
|
200
|
+
generation++;
|
|
201
|
+
for (const raw of allRaw(openRecords)) broad(raw);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const lineage = {
|
|
205
|
+
elements: [...elements],
|
|
206
|
+
onResult(result, report) {
|
|
207
|
+
if (frame.finished || frame.delivered) {
|
|
208
|
+
invalidate();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
frame.delivered = true;
|
|
212
|
+
frame.report = report;
|
|
213
|
+
frame.raws = rawSet(report?.conflicts || []);
|
|
214
|
+
const complete = !frame.invalid && frame.serial === sequence && result?.version === 1 &&
|
|
215
|
+
result.root === document.documentElement && result.status === 'complete' &&
|
|
216
|
+
Array.isArray(result.entries) && report?.lineage === result;
|
|
217
|
+
if (!complete) { invalidate(); return; }
|
|
218
|
+
const entries = new Map();
|
|
219
|
+
for (const entry of result.entries) {
|
|
220
|
+
if (!entry || typeof entry !== 'object' || !isElement(entry.from)) continue;
|
|
221
|
+
if (entries.has(entry.from)) entries.set(entry.from, null);
|
|
222
|
+
else entries.set(entry.from, entry);
|
|
223
|
+
}
|
|
224
|
+
for (const [raw, { state, chains }] of frame.before) {
|
|
225
|
+
if (released.has(raw)) continue;
|
|
226
|
+
if (state.mode === 'document') { broad(raw); continue; }
|
|
227
|
+
const nodes = [];
|
|
228
|
+
let fallback = false;
|
|
229
|
+
for (const chain of chains) {
|
|
230
|
+
let found = null;
|
|
231
|
+
for (let i = 0; i < chain.length; i++) {
|
|
232
|
+
found = positive(entries.get(chain[i]));
|
|
233
|
+
if (found) { fallback ||= i > 0; break; }
|
|
234
|
+
}
|
|
235
|
+
if (!found) { nodes.push(document.documentElement); break; }
|
|
236
|
+
nodes.push(found);
|
|
237
|
+
}
|
|
238
|
+
if (!nodes.length || nodes.includes(document.documentElement)) broad(raw);
|
|
239
|
+
else {
|
|
240
|
+
const same = !fallback && state.mode === 'source' && nodes.every((n, i) => n === state.nodes[i]);
|
|
241
|
+
put(raw, nodes, same ? 'source' : 'lineage', fallback ? 'unknown' : state.status);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const raw of frame.raws) if (!states.has(raw)) seed(raw);
|
|
245
|
+
frame.before.clear();
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
lineage,
|
|
251
|
+
returned() {
|
|
252
|
+
if (!frame.delivered) invalidate();
|
|
253
|
+
},
|
|
254
|
+
invalidate,
|
|
255
|
+
prepareComplete(report, openRecords) {
|
|
256
|
+
if (report !== frame.report || !frame.delivered || frame.invalid) {
|
|
257
|
+
for (const raw of rawSet(report?.conflicts || [])) frame.raws.add(raw);
|
|
258
|
+
invalidate(openRecords);
|
|
259
|
+
} else {
|
|
260
|
+
for (const raw of rawSet(report?.conflicts || [])) {
|
|
261
|
+
if (!frame.raws.has(raw)) { frame.raws.add(raw); broad(raw); }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
finish(openRecords) {
|
|
266
|
+
if (frame.finished) return;
|
|
267
|
+
const keep = rawSet(openRecords);
|
|
268
|
+
for (const raw of frame.raws) if (!keep.has(raw)) states.delete(raw);
|
|
269
|
+
frame.finished = true;
|
|
270
|
+
pending.delete(frame);
|
|
271
|
+
frame.tracked.clear();
|
|
272
|
+
frame.raws.clear();
|
|
273
|
+
frame.before.clear();
|
|
274
|
+
frame.report = null;
|
|
275
|
+
lineage.elements = [];
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
2
2
|
import { conflicts } from "./conflicts.js";
|
|
3
|
+
import { protectionOf, protectionGeneration, captureFootprintWitnesses, footprintWitnessesValid } from "./conflict-footprints.js";
|
|
3
4
|
import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
|
|
4
5
|
import { savePage } from "../core/save.js";
|
|
5
6
|
import { gateMarkDirty } from "../lib/dirty-gate.js";
|
|
@@ -794,6 +795,11 @@ function covers(k, op) {
|
|
|
794
795
|
// the text block around its recorded clash, else around its recorded scope, else its
|
|
795
796
|
// live subject, else its live placement parent, else the nearest ancestor of its
|
|
796
797
|
// clone path still on the page by identity, else the whole page.
|
|
798
|
+
function footprintsOf(rec, apply) {
|
|
799
|
+
const protection = protectionOf(rec);
|
|
800
|
+
return protection ? protection.nodes : [footprintOf(rec, apply)];
|
|
801
|
+
}
|
|
802
|
+
|
|
797
803
|
function footprintOf(rec, apply) {
|
|
798
804
|
const r = rec.recovery;
|
|
799
805
|
const subject = r.subject;
|
|
@@ -835,7 +841,7 @@ function planOne(rec, apply) {
|
|
|
835
841
|
return null;
|
|
836
842
|
}
|
|
837
843
|
|
|
838
|
-
const revisionNow = () => `${conflicts.hasPendingApply() ? 1 : 0}|${conflicts.list().map((r) => r.id).join(",")}`;
|
|
844
|
+
const revisionNow = () => `${protectionGeneration()}|${conflicts.hasPendingApply() ? 1 : 0}|${conflicts.list().map((r) => r.id).join(",")}`;
|
|
839
845
|
|
|
840
846
|
// ---------------------------------------------------------------------------
|
|
841
847
|
// The whole page: what must hold after the writes
|
|
@@ -1004,7 +1010,9 @@ export function prepareRevert(ids) {
|
|
|
1004
1010
|
const plan = (rec) => {
|
|
1005
1011
|
const apply = conflicts.recoveryOf(rec.id);
|
|
1006
1012
|
const op = apply ? planOne(rec, apply) : null;
|
|
1007
|
-
if (!op && apply)
|
|
1013
|
+
if (!op && apply) {
|
|
1014
|
+
for (const node of footprintsOf(rec, apply)) constraints.push({ ticket: apply.ticket, node });
|
|
1015
|
+
}
|
|
1008
1016
|
return op;
|
|
1009
1017
|
};
|
|
1010
1018
|
const ops = [];
|
|
@@ -1050,7 +1058,10 @@ export function prepareRevert(ids) {
|
|
|
1050
1058
|
block(newer);
|
|
1051
1059
|
}
|
|
1052
1060
|
}
|
|
1053
|
-
|
|
1061
|
+
const protectedWitnesses = captureFootprintWitnesses(constraints
|
|
1062
|
+
.filter((c) => kept.some((op) => c.ticket > op.ticket))
|
|
1063
|
+
.map((c) => c.node));
|
|
1064
|
+
return { ops: kept, covered, blocked, revision: revisionNow(), protectedWitnesses, before: census() };
|
|
1054
1065
|
}
|
|
1055
1066
|
|
|
1056
1067
|
/**
|
|
@@ -1061,8 +1072,9 @@ export function prepareRevert(ids) {
|
|
|
1061
1072
|
* saved.
|
|
1062
1073
|
*/
|
|
1063
1074
|
export function applyRevert(plan) {
|
|
1064
|
-
if (plan.revision !== revisionNow()) {
|
|
1075
|
+
if (plan.revision !== revisionNow() || !footprintWitnessesValid(plan.protectedWitnesses || [])) {
|
|
1065
1076
|
for (const op of plan.ops) plan.blocked.set(op.id, BLOCKED);
|
|
1077
|
+
for (const id of plan.covered.keys()) plan.blocked.set(id, BLOCKED);
|
|
1066
1078
|
return [];
|
|
1067
1079
|
}
|
|
1068
1080
|
const page = remember();
|
|
@@ -1082,7 +1094,8 @@ export function applyRevert(plan) {
|
|
|
1082
1094
|
for (const op of plan.ops) if (op.kind === "attr") applyAttr(op);
|
|
1083
1095
|
applyStructure(plan.ops.filter((op) => op.kind !== "text" && op.kind !== "attr"), parked);
|
|
1084
1096
|
records = observer.takeRecords();
|
|
1085
|
-
ok = plan.ops.every((op) => op.check()) && holds(plan)
|
|
1097
|
+
ok = plan.ops.every((op) => op.check()) && holds(plan) &&
|
|
1098
|
+
plan.revision === revisionNow() && footprintWitnessesValid(plan.protectedWitnesses || []);
|
|
1086
1099
|
} catch (err) {
|
|
1087
1100
|
console.error("[clay] Revert to mine failed while writing and was undone", err);
|
|
1088
1101
|
} finally {
|
package/src/sync/conflicts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerUnsavedState } from '../lib/unsaved-state.js';
|
|
2
|
+
import { releaseProtection } from './conflict-footprints.js';
|
|
2
3
|
|
|
3
4
|
// What incoming frames won over this tab's unsaved edits, kept for the life of
|
|
4
5
|
// the document until the person decides. Nothing here is read by a save, a
|
|
@@ -63,6 +64,33 @@ export function beginApply({ source, seq = null, etag = null, domain, root }) {
|
|
|
63
64
|
return id;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function insertionProof(raw) {
|
|
68
|
+
if (!raw.every((c) => c.kind === 'structure' && c.detail === 'insert-collision' && c.recovery?.applied === true && !c.recovery.unavailable && c.recovery.structure?.localAction === 'inserted' && c.recovery.structure.fragmentKind === 'element')) return null;
|
|
69
|
+
const subject = raw[0].recovery.subject;
|
|
70
|
+
if (subject?.local?.length !== 1 || subject.merged?.length !== 1 || subject.live?.length !== 1) return null;
|
|
71
|
+
return { local: subject.local[0], merged: subject.merged[0], live: subject.live[0] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function foldInsertions(groups) {
|
|
75
|
+
const proofs = new Map(groups.map((raw) => [raw, insertionProof(raw)]));
|
|
76
|
+
const below = (parent, child) => parent.length < child.length && parent.every((step, i) => step === child[i]);
|
|
77
|
+
const at = (root, path) => path.reduce((node, step) => step === 'content' ? node?.content : node?.childNodes[step], root);
|
|
78
|
+
const folded = new Map();
|
|
79
|
+
for (const raw of groups) {
|
|
80
|
+
const child = proofs.get(raw);
|
|
81
|
+
let owner = raw;
|
|
82
|
+
if (child) {
|
|
83
|
+
for (const candidate of groups) {
|
|
84
|
+
const parent = proofs.get(candidate);
|
|
85
|
+
if (parent && parent.local.length < proofs.get(owner).local.length && below(parent.local, child.local) && below(parent.merged, child.merged) && (parent.live.contains(child.live) || at(parent.live, child.merged.slice(parent.merged.length)) === child.live)) owner = candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!folded.has(owner)) folded.set(owner, [...owner]);
|
|
89
|
+
if (raw !== owner) folded.get(owner).push(...raw);
|
|
90
|
+
}
|
|
91
|
+
return [...folded.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
export function completeApply(applyId, conflicts, { ticket }) {
|
|
67
95
|
const apply = applies.get(applyId);
|
|
68
96
|
if (!apply) return [];
|
|
@@ -74,7 +102,7 @@ export function completeApply(applyId, conflicts, { ticket }) {
|
|
|
74
102
|
if (!groups.has(key)) groups.set(key, []);
|
|
75
103
|
groups.get(key).push(c);
|
|
76
104
|
}
|
|
77
|
-
const ids = [...groups.values()].map((raw) => install(apply, raw));
|
|
105
|
+
const ids = foldInsertions([...groups.values()]).map((raw) => install(apply, raw));
|
|
78
106
|
pending.delete(applyId);
|
|
79
107
|
if (!ids.length) applies.delete(applyId);
|
|
80
108
|
if (ids.length) changed({ added: ids.map((id) => records.get(id)) });
|
|
@@ -110,6 +138,7 @@ export const conflicts = {
|
|
|
110
138
|
for (const id of ids) {
|
|
111
139
|
const rec = records.get(id);
|
|
112
140
|
if (!rec) continue;
|
|
141
|
+
releaseProtection(rec);
|
|
113
142
|
records.delete(id);
|
|
114
143
|
removedIds.push(id);
|
|
115
144
|
const apply = applies.get(rec.applyId);
|