@panphora/clayjs 0.4.2 → 0.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/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -3
- package/src/core/admin-inputs.js +5 -3
- package/src/core/admin-onclick.js +4 -3
- package/src/core/admin-resources.js +8 -4
- package/src/core/is-edit-mode.js +3 -2
- package/src/core/save.js +16 -3
- package/src/core/snapshot.js +59 -4
- package/src/lib/dirty-gate.js +152 -0
- package/src/lib/root-attrs.js +5 -1
- package/src/sync/live-sync.js +440 -38
- package/src/sync/splice-merge.js +259 -0
- package/src/vendor/hyper-morph.vendor.js +4 -2
- package/src/vendor/hypercms.vendor.js +14 -14
package/package.json
CHANGED
|
@@ -22,9 +22,10 @@ export function enableContentEditableForAdminOnPageLoad () {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Runtime toggle functions
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Runtime toggle functions. `root` lets scoped live sync activate a parsed
|
|
26
|
+
// incoming document the same way boot activates the live one.
|
|
27
|
+
export function enableContentEditable(root = document) {
|
|
28
|
+
root.querySelectorAll(SELECTOR).forEach(el => {
|
|
28
29
|
let val = el.getAttribute("inert-contenteditable");
|
|
29
30
|
if (!["false", "plaintext-only"].includes(val)) val = "true";
|
|
30
31
|
el.setAttribute("contenteditable", val);
|
package/src/core/admin-inputs.js
CHANGED
|
@@ -24,11 +24,13 @@ export function enableAdminInputsOnPageLoad() {
|
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// `root` lets scoped live sync activate a parsed incoming document the same
|
|
28
|
+
// way boot activates the live one.
|
|
29
|
+
export function enableAdminInputs(root = document) {
|
|
30
|
+
root.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
|
|
29
31
|
input.removeAttribute('disabled');
|
|
30
32
|
});
|
|
31
|
-
|
|
33
|
+
root.querySelectorAll(SELECTOR_READONLY).forEach(input => {
|
|
32
34
|
input.removeAttribute('readonly');
|
|
33
35
|
});
|
|
34
36
|
}
|
|
@@ -22,9 +22,10 @@ export function enableOnClickForAdminOnPageLoad () {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Runtime toggle functions
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Runtime toggle functions. `root` lets scoped live sync activate a parsed
|
|
26
|
+
// incoming document the same way boot activates the live one.
|
|
27
|
+
export function enableOnClick(root = document) {
|
|
28
|
+
root.querySelectorAll(SELECTOR).forEach(el => {
|
|
28
29
|
const val = el.getAttribute("inert-onclick");
|
|
29
30
|
if (val) {
|
|
30
31
|
el.setAttribute("onclick", val);
|
|
@@ -37,11 +37,15 @@ export function enableAdminResourcesOnPageLoad () {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Runtime toggle functions
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Runtime toggle functions. `root` lets scoped live sync activate a parsed
|
|
41
|
+
// incoming document; the clone-swap re-execution trick only applies to the
|
|
42
|
+
// live document — in a detached parse nothing executes, and the morph's own
|
|
43
|
+
// script handling decides execution when the content lands.
|
|
44
|
+
export function enableAdminResources(root = document) {
|
|
45
|
+
const live = (root.ownerDocument || root) === document;
|
|
46
|
+
root.querySelectorAll(SELECTOR_INERT).forEach(resource => {
|
|
43
47
|
makeActive(resource);
|
|
44
|
-
resource.replaceWith(resource.cloneNode(true));
|
|
48
|
+
if (live) resource.replaceWith(resource.cloneNode(true));
|
|
45
49
|
});
|
|
46
50
|
}
|
|
47
51
|
|
package/src/core/is-edit-mode.js
CHANGED
|
@@ -4,8 +4,9 @@ import { hasSaveToken } from "./host-attrs.js";
|
|
|
4
4
|
|
|
5
5
|
// Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
|
|
6
6
|
// opt-in window.clayEditMode global (with the legacy window.__hyperclayEditMode
|
|
7
|
-
// still honored as a fallback
|
|
8
|
-
//
|
|
7
|
+
// still honored as a fallback for older standalone embedders — htmlclay itself
|
|
8
|
+
// uses the injected htmlclaytoken plus the admin cookie, not this global), then
|
|
9
|
+
// a save token the host put on the root, then the isAdminOfCurrentResource cookie. The
|
|
9
10
|
// global is for standalone uses (demos, htmlclay, any self-saving file) that are
|
|
10
11
|
// always editable and have no owner cookie; setting it before clayjs loads turns
|
|
11
12
|
// on the edit-only modules.
|
package/src/core/save.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isSaveInProgress
|
|
21
21
|
} from "./save-core.js";
|
|
22
22
|
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
|
+
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
23
24
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
24
25
|
|
|
25
26
|
// Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
|
|
@@ -129,10 +130,13 @@ function skipped_(msg) {
|
|
|
129
130
|
* where the write may or may not have landed, so it is not treated as success
|
|
130
131
|
* either.
|
|
131
132
|
*/
|
|
132
|
-
function applySaveResult(result, forComparison, label) {
|
|
133
|
+
function applySaveResult(result, forComparison, label, gateToken) {
|
|
133
134
|
if (result.ok) {
|
|
134
135
|
lastSavedContents = forComparison;
|
|
135
136
|
unsavedChanges = false;
|
|
137
|
+
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
138
|
+
// changed while this save was on the wire.
|
|
139
|
+
gateClearIfUnchanged(gateToken);
|
|
136
140
|
// The server's severity rides through untouched: a save can land AND carry a
|
|
137
141
|
// warning, and the UI module is what decides how to render that.
|
|
138
142
|
setSaveState('saved', result.msg || 'Saved', result.msgType);
|
|
@@ -201,6 +205,7 @@ export function savePage(callback = () => {}) {
|
|
|
201
205
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
202
206
|
// forComparison additionally strips every autosave-off region
|
|
203
207
|
let forSave, forComparison, snapshotHtml;
|
|
208
|
+
const gateToken = gateCaptureToken();
|
|
204
209
|
try {
|
|
205
210
|
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
206
211
|
} catch (err) {
|
|
@@ -219,6 +224,7 @@ export function savePage(callback = () => {}) {
|
|
|
219
224
|
|
|
220
225
|
// Skip if content hasn't changed
|
|
221
226
|
if (!unsavedChanges) {
|
|
227
|
+
gateClearIfUnchanged(gateToken);
|
|
222
228
|
const skipped = skipped_('No changes to save');
|
|
223
229
|
callback(skipped);
|
|
224
230
|
return resolve(skipped);
|
|
@@ -229,7 +235,7 @@ export function savePage(callback = () => {}) {
|
|
|
229
235
|
|
|
230
236
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
231
237
|
saveHtml(forSave, (result) => {
|
|
232
|
-
applySaveResult(result, forComparison, 'updated after save');
|
|
238
|
+
applySaveResult(result, forComparison, 'updated after save', gateToken);
|
|
233
239
|
if (typeof callback === 'function') {
|
|
234
240
|
callback(result);
|
|
235
241
|
}
|
|
@@ -266,6 +272,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
266
272
|
}
|
|
267
273
|
|
|
268
274
|
let forSave, forComparison, snapshotHtml;
|
|
275
|
+
const gateToken = gateCaptureToken();
|
|
269
276
|
try {
|
|
270
277
|
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
271
278
|
} catch (err) {
|
|
@@ -281,7 +288,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
281
288
|
setSavingState();
|
|
282
289
|
|
|
283
290
|
saveHtml(forSave, (result) => {
|
|
284
|
-
applySaveResult(result, forComparison, 'updated after force save');
|
|
291
|
+
applySaveResult(result, forComparison, 'updated after force save', gateToken);
|
|
285
292
|
if (typeof callback === 'function') {
|
|
286
293
|
callback(result);
|
|
287
294
|
}
|
|
@@ -383,9 +390,13 @@ function initBaselineCapture() {
|
|
|
383
390
|
// (if a save happened, lastSavedContents would differ from immediateContents)
|
|
384
391
|
if (!userEdited && lastSavedContents === immediateContents) {
|
|
385
392
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
393
|
+
const gateToken = gateCaptureToken();
|
|
386
394
|
const contents = captureForComparison();
|
|
387
395
|
lastSavedContents = contents;
|
|
388
396
|
baselineContents = contents;
|
|
397
|
+
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
398
|
+
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
|
399
|
+
gateClearIfUnchanged(gateToken);
|
|
389
400
|
logBaseline('settled capture', `${contents.length} chars`);
|
|
390
401
|
} else {
|
|
391
402
|
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
@@ -446,6 +457,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
446
457
|
// matters — otherwise undoing back to the page's original state can never be
|
|
447
458
|
// persisted, because it matches the baseline forever.
|
|
448
459
|
// Compare directly - stored versions are already stripped
|
|
460
|
+
const gateToken = gateCaptureToken();
|
|
449
461
|
const currentForCompare = captureForComparison();
|
|
450
462
|
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
451
463
|
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
@@ -454,6 +466,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
454
466
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
455
467
|
|
|
456
468
|
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
469
|
+
if (!differsFromLastSave) gateClearIfUnchanged(gateToken);
|
|
457
470
|
const skipped = skipped_('No changes to save');
|
|
458
471
|
callback(skipped);
|
|
459
472
|
return Promise.resolve(skipped);
|
package/src/core/snapshot.js
CHANGED
|
@@ -116,13 +116,16 @@ function clonePreventingOnclone(node) {
|
|
|
116
116
|
finally { window.__preventOnclone = prev; }
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
export function captureSnapshot() {
|
|
119
|
+
export function captureSnapshot({ flushUndo = true } = {}) {
|
|
120
120
|
// Force-close any pending undo idle batch BEFORE cloning the DOM, so the
|
|
121
121
|
// snapshot reflects a clean undo boundary. Without this, a save that fires
|
|
122
122
|
// mid-typing would leave the idle batch open across the save boundary, and
|
|
123
123
|
// Cmd+Z after save would restore to a state earlier than the last save.
|
|
124
124
|
// No-op when undo isn't loaded or no batch is pending.
|
|
125
|
-
|
|
125
|
+
// flushUndo: false is for captures that are not save boundaries (the scoped
|
|
126
|
+
// live-sync dirty oracle runs per incoming frame, and flushing there would
|
|
127
|
+
// shatter the user's undo batches mid-typing).
|
|
128
|
+
if (flushUndo && typeof window !== 'undefined' && window.clay?.undo?.flush) {
|
|
126
129
|
window.clay.undo.flush();
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -181,8 +184,8 @@ function prepareCloneForSave(clone) {
|
|
|
181
184
|
*
|
|
182
185
|
* @returns {string} HTML string with all autosave-off regions stripped
|
|
183
186
|
*/
|
|
184
|
-
export function captureForComparison() {
|
|
185
|
-
const clone = captureSnapshot();
|
|
187
|
+
export function captureForComparison({ flushUndo = true } = {}) {
|
|
188
|
+
const clone = captureSnapshot({ flushUndo });
|
|
186
189
|
|
|
187
190
|
// Run inline [onbeforesave] handlers
|
|
188
191
|
runAuthoredHandlers(clone, 'onbeforesave');
|
|
@@ -260,6 +263,58 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
260
263
|
return { forSave, forComparison, snapshotHtml };
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Capture for a protected live-sync merge: the save-domain and comparison-
|
|
268
|
+
* domain clones of ONE snapshot, plus a WeakMap pairing every comparison
|
|
269
|
+
* element to its save-clone twin.
|
|
270
|
+
*
|
|
271
|
+
* The pairing is recorded immediately after the comparison clone is created,
|
|
272
|
+
* while the two trees are still isomorphic; each side's strips then remove
|
|
273
|
+
* nodes independently without disturbing it. The scoped-sync dirty diff runs
|
|
274
|
+
* on the comparison clone (the same domain as lastSavedContents), and dirty
|
|
275
|
+
* roots map through pairMap to save-domain subtrees (the same domain as the
|
|
276
|
+
* file on disk), which still carry the no-trigger-autosave / freeze / no-watch
|
|
277
|
+
* children the comparison strips.
|
|
278
|
+
*
|
|
279
|
+
* Never emits snapshot-ready (this capture must not feed the send pipeline)
|
|
280
|
+
* and never flushes the undo batch (it runs per incoming frame, not per save).
|
|
281
|
+
*
|
|
282
|
+
* @returns {{ saveClone: HTMLElement, compareClone: HTMLElement, pairMap: WeakMap }}
|
|
283
|
+
*/
|
|
284
|
+
export function captureForMerge() {
|
|
285
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
286
|
+
|
|
287
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
288
|
+
|
|
289
|
+
const compareClone = clonePreventingOnclone(clone);
|
|
290
|
+
|
|
291
|
+
const pairMap = new WeakMap();
|
|
292
|
+
(function pair(compareEl, saveEl) {
|
|
293
|
+
pairMap.set(compareEl, saveEl);
|
|
294
|
+
const compareKids = compareEl.children;
|
|
295
|
+
const saveKids = saveEl.children;
|
|
296
|
+
for (let i = 0; i < compareKids.length; i++) {
|
|
297
|
+
pair(compareKids[i], saveKids[i]);
|
|
298
|
+
}
|
|
299
|
+
})(compareClone, clone);
|
|
300
|
+
|
|
301
|
+
for (const hook of documentTransforms) {
|
|
302
|
+
hook(clone);
|
|
303
|
+
}
|
|
304
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
305
|
+
el.remove();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
309
|
+
el.remove();
|
|
310
|
+
}
|
|
311
|
+
for (const hook of documentTransforms) {
|
|
312
|
+
hook(compareClone);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { saveClone: clone, compareClone, pairMap };
|
|
316
|
+
}
|
|
317
|
+
|
|
263
318
|
/**
|
|
264
319
|
* PHASE 1-4: Full pipeline for saving to server.
|
|
265
320
|
*
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dirty-gate.js — "might this page hold unsaved edits?" as a cheap counter.
|
|
3
|
+
*
|
|
4
|
+
* Scoped live sync must decide, on every incoming frame, whether to pay for a
|
|
5
|
+
* full capture-and-diff (the oracle) or apply the frame directly. This gate is
|
|
6
|
+
* the cheap side of that decision. It may over-report (a false "dirty" just
|
|
7
|
+
* runs the oracle, which then finds nothing), but it must never under-report:
|
|
8
|
+
* a false "clean" full-morphs over a real unsaved edit.
|
|
9
|
+
*
|
|
10
|
+
* Two feeds, because neither alone sees everything:
|
|
11
|
+
* - the mutation hub (require: 'autosave'), which sees DOM changes but not
|
|
12
|
+
* value-property writes on form controls (typing fires no MutationRecord
|
|
13
|
+
* for .value), and
|
|
14
|
+
* - capture-phase input/change listeners on ALL form controls, not just
|
|
15
|
+
* [persist] ones, plus contenteditable hosts.
|
|
16
|
+
*
|
|
17
|
+
* The one hole left is a PROGRAMMATIC value write on a [persist] control: no
|
|
18
|
+
* event, no MutationRecord. persistProbeDirty() closes it by comparing each
|
|
19
|
+
* [persist] control's live state against its serialized default state, with a
|
|
20
|
+
* per-element cache so a value the oracle or a save has already verified stops
|
|
21
|
+
* costing a probe miss on every frame.
|
|
22
|
+
*
|
|
23
|
+
* Clearing is generation-checked: a save records the counter at capture time
|
|
24
|
+
* and clears only if nothing changed while the request was on the wire, so
|
|
25
|
+
* keystrokes during an in-flight save keep the gate dirty.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import Mutation from './mutation.js';
|
|
29
|
+
import { isEditMode } from '../core/is-edit-mode.js';
|
|
30
|
+
|
|
31
|
+
let changes = 0;
|
|
32
|
+
let clearedAt = 0;
|
|
33
|
+
let paused = false;
|
|
34
|
+
let started = false;
|
|
35
|
+
|
|
36
|
+
const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
|
|
37
|
+
const probeCache = new WeakMap();
|
|
38
|
+
|
|
39
|
+
function onUserInput(event) {
|
|
40
|
+
// Deliberately NOT gated on `paused`: a morph never dispatches input or
|
|
41
|
+
// change events, so anything arriving here is the user — including typing
|
|
42
|
+
// during a morph's async resource wait, which must keep the page dirty.
|
|
43
|
+
const el = event.target;
|
|
44
|
+
if (!el || el.nodeType !== 1) return;
|
|
45
|
+
if (el.matches('input, textarea, select') || el.isContentEditable) {
|
|
46
|
+
changes++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function startDirtyGate() {
|
|
51
|
+
if (started) return;
|
|
52
|
+
started = true;
|
|
53
|
+
Mutation.onAnyChange(
|
|
54
|
+
{ omitChangeDetails: true, require: 'autosave' },
|
|
55
|
+
() => {
|
|
56
|
+
if (!paused) changes++;
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
document.addEventListener('input', onUserInput, true);
|
|
60
|
+
document.addEventListener('change', onUserInput, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The morph-apply window: nothing that happens inside it is a user edit. */
|
|
64
|
+
export function pauseGate() { paused = true; }
|
|
65
|
+
export function resumeGate() { paused = false; }
|
|
66
|
+
|
|
67
|
+
function controlSignature(el) {
|
|
68
|
+
if (el instanceof HTMLSelectElement) {
|
|
69
|
+
return Array.from(el.selectedOptions, (o) => o.value).join('\u0000');
|
|
70
|
+
}
|
|
71
|
+
if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
72
|
+
return el.checked ? '1' : '0';
|
|
73
|
+
}
|
|
74
|
+
return el.value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// True when the control's live state matches its serialized (attribute-level)
|
|
78
|
+
// state — i.e. a snapshot taken right now would carry nothing new for it.
|
|
79
|
+
// persist's own listeners keep attributes current for USER input; only
|
|
80
|
+
// programmatic writes diverge here.
|
|
81
|
+
function serializedStateMatches(el) {
|
|
82
|
+
if (el instanceof HTMLSelectElement) {
|
|
83
|
+
return Array.from(el.options).every((o) => o.selected === o.defaultSelected);
|
|
84
|
+
}
|
|
85
|
+
if (el instanceof HTMLInputElement) {
|
|
86
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
87
|
+
return el.checked === el.defaultChecked;
|
|
88
|
+
}
|
|
89
|
+
return el.value === el.defaultValue;
|
|
90
|
+
}
|
|
91
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
92
|
+
if (el.hasAttribute('data-value')) {
|
|
93
|
+
return el.value === el.getAttribute('data-value');
|
|
94
|
+
}
|
|
95
|
+
return el.value === el.defaultValue;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function persistProbeDirty() {
|
|
101
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
102
|
+
const sig = controlSignature(el);
|
|
103
|
+
if (probeCache.get(el) === sig) continue;
|
|
104
|
+
if (serializedStateMatches(el)) {
|
|
105
|
+
probeCache.set(el, sig);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Call after the oracle verified the whole page clean against its baseline. */
|
|
114
|
+
export function probeMarkClean() {
|
|
115
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
116
|
+
probeCache.set(el, controlSignature(el));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function pageMaybeDirty() {
|
|
121
|
+
return changes > clearedAt || persistProbeDirty();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record the gate state at capture time. The save flow takes a token when it
|
|
126
|
+
* clones the DOM and hands it back on success (or on a verified "no changes"),
|
|
127
|
+
* so a clear can never swallow edits made while the save was in flight.
|
|
128
|
+
*/
|
|
129
|
+
export function gateCaptureToken() {
|
|
130
|
+
const probe = [];
|
|
131
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
132
|
+
probe.push([el, controlSignature(el)]);
|
|
133
|
+
}
|
|
134
|
+
return { gen: changes, probe };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function gateClearIfUnchanged(token) {
|
|
138
|
+
if (!token) return;
|
|
139
|
+
if (changes === token.gen) {
|
|
140
|
+
clearedAt = token.gen;
|
|
141
|
+
}
|
|
142
|
+
// Probe entries clear per element, and only if the element still holds the
|
|
143
|
+
// exact value the capture carried — a programmatic write between capture
|
|
144
|
+
// and completion stays dirty.
|
|
145
|
+
for (const [el, sig] of token.probe) {
|
|
146
|
+
if (controlSignature(el) === sig) probeCache.set(el, sig);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof document !== 'undefined' && isEditMode) {
|
|
151
|
+
startDirtyGate();
|
|
152
|
+
}
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
// per-tab and makes the host strip it before writing, so it never reaches disk.
|
|
13
13
|
// But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
14
14
|
// browsers, which is the hole this module closes.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// htmlclayid is host-injected too (htmlclay's durable file identity, stamped on
|
|
17
|
+
// every serve, absent from disk bytes). It rides here so a morph of raw disk
|
|
18
|
+
// content cannot strip this tab's copy, and a peer's copy is never applied.
|
|
19
|
+
export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken", "htmlclayid"];
|
|
16
20
|
|
|
17
21
|
// Which save envelope this host's lane takes: a fact about the response, not
|
|
18
22
|
// about the document.
|