@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.
@@ -1,41 +1,278 @@
1
- import { conflicts } from './conflicts.js';
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 replacements = new WeakMap();
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
- export const replacementFootprint = (rec) => replacements.get(rec) || null;
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
- export function trackConflictFootprints(merge) {
8
- const watched = new Map();
9
- for (const rec of conflicts.list()) {
10
- if (!rec.recovery?.text) continue;
11
- const live = rec.recovery.subject.live;
12
- const node = replacements.get(rec) || (live.length === 1 ? live[0] : null);
13
- if (node?.nodeType === 1 && node.ownerDocument === document && node.isConnected) watched.set(rec, node);
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 (!watched.size) return merge();
16
- const observer = new MutationObserver(() => {});
17
- observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
18
- try {
19
- const pending = merge();
20
- const changes = observer.takeRecords();
21
- if (changes.length !== 2) return pending;
22
- const [insert, remove] = changes;
23
- if (insert.type !== 'childList' || remove.type !== 'childList' || insert.target !== remove.target ||
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 { replacementFootprint } from "./conflict-footprints.js";
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) constraints.push({ ticket: apply.ticket, node: footprintOf(rec, 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
- return { ops: kept, covered, blocked, revision, before: census() };
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 {
@@ -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);