@panphora/clayjs 1.3.0 → 1.5.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,270 +0,0 @@
1
- /**
2
- * splice-merge.js — scoped live sync's clayjs adapter.
3
- *
4
- * The shared core (findChangedRoots + spliceProtected, vendored from
5
- * hyper-morph) is pure tree logic. This module owns everything clayjs-specific
6
- * about feeding it: which capture pairs with which baseline, per lane, so the
7
- * diff always compares trees from ONE serialization domain.
8
- *
9
- * Disk frames (htmlclay external changes; save domain on the wire):
10
- * diff loss-domain clone vs parse(lastSavedDirty) [same domain]
11
- * splice save-clone subtrees into the incoming disk doc [same domain]
12
- * The two clones come from ONE snapshot via captureForMerge(), whose
13
- * pairMap bridges them. Save-domain subtrees still carry the freeze /
14
- * no-watch children the loss-domain clone strips.
15
- *
16
- * The loss domain, not the autosave domain: the question here is "would
17
- * this frame destroy work?", which is the close warning's question, so it
18
- * keeps no-trigger-autosave content. Disposable churn is declared out of
19
- * it with no-dirty rather than inferred from bytes or gesture timing.
20
- *
21
- * Peer frames (live-lane relays; snapshot domain on the wire):
22
- * diff snapshot clone vs parse(lastHtml) [same domain]
23
- * splice snapshot subtrees into the incoming peer doc [same domain]
24
- * Snapshot clones carry persist-finalized control values; live elements
25
- * do not, which is why the splice never imports clones of live elements.
26
- *
27
- * Also owns edit-mode activation of incoming disk documents: disk holds the
28
- * inert attribute forms (inert-contenteditable, inert-onclick, disabled
29
- * viewmode inputs, type="inert/…" admin resources) and the live DOM holds the
30
- * activated forms, so the patched document is run through the same enable
31
- * passes boot runs before it is morphed in.
32
- */
33
-
34
- import { findChangedRoots, spliceProtected } from '../vendor/hyper-morph.vendor.js';
35
- import { captureSnapshot, captureForMerge, originalSnapshotNode } from '../core/snapshot.js';
36
- // save.js is edit-only in the loader waves but safe to reach from here: its
37
- // module body guards every init on isEditMode, and the disk lane that needs
38
- // this state only ever runs in edit-mode tabs.
39
- import { getLastSavedDirty } from '../core/save.js';
40
- import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
41
- import {
42
- isSnapshotRemoved,
43
- STRIP_FROM_SAVE,
44
- FREEZE_SELECTOR,
45
- SNAPSHOT_REMOVE_SELECTOR,
46
- NO_DIRTY_SELECTOR,
47
- } from '../lib/region-policy.js';
48
- import { probeMarkClean, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
49
- import { isEditMode } from '../core/is-edit-mode.js';
50
- import { enableContentEditable } from '../core/admin-contenteditable.js';
51
- import { enableOnClick } from '../core/admin-onclick.js';
52
- import { enableAdminInputs } from '../core/admin-inputs.js';
53
- import { enableAdminResources } from '../core/admin-resources.js';
54
-
55
- // Per-tab chrome the peer-lane diff must skip on BOTH sides: these regions
56
- // legitimately differ between tabs, and the morph never touches them anyway
57
- // (hyper-morph's sync-ignore set, composed from the same policy selectors).
58
- const PEER_SKIP_SELECTOR = [
59
- STRIP_FROM_SAVE,
60
- FREEZE_SELECTOR,
61
- SNAPSHOT_REMOVE_SELECTOR,
62
- NO_DIRTY_SELECTOR,
63
- ].join(', ');
64
-
65
- function peerSkip(el) {
66
- return el.matches(PEER_SKIP_SELECTOR);
67
- }
68
-
69
- // Root attributes owned by the host or this tab: never a local edit.
70
- function ignoreTabLocalRootAttrs(el, name) {
71
- return !el.parentElement && TAB_LOCAL_ROOT_ATTRS.has(name);
72
- }
73
-
74
- // One-deep parse cache per lane: sender bursts reuse the same baseline string
75
- // across consecutive frames, and parsing a full document per frame is the
76
- // dirty path's single biggest avoidable cost.
77
- function makeParseCache() {
78
- let lastString = null;
79
- let lastDoc = null;
80
- return (html) => {
81
- if (html !== lastString) {
82
- lastDoc = new DOMParser().parseFromString(html, 'text/html');
83
- lastString = html;
84
- }
85
- return lastDoc;
86
- };
87
- }
88
-
89
- const parsePeerBase = makeParseCache();
90
- const parseDiskBase = makeParseCache();
91
-
92
- /**
93
- * Walk two identically-shaped element trees in lockstep, invoking cb(a, b)
94
- * per pair. Used to carry synthetic ids across cloning and importing.
95
- */
96
- function walkPairs(a, b, cb) {
97
- cb(a, b);
98
- const aKids = a.children;
99
- const bKids = b.children;
100
- for (let i = 0; i < aKids.length; i++) {
101
- walkPairs(aKids[i], bKids[i], cb);
102
- }
103
- }
104
-
105
- /**
106
- * Copy synthetic ids from live elements onto their snapshot-clone twins.
107
- * Mirrors _buildIdentityMap's walk: the live side filters [no-snapshot]
108
- * chrome to stay aligned with what captureSnapshot stripped, and a subtree
109
- * whose child counts diverge (extension noise beside the clone's strip) is
110
- * skipped — those elements fall back to data-id / id matching.
111
- */
112
- function fillCloneIds(liveEl, cloneEl, liveWeakMap, idOf, root = true) {
113
- const live = originalSnapshotNode(cloneEl) || (root ? liveEl : null);
114
- const id = liveWeakMap.get(live);
115
- if (id) idOf.set(cloneEl, id);
116
- const cloneKids = cloneEl.children;
117
- for (let i = 0; i < cloneKids.length; i++) {
118
- const childLive = originalSnapshotNode(cloneKids[i]);
119
- fillCloneIds(childLive, cloneKids[i], liveWeakMap, idOf, false);
120
- }
121
- }
122
-
123
- /**
124
- * Fill ids onto a parsed tree from a path-keyed identityMap (the wire format
125
- * peers send). Same dot-path scheme as live-sync's _walkParsedTree.
126
- */
127
- function fillParsedIds(root, identityMap, idOf) {
128
- if (!root || !identityMap) return;
129
- const visit = (el, path) => {
130
- const id = identityMap[path];
131
- if (id) idOf.set(el, id);
132
- const kids = el.children;
133
- for (let i = 0; i < kids.length; i++) {
134
- visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
135
- }
136
- };
137
- visit(root, '');
138
- }
139
-
140
- /**
141
- * Protect local dirty regions in an incoming PEER document (snapshot domain).
142
- * Mutates newDoc in place on success.
143
- *
144
- * @returns {{ ok: boolean, entries: Array, held?: object }}
145
- * ok:false means the frame must be held back (applied not at all).
146
- */
147
- export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMap, liveWeakMap }) {
148
- // No baseline yet: nothing to diff against, and a dirty page must not be
149
- // full-morphed over. The next frame (or our own first send) sets one.
150
- if (typeof baseHtml !== 'string' || !baseHtml) {
151
- return { ok: false, entries: [], held: null };
152
- }
153
-
154
- const gateToken = gateCaptureToken();
155
- const localClone = captureSnapshot({ flushUndo: false });
156
- const baseDoc = parsePeerBase(baseHtml);
157
- if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
158
-
159
- // One synthetic-identity space across all three trees. Local clone ids come
160
- // from the live elements; base ids from the last frame's identityMap; the
161
- // incoming doc's ids are already in parsedWeakMap.
162
- const idOf = new WeakMap();
163
- fillCloneIds(document.documentElement, localClone, liveWeakMap, idOf);
164
- fillParsedIds(baseDoc.documentElement, baseIdentityMap, idOf);
165
-
166
- const tiers = [
167
- (el) => idOf.get(el) || (parsedWeakMap && parsedWeakMap.get(el)) || null,
168
- (el) => el.getAttribute('data-id'),
169
- (el) => el.getAttribute('id'),
170
- ];
171
-
172
- const { entries } = findChangedRoots(localClone, baseDoc.documentElement, {
173
- skip: peerSkip,
174
- ignoreAttr: ignoreTabLocalRootAttrs,
175
- tiers,
176
- });
177
-
178
- if (!entries.length) {
179
- // The oracle just proved the page clean against its baseline. probeMarkClean
180
- // only caches form signatures; without the generation-checked counter clear
181
- // an edit that was typed and then undone leaves the gate armed forever, and
182
- // every later frame pays the full capture-and-diff for nothing.
183
- probeMarkClean();
184
- gateClearIfUnchanged(gateToken);
185
- return { ok: true, entries };
186
- }
187
-
188
- const res = spliceProtected(newDoc, entries, { tiers });
189
- if (!res.ok) {
190
- return { ok: false, entries, held: res.held };
191
- }
192
-
193
- // The imported clones are fresh nodes; hand them their originals' synthetic
194
- // ids so the morph pairs each protected region with the exact live elements
195
- // it came from (zero churn, caret intact).
196
- if (parsedWeakMap) {
197
- for (const { entry, imported } of res.placed) {
198
- walkPairs(entry.el, imported, (original, copy) => {
199
- const id = idOf.get(original);
200
- if (id) parsedWeakMap.set(copy, id);
201
- });
202
- }
203
- }
204
-
205
- return { ok: true, entries };
206
- }
207
-
208
- /**
209
- * Protect local dirty regions in an incoming DISK document (save domain).
210
- * Mutates newDoc in place on success.
211
- *
212
- * @returns {{ ok: boolean, entries: Array, held?: object }}
213
- */
214
- export function protectDiskDoc({ newDoc }) {
215
- const base = getLastSavedDirty();
216
- if (!base) return { ok: false, entries: [], held: null };
217
-
218
- const gateToken = gateCaptureToken();
219
- const { saveClone, compareClone, pairMap } = captureForMerge();
220
- const baseDoc = parseDiskBase(base);
221
- if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
222
-
223
- const { entries } = findChangedRoots(compareClone, baseDoc.documentElement, {
224
- ignoreAttr: ignoreTabLocalRootAttrs,
225
- });
226
-
227
- if (!entries.length) {
228
- probeMarkClean();
229
- gateClearIfUnchanged(gateToken);
230
- return { ok: true, entries };
231
- }
232
-
233
- // Dirty roots were found on the comparison clone; the subtrees that go into
234
- // the (save-domain) disk doc are their save-clone twins. An entry pairMap
235
- // can't bridge means an authored transform grew the comparison clone after
236
- // the pairing — unmappable, so the frame holds rather than guesses.
237
- const spliceEntries = [];
238
- for (const entry of entries) {
239
- if (entry.type === 'deletion') {
240
- spliceEntries.push(entry);
241
- continue;
242
- }
243
- const saveEl = pairMap.get(entry.el);
244
- if (!saveEl) return { ok: false, entries, held: entry };
245
- spliceEntries.push({ ...entry, el: saveEl });
246
- }
247
-
248
- const res = spliceProtected(newDoc, spliceEntries, {});
249
- if (!res.ok) {
250
- return { ok: false, entries, held: res.held };
251
- }
252
-
253
- return { ok: true, entries };
254
- }
255
-
256
- /**
257
- * Convert an incoming disk document from its saved (inert) form to the live
258
- * edit-mode form, exactly as boot does on page load, so the morph compares
259
- * like with like: without this, every disk frame would swap the live page's
260
- * contenteditable/onclick/admin state back to inert — caret killed mid-edit.
261
- * Root attributes (editmode, savestatus, tokens) need no reversal here; the
262
- * morph's beforeAttributeUpdated veto keeps the live root's own.
263
- */
264
- export function activateIncomingDoc(rootEl) {
265
- if (!isEditMode || !rootEl) return;
266
- enableContentEditable(rootEl);
267
- enableOnClick(rootEl);
268
- enableAdminInputs(rootEl);
269
- enableAdminResources(rootEl);
270
- }