@panphora/clayjs 0.2.1 → 0.4.1
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/README.md +103 -7
- package/clay-data.js +1 -1
- package/clay-internals.js +19 -0
- package/clay.js +19 -3
- package/package.json +5 -2
- package/sap.js +5 -5
- package/src/attrs/onaftersave.js +12 -1
- package/src/attrs/refetch-on-save.js +4 -4
- package/src/attrs/save-freeze.js +47 -19
- package/src/core/admin-contenteditable.js +2 -2
- package/src/core/admin-inputs.js +2 -2
- package/src/core/admin-onclick.js +2 -2
- package/src/core/admin-resources.js +23 -14
- package/src/core/autosave.js +11 -1
- package/src/core/edit-mode.js +2 -2
- package/src/core/host-attrs.js +46 -0
- package/src/core/is-edit-mode.js +14 -6
- package/src/core/persist.js +12 -2
- package/src/core/save-core.js +199 -290
- package/src/core/save.js +121 -82
- package/src/core/snapshot.js +89 -36
- package/src/core/unsaved-warning.js +8 -2
- package/src/dom/all.js +4 -29
- package/src/dom/form-data.js +1 -1
- package/src/dom/nearest.js +11 -9
- package/src/internals/index.js +69 -0
- package/src/lib/cache-bust.js +10 -0
- package/src/lib/cookie.js +50 -15
- package/src/lib/extension-noise.js +5 -1
- package/src/lib/mutation.js +65 -18
- package/src/lib/region-policy.js +17 -2
- package/src/lib/root-attrs.js +45 -0
- package/src/lib/throttle.js +25 -13
- package/src/loader.js +18 -24
- package/src/options/options.js +1 -3
- package/src/plugins/demo.js +7 -1
- package/src/plugins/undo.js +3 -3
- package/src/sync/live-sync.js +77 -76
- package/src/ui/dialogs.js +10 -1
- package/src/ui/index.js +0 -10
- package/src/utils/debounce.js +24 -7
- package/src/vendor/control-serialize.vendor.js +2 -1
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +29 -16
- package/src/vendor/richclay.vendor.js +29 -22
package/src/core/save.js
CHANGED
|
@@ -16,14 +16,14 @@ import {
|
|
|
16
16
|
saveHtml,
|
|
17
17
|
getPageContents,
|
|
18
18
|
replacePageWith as replacePageWithCore,
|
|
19
|
-
|
|
19
|
+
addDocumentTransform,
|
|
20
20
|
isSaveInProgress
|
|
21
21
|
} from "./save-core.js";
|
|
22
22
|
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
23
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
24
24
|
|
|
25
25
|
// Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
|
|
26
|
-
|
|
26
|
+
addDocumentTransform(clone => {
|
|
27
27
|
clone.setAttribute('savestatus', 'saved');
|
|
28
28
|
});
|
|
29
29
|
|
|
@@ -92,27 +92,68 @@ window.addEventListener('online', () => {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
// ============================================
|
|
95
|
-
//
|
|
95
|
+
// THE BASELINE IS THE BYTES WE SENT
|
|
96
96
|
// ============================================
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
97
|
+
// There is deliberately no post-save re-read of the live DOM here.
|
|
98
|
+
//
|
|
99
|
+
// One used to exist, because [onaftersave] handlers mutate the page after a save
|
|
100
|
+
// lands (cacheBust rewrites ?v= query params) and without it the page read dirty
|
|
101
|
+
// forever. But a re-read cannot tell that churn apart from something the user typed
|
|
102
|
+
// while the request was in flight, so it recorded those keystrokes as saved without
|
|
103
|
+
// ever sending them: the edit was gone, with no error, no dirty flag, and no
|
|
104
|
+
// close-tab warning.
|
|
105
|
+
//
|
|
106
|
+
// Post-save mutators are made invisible to the comparison instead — they mark what
|
|
107
|
+
// they touch `no-trigger-autosave`, which strips it from every comparison capture
|
|
108
|
+
// (see cache-bust.js and refetch-on-save.js). That leaves the baseline free to stay
|
|
109
|
+
// exactly what savePage sent, which is the only value that is true by construction.
|
|
110
110
|
|
|
111
111
|
// Re-export from core for backward compatibility
|
|
112
|
-
export {
|
|
112
|
+
export { addDocumentTransform, getPageContents };
|
|
113
113
|
|
|
114
114
|
let unsavedChanges = false;
|
|
115
115
|
let lastSavedContents = '';
|
|
116
|
+
// A save was requested while one was on the wire; run one more when it settles.
|
|
117
|
+
let pendingSave = false;
|
|
118
|
+
|
|
119
|
+
function skipped_(msg) {
|
|
120
|
+
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply one save result to the page's state.
|
|
125
|
+
*
|
|
126
|
+
* A 'skipped' result means the bytes never reached the wire, so the baseline must
|
|
127
|
+
* NOT advance: recording content as saved when it was never sent is the same defect
|
|
128
|
+
* as re-reading the live DOM after a save, one layer down. 'unknown' is a timeout,
|
|
129
|
+
* where the write may or may not have landed, so it is not treated as success
|
|
130
|
+
* either.
|
|
131
|
+
*/
|
|
132
|
+
function applySaveResult(result, forComparison, label) {
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
lastSavedContents = forComparison;
|
|
135
|
+
unsavedChanges = false;
|
|
136
|
+
// The server's severity rides through untouched: a save can land AND carry a
|
|
137
|
+
// warning, and the UI module is what decides how to render that.
|
|
138
|
+
setSaveState('saved', result.msg || 'Saved', result.msgType);
|
|
139
|
+
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
140
|
+
} else if (result.msgType !== 'skipped') {
|
|
141
|
+
if (!navigator.onLine) {
|
|
142
|
+
setSaveState('offline', result.msg);
|
|
143
|
+
} else {
|
|
144
|
+
setSaveState('error', result.msg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Run the save that arrived while this one was in flight. savePage does its own
|
|
150
|
+
// dirty check, so if nothing actually changed it resolves 'skipped' and stops:
|
|
151
|
+
// this cannot spin.
|
|
152
|
+
function drainPendingSave() {
|
|
153
|
+
if (!pendingSave) return;
|
|
154
|
+
pendingSave = false;
|
|
155
|
+
savePage();
|
|
156
|
+
}
|
|
116
157
|
|
|
117
158
|
// State accessors for autosave module
|
|
118
159
|
export function getUnsavedChanges() { return unsavedChanges; }
|
|
@@ -133,14 +174,18 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
|
133
174
|
export function savePage(callback = () => {}) {
|
|
134
175
|
return new Promise((resolve) => {
|
|
135
176
|
if (!isEditMode && !window.clay?.testMode) {
|
|
136
|
-
const skipped =
|
|
177
|
+
const skipped = skipped_('Not in edit mode');
|
|
137
178
|
callback(skipped);
|
|
138
179
|
return resolve(skipped);
|
|
139
180
|
}
|
|
140
181
|
|
|
141
|
-
//
|
|
182
|
+
// A save is already on the wire. Remember that a newer state is waiting rather
|
|
183
|
+
// than dropping it: the in-flight request carries the older bytes, and if no
|
|
184
|
+
// further mutation happens to retrigger autosave, the newer ones would never
|
|
185
|
+
// reach disk at all.
|
|
142
186
|
if (isSaveInProgress()) {
|
|
143
|
-
|
|
187
|
+
pendingSave = true;
|
|
188
|
+
const skipped = skipped_('Save already in progress');
|
|
144
189
|
callback(skipped);
|
|
145
190
|
return resolve(skipped);
|
|
146
191
|
}
|
|
@@ -155,13 +200,13 @@ export function savePage(callback = () => {}) {
|
|
|
155
200
|
// Single capture: clone once, get both versions
|
|
156
201
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
157
202
|
// forComparison additionally strips every autosave-off region
|
|
158
|
-
let forSave, forComparison;
|
|
203
|
+
let forSave, forComparison, snapshotHtml;
|
|
159
204
|
try {
|
|
160
|
-
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
205
|
+
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
161
206
|
} catch (err) {
|
|
162
207
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
163
208
|
setSaveState('error', err.message);
|
|
164
|
-
const result = { msg: err.message, msgType: 'error' };
|
|
209
|
+
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
165
210
|
if (typeof callback === 'function') {
|
|
166
211
|
callback(result);
|
|
167
212
|
}
|
|
@@ -174,7 +219,7 @@ export function savePage(callback = () => {}) {
|
|
|
174
219
|
|
|
175
220
|
// Skip if content hasn't changed
|
|
176
221
|
if (!unsavedChanges) {
|
|
177
|
-
const skipped =
|
|
222
|
+
const skipped = skipped_('No changes to save');
|
|
178
223
|
callback(skipped);
|
|
179
224
|
return resolve(skipped);
|
|
180
225
|
}
|
|
@@ -183,32 +228,14 @@ export function savePage(callback = () => {}) {
|
|
|
183
228
|
setSavingState();
|
|
184
229
|
|
|
185
230
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
186
|
-
saveHtml(forSave, (
|
|
187
|
-
|
|
188
|
-
// SUCCESS - store stripped version for future comparisons
|
|
189
|
-
lastSavedContents = forComparison;
|
|
190
|
-
unsavedChanges = false;
|
|
191
|
-
setSaveState('saved', data?.msg || 'Saved', data?.msgType);
|
|
192
|
-
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
193
|
-
} else {
|
|
194
|
-
// FAILED - determine if it's offline or server error
|
|
195
|
-
if (!navigator.onLine) {
|
|
196
|
-
setSaveState('offline', err.message);
|
|
197
|
-
} else {
|
|
198
|
-
setSaveState('error', err.message);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Call user callback if provided (preserve server's msgType)
|
|
203
|
-
const result = {
|
|
204
|
-
msg: err?.message || data?.msg,
|
|
205
|
-
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
206
|
-
};
|
|
231
|
+
saveHtml(forSave, (result) => {
|
|
232
|
+
applySaveResult(result, forComparison, 'updated after save');
|
|
207
233
|
if (typeof callback === 'function') {
|
|
208
234
|
callback(result);
|
|
209
235
|
}
|
|
210
236
|
resolve(result);
|
|
211
|
-
|
|
237
|
+
drainPendingSave();
|
|
238
|
+
}, { snapshotHtml });
|
|
212
239
|
});
|
|
213
240
|
}
|
|
214
241
|
|
|
@@ -221,13 +248,14 @@ export function savePage(callback = () => {}) {
|
|
|
221
248
|
export function savePageForce(callback = () => {}) {
|
|
222
249
|
return new Promise((resolve) => {
|
|
223
250
|
if (!isEditMode && !window.clay?.testMode) {
|
|
224
|
-
const skipped =
|
|
251
|
+
const skipped = skipped_('Not in edit mode');
|
|
225
252
|
callback(skipped);
|
|
226
253
|
return resolve(skipped);
|
|
227
254
|
}
|
|
228
255
|
|
|
229
256
|
if (isSaveInProgress()) {
|
|
230
|
-
|
|
257
|
+
pendingSave = true;
|
|
258
|
+
const skipped = skipped_('Save already in progress');
|
|
231
259
|
callback(skipped);
|
|
232
260
|
return resolve(skipped);
|
|
233
261
|
}
|
|
@@ -237,13 +265,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
237
265
|
setOfflineStateQuiet();
|
|
238
266
|
}
|
|
239
267
|
|
|
240
|
-
let forSave, forComparison;
|
|
268
|
+
let forSave, forComparison, snapshotHtml;
|
|
241
269
|
try {
|
|
242
|
-
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
270
|
+
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
243
271
|
} catch (err) {
|
|
244
272
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
245
273
|
setSaveState('error', err.message);
|
|
246
|
-
const result = { msg: err.message, msgType: 'error' };
|
|
274
|
+
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
247
275
|
if (typeof callback === 'function') {
|
|
248
276
|
callback(result);
|
|
249
277
|
}
|
|
@@ -252,29 +280,14 @@ export function savePageForce(callback = () => {}) {
|
|
|
252
280
|
|
|
253
281
|
setSavingState();
|
|
254
282
|
|
|
255
|
-
saveHtml(forSave, (
|
|
256
|
-
|
|
257
|
-
lastSavedContents = forComparison;
|
|
258
|
-
unsavedChanges = false;
|
|
259
|
-
setSaveState('saved', data?.msg || 'Saved', data?.msgType);
|
|
260
|
-
logBaseline('updated after force save', `${lastSavedContents.length} chars`);
|
|
261
|
-
} else {
|
|
262
|
-
if (!navigator.onLine) {
|
|
263
|
-
setSaveState('offline', err.message);
|
|
264
|
-
} else {
|
|
265
|
-
setSaveState('error', err.message);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const result = {
|
|
270
|
-
msg: err?.message || data?.msg,
|
|
271
|
-
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
272
|
-
};
|
|
283
|
+
saveHtml(forSave, (result) => {
|
|
284
|
+
applySaveResult(result, forComparison, 'updated after force save');
|
|
273
285
|
if (typeof callback === 'function') {
|
|
274
286
|
callback(result);
|
|
275
287
|
}
|
|
276
288
|
resolve(result);
|
|
277
|
-
|
|
289
|
+
drainPendingSave();
|
|
290
|
+
}, { snapshotHtml });
|
|
278
291
|
});
|
|
279
292
|
}
|
|
280
293
|
|
|
@@ -289,13 +302,16 @@ export function replacePageWith(url) {
|
|
|
289
302
|
return;
|
|
290
303
|
}
|
|
291
304
|
|
|
292
|
-
replacePageWithCore(url, (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// Only reload if save was successful
|
|
305
|
+
replacePageWithCore(url, (result) => {
|
|
306
|
+
// Reload ONLY on a save that actually landed. A skipped result means the
|
|
307
|
+
// replacement never happened (busy lane, or not in edit mode), and reloading
|
|
308
|
+
// then presented a no-op as a completed swap.
|
|
309
|
+
if (result.ok) {
|
|
298
310
|
window.location.reload();
|
|
311
|
+
} else if (result.msgType === 'skipped') {
|
|
312
|
+
setSaveState('error', result.msg || 'Template not saved');
|
|
313
|
+
} else {
|
|
314
|
+
setSaveState('error', result.msg || 'Failed to save template');
|
|
299
315
|
}
|
|
300
316
|
});
|
|
301
317
|
}
|
|
@@ -305,6 +321,9 @@ const throttledSave = throttle(savePage, 1200);
|
|
|
305
321
|
|
|
306
322
|
// Baseline for autosave comparison
|
|
307
323
|
let baselineContents = '';
|
|
324
|
+
// The baseline veto only guards the load-time settle window; captureBaseline
|
|
325
|
+
// disarms it. See the comment there.
|
|
326
|
+
let baselineActive = true;
|
|
308
327
|
|
|
309
328
|
// ============================================
|
|
310
329
|
// BASELINE CAPTURE (Settled Signal)
|
|
@@ -372,6 +391,14 @@ function initBaselineCapture() {
|
|
|
372
391
|
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
373
392
|
}
|
|
374
393
|
|
|
394
|
+
// The load-time veto has done its job. It exists so setup churn from modules
|
|
395
|
+
// booting cannot trigger a save, and that window is over once mutations have
|
|
396
|
+
// settled. Leaving it armed for the life of the tab is what made an undo back
|
|
397
|
+
// to the page's original state unsaveable: it differed from lastSavedContents
|
|
398
|
+
// but matched baselineContents, so autosave vetoed it forever and only a manual
|
|
399
|
+
// save could persist the revert.
|
|
400
|
+
baselineActive = false;
|
|
401
|
+
|
|
375
402
|
document.documentElement.setAttribute('savestatus', 'saved');
|
|
376
403
|
};
|
|
377
404
|
|
|
@@ -408,29 +435,41 @@ if (document.readyState === 'loading') {
|
|
|
408
435
|
*/
|
|
409
436
|
export function savePageThrottled(callback = () => {}) {
|
|
410
437
|
if (!isEditMode) {
|
|
411
|
-
const skipped =
|
|
438
|
+
const skipped = skipped_('Not in edit mode');
|
|
412
439
|
callback(skipped);
|
|
413
440
|
return Promise.resolve(skipped);
|
|
414
441
|
}
|
|
415
442
|
|
|
416
|
-
// For autosave:
|
|
417
|
-
//
|
|
443
|
+
// For autosave: while the page is still settling, content must differ from BOTH
|
|
444
|
+
// the load-time baseline and the last save, so module setup churn cannot trigger
|
|
445
|
+
// a save. Once settled, the baseline veto is disarmed and only the last save
|
|
446
|
+
// matters — otherwise undoing back to the page's original state can never be
|
|
447
|
+
// persisted, because it matches the baseline forever.
|
|
418
448
|
// Compare directly - stored versions are already stripped
|
|
419
449
|
const currentForCompare = captureForComparison();
|
|
420
|
-
const differsFromBaseline = currentForCompare !== baselineContents;
|
|
450
|
+
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
421
451
|
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
422
452
|
|
|
423
453
|
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
424
454
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
425
455
|
|
|
426
456
|
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
427
|
-
const skipped =
|
|
457
|
+
const skipped = skipped_('No changes to save');
|
|
428
458
|
callback(skipped);
|
|
429
459
|
return Promise.resolve(skipped);
|
|
430
460
|
}
|
|
431
461
|
|
|
432
462
|
unsavedChanges = true;
|
|
433
|
-
|
|
463
|
+
// The throttled promise can reject now that a throwing save no longer strands
|
|
464
|
+
// its callers (see throttle.js), and both autosave call sites are fire-and-forget,
|
|
465
|
+
// with no reach into this module's error path. Catch it here, the one place that
|
|
466
|
+
// owns that path, so every caller keeps the never-rejects contract savePage
|
|
467
|
+
// documents and a throw surfaces as a failed save instead of an unhandled rejection.
|
|
468
|
+
return throttledSave(callback).catch(err => {
|
|
469
|
+
const result = { ok: false, msg: err?.message || 'Save failed', msgType: 'error', code: null, etag: null };
|
|
470
|
+
setSaveState('error', result.msg);
|
|
471
|
+
return result;
|
|
472
|
+
});
|
|
434
473
|
}
|
|
435
474
|
|
|
436
475
|
/**
|
package/src/core/snapshot.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* ▼ ▼
|
|
20
20
|
* ┌─────────────────────────┐ ┌─────────────────────────┐
|
|
21
21
|
* │ 3a. PREPARE HOOKS │ │ 3b. DONE │
|
|
22
|
-
* │
|
|
22
|
+
* │ addDocumentTransform │ │ (live-sync stops here) │
|
|
23
23
|
* │ [onbeforesave] │ │ │
|
|
24
24
|
* │ [save-remove] │ │ → emits snapshot-ready │
|
|
25
25
|
* │ │ └─────────────────────────┘
|
|
@@ -38,13 +38,38 @@
|
|
|
38
38
|
|
|
39
39
|
import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
40
40
|
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
41
|
+
import { saveTransport, DESKTOP_JSON } from './host-attrs.js';
|
|
42
|
+
import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
41
43
|
|
|
42
44
|
// =============================================================================
|
|
43
45
|
// HOOK REGISTRIES
|
|
44
46
|
// =============================================================================
|
|
45
47
|
|
|
46
48
|
const snapshotHooks = []; // Phase 2: Always run (form sync)
|
|
47
|
-
const
|
|
49
|
+
const documentTransforms = []; // Phase 3a: Save only (strip admin)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run every authored handler of one kind over a clone, and never let one of them
|
|
53
|
+
* take the page down with it.
|
|
54
|
+
*
|
|
55
|
+
* These attributes hold page-author JavaScript, and the capture pipeline runs on
|
|
56
|
+
* the boot path: save.js captures a baseline during module evaluation, inside the
|
|
57
|
+
* dynamic import the loader awaits. An unguarded throw in one page attribute
|
|
58
|
+
* therefore cost the document its entire client rather than costing that one
|
|
59
|
+
* handler its effect.
|
|
60
|
+
*
|
|
61
|
+
* @param {HTMLElement} clone
|
|
62
|
+
* @param {string} attr - e.g. 'onbeforesave'
|
|
63
|
+
*/
|
|
64
|
+
function runAuthoredHandlers(clone, attr) {
|
|
65
|
+
for (const el of clone.querySelectorAll(`[${attr}]`)) {
|
|
66
|
+
try {
|
|
67
|
+
new Function(el.getAttribute(attr)).call(el);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`[${attr}] handler failed:`, err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
48
73
|
|
|
49
74
|
/**
|
|
50
75
|
* Register a hook that runs on EVERY snapshot (save AND sync).
|
|
@@ -57,18 +82,21 @@ export function onSnapshot(callback) {
|
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
/**
|
|
60
|
-
* Register a
|
|
85
|
+
* Register a transform that runs over a detached clone when preparing to save.
|
|
61
86
|
* Use for: stripping admin elements, cleanup.
|
|
62
87
|
*
|
|
88
|
+
* It is a TRANSFORM, not a save lifecycle event: it runs over the clone that
|
|
89
|
+
* gets saved AND over the clone used to answer "has anything changed", and that
|
|
90
|
+
* second one runs on dirty checks that never become a save. So it must be pure
|
|
91
|
+
* and repeatable — no counters, no logging, no network. The two clones have
|
|
92
|
+
* different visibility rules, which is why neither run can be skipped.
|
|
93
|
+
*
|
|
63
94
|
* @param {Function} callback - Receives the cloned document element
|
|
64
95
|
*/
|
|
65
|
-
export function
|
|
66
|
-
|
|
96
|
+
export function addDocumentTransform(callback) {
|
|
97
|
+
documentTransforms.push(callback);
|
|
67
98
|
}
|
|
68
99
|
|
|
69
|
-
// Backwards compat alias
|
|
70
|
-
export const beforeSave = onPrepareForSave;
|
|
71
|
-
|
|
72
100
|
// =============================================================================
|
|
73
101
|
// CAPTURE FUNCTIONS
|
|
74
102
|
// =============================================================================
|
|
@@ -94,8 +122,8 @@ export function captureSnapshot() {
|
|
|
94
122
|
// mid-typing would leave the idle batch open across the save boundary, and
|
|
95
123
|
// Cmd+Z after save would restore to a state earlier than the last save.
|
|
96
124
|
// No-op when undo isn't loaded or no batch is pending.
|
|
97
|
-
if (typeof window !== 'undefined' && window.
|
|
98
|
-
window.
|
|
125
|
+
if (typeof window !== 'undefined' && window.clay?.undo?.flush) {
|
|
126
|
+
window.clay.undo.flush();
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
const clone = clonePreventingOnclone(document.documentElement);
|
|
@@ -104,9 +132,7 @@ export function captureSnapshot() {
|
|
|
104
132
|
hook(clone);
|
|
105
133
|
}
|
|
106
134
|
|
|
107
|
-
|
|
108
|
-
new Function(el.getAttribute('onbeforesnapshot')).call(el);
|
|
109
|
-
}
|
|
135
|
+
runAuthoredHandlers(clone, 'onbeforesnapshot');
|
|
110
136
|
|
|
111
137
|
for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
|
|
112
138
|
el.remove();
|
|
@@ -129,12 +155,10 @@ export function captureSnapshot() {
|
|
|
129
155
|
*/
|
|
130
156
|
function prepareCloneForSave(clone) {
|
|
131
157
|
// Run inline [onbeforesave] handlers
|
|
132
|
-
|
|
133
|
-
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
134
|
-
}
|
|
158
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
135
159
|
|
|
136
160
|
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
137
|
-
for (const hook of
|
|
161
|
+
for (const hook of documentTransforms) {
|
|
138
162
|
hook(clone);
|
|
139
163
|
}
|
|
140
164
|
|
|
@@ -161,9 +185,7 @@ export function captureForComparison() {
|
|
|
161
185
|
const clone = captureSnapshot();
|
|
162
186
|
|
|
163
187
|
// Run inline [onbeforesave] handlers
|
|
164
|
-
|
|
165
|
-
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
166
|
-
}
|
|
188
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
167
189
|
|
|
168
190
|
// Strip before hooks (hooks see the "final" state)
|
|
169
191
|
for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
@@ -171,7 +193,7 @@ export function captureForComparison() {
|
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
// Run registered prepare hooks
|
|
174
|
-
for (const hook of
|
|
196
|
+
for (const hook of documentTransforms) {
|
|
175
197
|
hook(clone);
|
|
176
198
|
}
|
|
177
199
|
|
|
@@ -186,37 +208,39 @@ export function captureForComparison() {
|
|
|
186
208
|
*
|
|
187
209
|
* @param {Object} options
|
|
188
210
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
189
|
-
* @returns {{ forSave: string, forComparison: string }}
|
|
211
|
+
* @returns {{ forSave: string, forComparison: string, snapshotHtml: ?string }}
|
|
190
212
|
*/
|
|
191
213
|
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
192
214
|
const clone = captureSnapshot();
|
|
193
215
|
|
|
216
|
+
// The unstripped snapshot, for a host that asked for the desktop JSON envelope:
|
|
217
|
+
// the save then sends both the stripped document and this. Returned to the caller
|
|
218
|
+
// rather than parked on a window global, so it can only ever be paired with the
|
|
219
|
+
// content captured alongside it. As a global it was cleared on success only, so
|
|
220
|
+
// two captures without an intervening successful save shipped a stale snapshot
|
|
221
|
+
// next to fresh content. Captured only when the document DECLARES the transport;
|
|
222
|
+
// this used to key off `location.hostname`, which set it on every localhost page
|
|
223
|
+
// whether or not its host wanted it.
|
|
224
|
+
const snapshotHtml = saveTransport() === DESKTOP_JSON
|
|
225
|
+
? '<!DOCTYPE html>' + clone.outerHTML
|
|
226
|
+
: null;
|
|
227
|
+
|
|
194
228
|
// Emit for live-sync before any stripping
|
|
195
229
|
if (emitForSync) {
|
|
196
230
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
197
231
|
detail: { documentElement: clone }
|
|
198
232
|
}));
|
|
199
|
-
|
|
200
|
-
// Store snapshot HTML for Hyperclay Local platform sync
|
|
201
|
-
// This allows the save system to send both stripped and full versions
|
|
202
|
-
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
203
|
-
window.location.hostname === '127.0.0.1';
|
|
204
|
-
if (isHyperclayLocal) {
|
|
205
|
-
window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
|
|
206
|
-
}
|
|
207
233
|
}
|
|
208
234
|
|
|
209
235
|
// Run inline [onbeforesave] handlers
|
|
210
|
-
|
|
211
|
-
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
212
|
-
}
|
|
236
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
213
237
|
|
|
214
238
|
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
215
239
|
const compareClone = clonePreventingOnclone(clone);
|
|
216
240
|
|
|
217
241
|
// Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
|
|
218
242
|
// LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
|
|
219
|
-
for (const hook of
|
|
243
|
+
for (const hook of documentTransforms) {
|
|
220
244
|
hook(clone);
|
|
221
245
|
}
|
|
222
246
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
@@ -228,12 +252,12 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
228
252
|
for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
229
253
|
el.remove();
|
|
230
254
|
}
|
|
231
|
-
for (const hook of
|
|
255
|
+
for (const hook of documentTransforms) {
|
|
232
256
|
hook(compareClone);
|
|
233
257
|
}
|
|
234
258
|
const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
|
|
235
259
|
|
|
236
|
-
return { forSave, forComparison };
|
|
260
|
+
return { forSave, forComparison, snapshotHtml };
|
|
237
261
|
}
|
|
238
262
|
|
|
239
263
|
/**
|
|
@@ -260,6 +284,35 @@ export function captureForSave({ emitForSync = true } = {}) {
|
|
|
260
284
|
return prepareCloneForSave(clone);
|
|
261
285
|
}
|
|
262
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The bytes a live-sync broadcast carries: the snapshot, minus the root
|
|
289
|
+
* attributes that belong to this tab alone. A third serialization of the same
|
|
290
|
+
* clone alongside the snapshot and the document, and the only one that crosses
|
|
291
|
+
* into another person's browser.
|
|
292
|
+
*
|
|
293
|
+
* Takes the clone rather than capturing one, because the caller already has the
|
|
294
|
+
* snapshot-ready clone and capturing again would run every hook a second time.
|
|
295
|
+
* (Not to be confused with captureBodyForSync below, which is the older
|
|
296
|
+
* body-innerHTML helper and unrelated to the live-sync lane.)
|
|
297
|
+
*
|
|
298
|
+
* The clone is detached and unobserved, so removing the attributes in place and
|
|
299
|
+
* putting them back is exact, and far cheaper than cloning the tree again. The
|
|
300
|
+
* finally is load-bearing: the save path reads this same clone afterwards.
|
|
301
|
+
*/
|
|
302
|
+
export function serializeForSync(clone) {
|
|
303
|
+
const removed = [];
|
|
304
|
+
for (const name of TAB_LOCAL_ROOT_ATTRS) {
|
|
305
|
+
if (!clone.hasAttribute(name)) continue;
|
|
306
|
+
removed.push([name, clone.getAttribute(name)]);
|
|
307
|
+
clone.removeAttribute(name);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return clone.outerHTML;
|
|
311
|
+
} finally {
|
|
312
|
+
for (const [name, value] of removed) clone.setAttribute(name, value);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
263
316
|
/**
|
|
264
317
|
* PHASE 1-2 (body only): For live-sync between admin users.
|
|
265
318
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Requires the 'save-system' module (automatically included as dependency).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { isEditMode } from "./is-edit-mode.js";
|
|
17
17
|
import { captureForComparison } from "./snapshot.js";
|
|
18
18
|
import { getLastSavedContents } from "./save.js";
|
|
19
19
|
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
@@ -21,8 +21,14 @@ import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
|
21
21
|
// Pre-load diff library if debug mode is on (so it's ready for unload)
|
|
22
22
|
preloadIfEnabled();
|
|
23
23
|
|
|
24
|
+
// Gated on isEditMode, not isOwner. isOwner means the platform's admin cookie
|
|
25
|
+
// specifically, so gating on it switched the warning off for every host that
|
|
26
|
+
// authenticates another way: htmlclay, anything using a root save token, and any
|
|
27
|
+
// sandboxed document, which cannot read cookies at all. Those are exactly the
|
|
28
|
+
// documents where an unsaved edit is easiest to lose. If the page is editable,
|
|
29
|
+
// the person editing it deserves the warning.
|
|
24
30
|
window.addEventListener('beforeunload', (event) => {
|
|
25
|
-
if (!
|
|
31
|
+
if (!isEditMode) return;
|
|
26
32
|
|
|
27
33
|
// Compare directly - both are already stripped
|
|
28
34
|
const currentForCompare = captureForComparison();
|
package/src/dom/all.js
CHANGED
|
@@ -156,36 +156,9 @@ const createMethodHandler = (elements, plugins, methods) => ({
|
|
|
156
156
|
// - When a method returns an Element (like cloneNode), it wraps it in a proxy
|
|
157
157
|
// - When a method returns undefined (like removeAttribute), it chains on the original elements
|
|
158
158
|
// - For other return values, like strings, it returns the results in an array
|
|
159
|
-
// We also handle passing in an all-wrapped proxy object as an argument and loop over all elements in it
|
|
160
|
-
// In the method handler's get function, replace the function call handling with:
|
|
161
159
|
if (typeof value === 'function') {
|
|
162
160
|
return (...args) => {
|
|
163
|
-
|
|
164
|
-
const unwrappedArgs = args.map(arg => {
|
|
165
|
-
if (arg && arg.constructor === Proxy) {
|
|
166
|
-
return Array.from(arg);
|
|
167
|
-
}
|
|
168
|
-
return arg;
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const results = elements.map(el => {
|
|
172
|
-
// Check if any of the unwrapped arguments are arrays (from proxies)
|
|
173
|
-
const hasProxyArgs = unwrappedArgs.some(Array.isArray);
|
|
174
|
-
|
|
175
|
-
if (hasProxyArgs) {
|
|
176
|
-
// Handle proxy arguments case
|
|
177
|
-
const elementResults = unwrappedArgs.map(arg => {
|
|
178
|
-
if (Array.isArray(arg)) {
|
|
179
|
-
return arg.map(proxyEl => el[prop](proxyEl));
|
|
180
|
-
}
|
|
181
|
-
return [el[prop](...args)];
|
|
182
|
-
}).flat();
|
|
183
|
-
return elementResults[elementResults.length - 1];
|
|
184
|
-
} else {
|
|
185
|
-
// Simple case - just call the method once with original arguments
|
|
186
|
-
return el[prop](...args);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
161
|
+
const results = elements.map(el => el[prop](...args));
|
|
189
162
|
|
|
190
163
|
if (results[0] instanceof Element) {
|
|
191
164
|
return createElementProxy(results.filter(Boolean), plugins, methods);
|
|
@@ -390,7 +363,9 @@ const All = new Proxy(function (selectorOrElements, contextSelector) {
|
|
|
390
363
|
if (typeof selectorOrElements === 'string') {
|
|
391
364
|
const elements = contextElements.flatMap(context => {
|
|
392
365
|
// Include context itself if it matches the selector
|
|
393
|
-
|
|
366
|
+
// Document has no matches(): it can never be its own match, only a root
|
|
367
|
+
// to search under.
|
|
368
|
+
const matches = context.matches?.(selectorOrElements) ? [context] : [];
|
|
394
369
|
// Plus all descendants that match
|
|
395
370
|
const descendants = Array.from(context.querySelectorAll(selectorOrElements));
|
|
396
371
|
return [...matches, ...descendants];
|
package/src/dom/form-data.js
CHANGED
|
@@ -5,7 +5,7 @@ function getDataFromForm(container) {
|
|
|
5
5
|
// Helper function to process a single element
|
|
6
6
|
const processElement = (elem) => {
|
|
7
7
|
const name = elem.getAttribute('name');
|
|
8
|
-
const value = elem.value
|
|
8
|
+
const value = elem.value ?? elem.getAttribute('value');
|
|
9
9
|
|
|
10
10
|
// Skip elements without a name or with a disabled attribute
|
|
11
11
|
if (!name || elem.disabled) return;
|