@panphora/clayjs 1.4.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.
- package/dist/clay.standalone.js +6038 -3775
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +6 -4
- package/package.json +1 -1
- package/src/core/autosave.js +2 -0
- package/src/core/save.js +9 -3
- package/src/core/snapshot.js +2 -2
- package/src/lib/autosave-state.js +17 -0
- package/src/lib/dirty-gate.js +5 -0
- package/src/sync/live-sync.js +464 -311
- package/src/sync/section-notice.js +6 -7
- package/src/vendor/control-serialize.vendor.js +4 -1
- package/src/vendor/hyper-morph.vendor.js +4 -9
- package/src/vendor/hypercms.vendor.js +38 -38
- package/src/sync/splice-merge.js +0 -270
package/src/sync/splice-merge.js
DELETED
|
@@ -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
|
-
}
|