@panphora/clayjs 1.7.0 → 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/dist/clay.standalone.js +6278 -5396
- package/package.json +2 -1
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/save.js +13 -5
- package/src/lib/root-attrs.js +14 -0
- package/src/plugins/ai-edit.js +307 -89
- package/src/sync/conflict-footprints.js +272 -35
- package/src/sync/conflict-revert.js +18 -8
- package/src/sync/conflicts.js +2 -0
- package/src/sync/live-sync.js +328 -37
- package/src/sync/stream.js +17 -4
- package/src/vendor/hyper-morph.vendor.js +4 -4
|
@@ -1,41 +1,278 @@
|
|
|
1
|
-
|
|
1
|
+
const states = new WeakMap();
|
|
2
|
+
const released = new WeakSet();
|
|
3
|
+
const pending = new Set();
|
|
4
|
+
let generation = 0;
|
|
5
|
+
let sequence = 0;
|
|
2
6
|
|
|
3
|
-
const
|
|
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
|
+
}
|
|
4
26
|
|
|
5
|
-
|
|
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
|
+
}
|
|
6
38
|
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
for (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
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;
|
|
14
58
|
}
|
|
15
|
-
if (
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
insert.addedNodes.length !== 1 || insert.removedNodes.length ||
|
|
25
|
-
remove.removedNodes.length !== 1 || remove.addedNodes.length) return pending;
|
|
26
|
-
const old = remove.removedNodes[0];
|
|
27
|
-
const fresh = insert.addedNodes[0];
|
|
28
|
-
if (old.nodeType !== 1 || fresh.nodeType !== 1 || old.isConnected || !fresh.isConnected ||
|
|
29
|
-
old.namespaceURI !== fresh.namespaceURI || old.tagName === fresh.tagName ||
|
|
30
|
-
insert.nextSibling !== old || remove.previousSibling !== fresh ||
|
|
31
|
-
fresh.parentNode !== insert.target || fresh.previousSibling !== insert.previousSibling ||
|
|
32
|
-
fresh.nextSibling !== remove.nextSibling) return pending;
|
|
33
|
-
if (old.childNodes.length !== 1 || fresh.childNodes.length !== 1 || old.firstChild.nodeType !== 3 ||
|
|
34
|
-
!old.firstChild.isEqualNode(fresh.firstChild) || old.attributes.length !== fresh.attributes.length ||
|
|
35
|
-
[...old.attributes].some((a) => fresh.getAttributeNS(a.namespaceURI, a.localName) !== a.value)) return pending;
|
|
36
|
-
for (const [rec, node] of watched) if (node === old) replacements.set(rec, fresh);
|
|
37
|
-
return pending;
|
|
38
|
-
} finally {
|
|
39
|
-
observer.disconnect();
|
|
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;
|
|
40
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
|
+
};
|
|
41
278
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
2
2
|
import { conflicts } from "./conflicts.js";
|
|
3
|
-
import {
|
|
3
|
+
import { protectionOf, protectionGeneration, captureFootprintWitnesses, footprintWitnessesValid } from "./conflict-footprints.js";
|
|
4
4
|
import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
|
|
5
5
|
import { savePage } from "../core/save.js";
|
|
6
6
|
import { gateMarkDirty } from "../lib/dirty-gate.js";
|
|
@@ -795,6 +795,11 @@ function covers(k, op) {
|
|
|
795
795
|
// the text block around its recorded clash, else around its recorded scope, else its
|
|
796
796
|
// live subject, else its live placement parent, else the nearest ancestor of its
|
|
797
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
|
+
|
|
798
803
|
function footprintOf(rec, apply) {
|
|
799
804
|
const r = rec.recovery;
|
|
800
805
|
const subject = r.subject;
|
|
@@ -803,8 +808,6 @@ function footprintOf(rec, apply) {
|
|
|
803
808
|
}
|
|
804
809
|
const live = subject ? liveOf(subject, apply, subject.nodeType) : null;
|
|
805
810
|
if (live) return live;
|
|
806
|
-
const replacement = replacementFootprint(rec);
|
|
807
|
-
if (replacement && onPage(replacement)) return replacement;
|
|
808
811
|
const parent = r.structure && r.structure.localPlacement ? liveOf(r.structure.localPlacement.parent, apply, 1) : null;
|
|
809
812
|
if (parent) return parent;
|
|
810
813
|
const path = subject && subject.local.length ? subject.local[0] : [];
|
|
@@ -838,7 +841,7 @@ function planOne(rec, apply) {
|
|
|
838
841
|
return null;
|
|
839
842
|
}
|
|
840
843
|
|
|
841
|
-
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(",")}`;
|
|
842
845
|
|
|
843
846
|
// ---------------------------------------------------------------------------
|
|
844
847
|
// The whole page: what must hold after the writes
|
|
@@ -1007,7 +1010,9 @@ export function prepareRevert(ids) {
|
|
|
1007
1010
|
const plan = (rec) => {
|
|
1008
1011
|
const apply = conflicts.recoveryOf(rec.id);
|
|
1009
1012
|
const op = apply ? planOne(rec, apply) : null;
|
|
1010
|
-
if (!op && apply)
|
|
1013
|
+
if (!op && apply) {
|
|
1014
|
+
for (const node of footprintsOf(rec, apply)) constraints.push({ ticket: apply.ticket, node });
|
|
1015
|
+
}
|
|
1011
1016
|
return op;
|
|
1012
1017
|
};
|
|
1013
1018
|
const ops = [];
|
|
@@ -1053,7 +1058,10 @@ export function prepareRevert(ids) {
|
|
|
1053
1058
|
block(newer);
|
|
1054
1059
|
}
|
|
1055
1060
|
}
|
|
1056
|
-
|
|
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() };
|
|
1057
1065
|
}
|
|
1058
1066
|
|
|
1059
1067
|
/**
|
|
@@ -1064,8 +1072,9 @@ export function prepareRevert(ids) {
|
|
|
1064
1072
|
* saved.
|
|
1065
1073
|
*/
|
|
1066
1074
|
export function applyRevert(plan) {
|
|
1067
|
-
if (plan.revision !== revisionNow()) {
|
|
1075
|
+
if (plan.revision !== revisionNow() || !footprintWitnessesValid(plan.protectedWitnesses || [])) {
|
|
1068
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);
|
|
1069
1078
|
return [];
|
|
1070
1079
|
}
|
|
1071
1080
|
const page = remember();
|
|
@@ -1085,7 +1094,8 @@ export function applyRevert(plan) {
|
|
|
1085
1094
|
for (const op of plan.ops) if (op.kind === "attr") applyAttr(op);
|
|
1086
1095
|
applyStructure(plan.ops.filter((op) => op.kind !== "text" && op.kind !== "attr"), parked);
|
|
1087
1096
|
records = observer.takeRecords();
|
|
1088
|
-
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 || []);
|
|
1089
1099
|
} catch (err) {
|
|
1090
1100
|
console.error("[clay] Revert to mine failed while writing and was undone", err);
|
|
1091
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
|
|
@@ -137,6 +138,7 @@ export const conflicts = {
|
|
|
137
138
|
for (const id of ids) {
|
|
138
139
|
const rec = records.get(id);
|
|
139
140
|
if (!rec) continue;
|
|
141
|
+
releaseProtection(rec);
|
|
140
142
|
records.delete(id);
|
|
141
143
|
removedIds.push(id);
|
|
142
144
|
const apply = applies.get(rec.applyId);
|