@panphora/clayjs 0.4.3 → 0.6.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 +57 -3
- package/src/core/snapshot.js +59 -4
- package/src/lib/dirty-gate.js +169 -0
- package/src/lib/root-attrs.js +5 -1
- package/src/loader-logic.js +2 -1
- package/src/loader.js +2 -0
- package/src/plugins/wire.js +598 -0
- package/src/sync/live-sync.js +489 -39
- 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)
|
|
@@ -116,6 +117,37 @@ let lastSavedContents = '';
|
|
|
116
117
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
117
118
|
let pendingSave = false;
|
|
118
119
|
|
|
120
|
+
// ============================================
|
|
121
|
+
// AUTOSAVE SUSPENSION
|
|
122
|
+
// ============================================
|
|
123
|
+
//
|
|
124
|
+
// clay.wire holds this for the length of an agent request. The save is
|
|
125
|
+
// last-writer-wins with a backup, so an autosave landing while a local process is
|
|
126
|
+
// writing the same file posts the pre-agent document: the server backs the agent's
|
|
127
|
+
// bytes up and writes the browser's, the watcher's revalidation then fails, and
|
|
128
|
+
// the agent's work exists only in Backups while the page reports success.
|
|
129
|
+
//
|
|
130
|
+
// It suspends AUTOsave only. An explicit savePage — Cmd+S, a [trigger-save]
|
|
131
|
+
// button, or the wire's own pre-send flush — is a deliberate act and still runs.
|
|
132
|
+
//
|
|
133
|
+
// Reference counted, because two overlapping requests each hold it. A save
|
|
134
|
+
// skipped while suspended is replayed on release, or the user's edits would sit
|
|
135
|
+
// unsaved until something else happened to mutate the page.
|
|
136
|
+
let autosaveSuspended = 0;
|
|
137
|
+
let autosaveMissed = false;
|
|
138
|
+
|
|
139
|
+
export function suspendAutosave() {
|
|
140
|
+
autosaveSuspended++;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resumeAutosave() {
|
|
144
|
+
if (autosaveSuspended === 0) return;
|
|
145
|
+
autosaveSuspended--;
|
|
146
|
+
if (autosaveSuspended > 0 || !autosaveMissed) return;
|
|
147
|
+
autosaveMissed = false;
|
|
148
|
+
savePageThrottled();
|
|
149
|
+
}
|
|
150
|
+
|
|
119
151
|
function skipped_(msg) {
|
|
120
152
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
121
153
|
}
|
|
@@ -129,10 +161,13 @@ function skipped_(msg) {
|
|
|
129
161
|
* where the write may or may not have landed, so it is not treated as success
|
|
130
162
|
* either.
|
|
131
163
|
*/
|
|
132
|
-
function applySaveResult(result, forComparison, label) {
|
|
164
|
+
function applySaveResult(result, forComparison, label, gateToken) {
|
|
133
165
|
if (result.ok) {
|
|
134
166
|
lastSavedContents = forComparison;
|
|
135
167
|
unsavedChanges = false;
|
|
168
|
+
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
169
|
+
// changed while this save was on the wire.
|
|
170
|
+
gateClearIfUnchanged(gateToken);
|
|
136
171
|
// The server's severity rides through untouched: a save can land AND carry a
|
|
137
172
|
// warning, and the UI module is what decides how to render that.
|
|
138
173
|
setSaveState('saved', result.msg || 'Saved', result.msgType);
|
|
@@ -201,6 +236,7 @@ export function savePage(callback = () => {}) {
|
|
|
201
236
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
202
237
|
// forComparison additionally strips every autosave-off region
|
|
203
238
|
let forSave, forComparison, snapshotHtml;
|
|
239
|
+
const gateToken = gateCaptureToken();
|
|
204
240
|
try {
|
|
205
241
|
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
206
242
|
} catch (err) {
|
|
@@ -219,6 +255,7 @@ export function savePage(callback = () => {}) {
|
|
|
219
255
|
|
|
220
256
|
// Skip if content hasn't changed
|
|
221
257
|
if (!unsavedChanges) {
|
|
258
|
+
gateClearIfUnchanged(gateToken);
|
|
222
259
|
const skipped = skipped_('No changes to save');
|
|
223
260
|
callback(skipped);
|
|
224
261
|
return resolve(skipped);
|
|
@@ -229,7 +266,7 @@ export function savePage(callback = () => {}) {
|
|
|
229
266
|
|
|
230
267
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
231
268
|
saveHtml(forSave, (result) => {
|
|
232
|
-
applySaveResult(result, forComparison, 'updated after save');
|
|
269
|
+
applySaveResult(result, forComparison, 'updated after save', gateToken);
|
|
233
270
|
if (typeof callback === 'function') {
|
|
234
271
|
callback(result);
|
|
235
272
|
}
|
|
@@ -266,6 +303,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
let forSave, forComparison, snapshotHtml;
|
|
306
|
+
const gateToken = gateCaptureToken();
|
|
269
307
|
try {
|
|
270
308
|
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
271
309
|
} catch (err) {
|
|
@@ -281,7 +319,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
281
319
|
setSavingState();
|
|
282
320
|
|
|
283
321
|
saveHtml(forSave, (result) => {
|
|
284
|
-
applySaveResult(result, forComparison, 'updated after force save');
|
|
322
|
+
applySaveResult(result, forComparison, 'updated after force save', gateToken);
|
|
285
323
|
if (typeof callback === 'function') {
|
|
286
324
|
callback(result);
|
|
287
325
|
}
|
|
@@ -383,9 +421,13 @@ function initBaselineCapture() {
|
|
|
383
421
|
// (if a save happened, lastSavedContents would differ from immediateContents)
|
|
384
422
|
if (!userEdited && lastSavedContents === immediateContents) {
|
|
385
423
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
424
|
+
const gateToken = gateCaptureToken();
|
|
386
425
|
const contents = captureForComparison();
|
|
387
426
|
lastSavedContents = contents;
|
|
388
427
|
baselineContents = contents;
|
|
428
|
+
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
429
|
+
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
|
430
|
+
gateClearIfUnchanged(gateToken);
|
|
389
431
|
logBaseline('settled capture', `${contents.length} chars`);
|
|
390
432
|
} else {
|
|
391
433
|
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
@@ -440,12 +482,23 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
440
482
|
return Promise.resolve(skipped);
|
|
441
483
|
}
|
|
442
484
|
|
|
485
|
+
// Every autosave path lands here — the mutation-driven one, the [persist] input
|
|
486
|
+
// timer, and live-sync's convergence save after a protected apply — which is
|
|
487
|
+
// why the suspension lives at this one entry rather than at each caller.
|
|
488
|
+
if (autosaveSuspended > 0) {
|
|
489
|
+
autosaveMissed = true;
|
|
490
|
+
const skipped = skipped_('Autosave suspended');
|
|
491
|
+
callback(skipped);
|
|
492
|
+
return Promise.resolve(skipped);
|
|
493
|
+
}
|
|
494
|
+
|
|
443
495
|
// For autosave: while the page is still settling, content must differ from BOTH
|
|
444
496
|
// the load-time baseline and the last save, so module setup churn cannot trigger
|
|
445
497
|
// a save. Once settled, the baseline veto is disarmed and only the last save
|
|
446
498
|
// matters — otherwise undoing back to the page's original state can never be
|
|
447
499
|
// persisted, because it matches the baseline forever.
|
|
448
500
|
// Compare directly - stored versions are already stripped
|
|
501
|
+
const gateToken = gateCaptureToken();
|
|
449
502
|
const currentForCompare = captureForComparison();
|
|
450
503
|
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
451
504
|
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
@@ -454,6 +507,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
454
507
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
455
508
|
|
|
456
509
|
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
510
|
+
if (!differsFromLastSave) gateClearIfUnchanged(gateToken);
|
|
457
511
|
const skipped = skipped_('No changes to save');
|
|
458
512
|
callback(skipped);
|
|
459
513
|
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,169 @@
|
|
|
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
|
+
import { STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
|
|
31
|
+
|
|
32
|
+
let changes = 0;
|
|
33
|
+
let clearedAt = 0;
|
|
34
|
+
let paused = false;
|
|
35
|
+
let started = false;
|
|
36
|
+
|
|
37
|
+
const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
|
|
38
|
+
|
|
39
|
+
// Regions the comparison never sees. The hub feed already skips them, through
|
|
40
|
+
// `require: 'autosave'`, and the input feed has to skip them for the same
|
|
41
|
+
// reason: their content is stripped from the comparison clone, so an edit inside
|
|
42
|
+
// one can never produce a dirty root, and counting it marks the page dirty with
|
|
43
|
+
// nothing for the oracle to find — permanently, since only a save clears the
|
|
44
|
+
// counter and churn in these regions triggers none.
|
|
45
|
+
//
|
|
46
|
+
// This is not a relaxation of "never under-report". A control here is absent
|
|
47
|
+
// from the clone by definition, so there is nothing about it to under-report.
|
|
48
|
+
// It matters because a mounted tool (redpen's answer field, any panel that
|
|
49
|
+
// marks itself no-save) is a real <textarea> in the document: one keystroke in
|
|
50
|
+
// it used to freeze the live-sync save baseline for the rest of the session,
|
|
51
|
+
// after which every incoming disk change was diffed against a stale base, and
|
|
52
|
+
// the previous change was spliced back over the newer one and written to disk.
|
|
53
|
+
const GATE_IGNORE = `${STRIP_FROM_COMPARISON}, ${SNAPSHOT_REMOVE_SELECTOR}`;
|
|
54
|
+
const probeCache = new WeakMap();
|
|
55
|
+
|
|
56
|
+
function onUserInput(event) {
|
|
57
|
+
// Deliberately NOT gated on `paused`: a morph never dispatches input or
|
|
58
|
+
// change events, so anything arriving here is the user — including typing
|
|
59
|
+
// during a morph's async resource wait, which must keep the page dirty.
|
|
60
|
+
const el = event.target;
|
|
61
|
+
if (!el || el.nodeType !== 1) return;
|
|
62
|
+
if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
|
|
63
|
+
if (el.closest(GATE_IGNORE)) return;
|
|
64
|
+
changes++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function startDirtyGate() {
|
|
68
|
+
if (started) return;
|
|
69
|
+
started = true;
|
|
70
|
+
Mutation.onAnyChange(
|
|
71
|
+
{ omitChangeDetails: true, require: 'autosave' },
|
|
72
|
+
() => {
|
|
73
|
+
if (!paused) changes++;
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
document.addEventListener('input', onUserInput, true);
|
|
77
|
+
document.addEventListener('change', onUserInput, true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The morph-apply window: nothing that happens inside it is a user edit. */
|
|
81
|
+
export function pauseGate() { paused = true; }
|
|
82
|
+
export function resumeGate() { paused = false; }
|
|
83
|
+
|
|
84
|
+
function controlSignature(el) {
|
|
85
|
+
if (el instanceof HTMLSelectElement) {
|
|
86
|
+
return Array.from(el.selectedOptions, (o) => o.value).join('\u0000');
|
|
87
|
+
}
|
|
88
|
+
if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
89
|
+
return el.checked ? '1' : '0';
|
|
90
|
+
}
|
|
91
|
+
return el.value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// True when the control's live state matches its serialized (attribute-level)
|
|
95
|
+
// state — i.e. a snapshot taken right now would carry nothing new for it.
|
|
96
|
+
// persist's own listeners keep attributes current for USER input; only
|
|
97
|
+
// programmatic writes diverge here.
|
|
98
|
+
function serializedStateMatches(el) {
|
|
99
|
+
if (el instanceof HTMLSelectElement) {
|
|
100
|
+
return Array.from(el.options).every((o) => o.selected === o.defaultSelected);
|
|
101
|
+
}
|
|
102
|
+
if (el instanceof HTMLInputElement) {
|
|
103
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
104
|
+
return el.checked === el.defaultChecked;
|
|
105
|
+
}
|
|
106
|
+
return el.value === el.defaultValue;
|
|
107
|
+
}
|
|
108
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
109
|
+
if (el.hasAttribute('data-value')) {
|
|
110
|
+
return el.value === el.getAttribute('data-value');
|
|
111
|
+
}
|
|
112
|
+
return el.value === el.defaultValue;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function persistProbeDirty() {
|
|
118
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
119
|
+
const sig = controlSignature(el);
|
|
120
|
+
if (probeCache.get(el) === sig) continue;
|
|
121
|
+
if (serializedStateMatches(el)) {
|
|
122
|
+
probeCache.set(el, sig);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Call after the oracle verified the whole page clean against its baseline. */
|
|
131
|
+
export function probeMarkClean() {
|
|
132
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
133
|
+
probeCache.set(el, controlSignature(el));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function pageMaybeDirty() {
|
|
138
|
+
return changes > clearedAt || persistProbeDirty();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Record the gate state at capture time. The save flow takes a token when it
|
|
143
|
+
* clones the DOM and hands it back on success (or on a verified "no changes"),
|
|
144
|
+
* so a clear can never swallow edits made while the save was in flight.
|
|
145
|
+
*/
|
|
146
|
+
export function gateCaptureToken() {
|
|
147
|
+
const probe = [];
|
|
148
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
149
|
+
probe.push([el, controlSignature(el)]);
|
|
150
|
+
}
|
|
151
|
+
return { gen: changes, probe };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function gateClearIfUnchanged(token) {
|
|
155
|
+
if (!token) return;
|
|
156
|
+
if (changes === token.gen) {
|
|
157
|
+
clearedAt = token.gen;
|
|
158
|
+
}
|
|
159
|
+
// Probe entries clear per element, and only if the element still holds the
|
|
160
|
+
// exact value the capture carried — a programmatic write between capture
|
|
161
|
+
// and completion stays dirty.
|
|
162
|
+
for (const [el, sig] of token.probe) {
|
|
163
|
+
if (controlSignature(el) === sig) probeCache.set(el, sig);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (typeof document !== 'undefined' && isEditMode) {
|
|
168
|
+
startDirtyGate();
|
|
169
|
+
}
|
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.
|
package/src/loader-logic.js
CHANGED
|
@@ -21,10 +21,11 @@ export const PLUGIN_PATHS = {
|
|
|
21
21
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
22
22
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
23
23
|
cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
|
|
24
|
+
wire: { path: "plugins/wire.js", editOnly: false, default: false },
|
|
24
25
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "demo"];
|
|
28
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "wire", "demo"];
|
|
28
29
|
|
|
29
30
|
function parseCsv(params, key, enabled, apply) {
|
|
30
31
|
const raw = params.get(key);
|
package/src/loader.js
CHANGED
|
@@ -102,6 +102,8 @@ function attachPluginMember(path, mod) {
|
|
|
102
102
|
clay.morph = mod.morph;
|
|
103
103
|
} else if (path === "vendor/hypercms.vendor.js") {
|
|
104
104
|
clay.cms = mod.cms || mod.default;
|
|
105
|
+
} else if (path === "plugins/wire.js") {
|
|
106
|
+
clay.wire = mod.wire || mod.default;
|
|
105
107
|
} else if (path === "plugins/demo.js") {
|
|
106
108
|
clay.demo = mod.demo;
|
|
107
109
|
} else if (path === "vendor/richclay.vendor.js") {
|