@panphora/clayjs 0.2.0 → 0.4.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/README.md +99 -7
- package/clay-internals.js +19 -0
- package/clay.js +18 -2
- package/package.json +19 -3
- 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 +8 -1
- package/src/core/save-core.js +199 -290
- package/src/core/save.js +124 -84
- package/src/core/snapshot.js +99 -43
- 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 +64 -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/sync/live-sync.js +121 -89
- package/src/ui/dialogs.js +10 -1
- package/src/ui/index.js +2 -11
- package/src/ui/toast.js +8 -2
- package/src/utils/debounce.js +24 -7
- 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
|
|
|
@@ -38,8 +38,9 @@ let savingTimeout = null;
|
|
|
38
38
|
*
|
|
39
39
|
* @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
|
|
40
40
|
* @param {string} msg - Optional message (e.g., error details)
|
|
41
|
+
* @param {string} msgType - Optional severity from the server (e.g., 'warning')
|
|
41
42
|
*/
|
|
42
|
-
function setSaveState(state, msg = '') {
|
|
43
|
+
function setSaveState(state, msg = '', msgType = '') {
|
|
43
44
|
if (savingTimeout) {
|
|
44
45
|
clearTimeout(savingTimeout);
|
|
45
46
|
savingTimeout = null;
|
|
@@ -48,7 +49,7 @@ function setSaveState(state, msg = '') {
|
|
|
48
49
|
document.documentElement.setAttribute('savestatus', state);
|
|
49
50
|
|
|
50
51
|
const event = new CustomEvent(`clay:save-${state}`, {
|
|
51
|
-
detail: { msg, timestamp: Date.now() }
|
|
52
|
+
detail: { msg, msgType, timestamp: Date.now() }
|
|
52
53
|
});
|
|
53
54
|
document.dispatchEvent(event);
|
|
54
55
|
}
|
|
@@ -91,27 +92,68 @@ window.addEventListener('online', () => {
|
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
// ============================================
|
|
94
|
-
//
|
|
95
|
+
// THE BASELINE IS THE BYTES WE SENT
|
|
95
96
|
// ============================================
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
109
110
|
|
|
110
111
|
// Re-export from core for backward compatibility
|
|
111
|
-
export {
|
|
112
|
+
export { addDocumentTransform, getPageContents };
|
|
112
113
|
|
|
113
114
|
let unsavedChanges = false;
|
|
114
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
|
+
}
|
|
115
157
|
|
|
116
158
|
// State accessors for autosave module
|
|
117
159
|
export function getUnsavedChanges() { return unsavedChanges; }
|
|
@@ -132,14 +174,18 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
|
132
174
|
export function savePage(callback = () => {}) {
|
|
133
175
|
return new Promise((resolve) => {
|
|
134
176
|
if (!isEditMode && !window.clay?.testMode) {
|
|
135
|
-
const skipped =
|
|
177
|
+
const skipped = skipped_('Not in edit mode');
|
|
136
178
|
callback(skipped);
|
|
137
179
|
return resolve(skipped);
|
|
138
180
|
}
|
|
139
181
|
|
|
140
|
-
//
|
|
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.
|
|
141
186
|
if (isSaveInProgress()) {
|
|
142
|
-
|
|
187
|
+
pendingSave = true;
|
|
188
|
+
const skipped = skipped_('Save already in progress');
|
|
143
189
|
callback(skipped);
|
|
144
190
|
return resolve(skipped);
|
|
145
191
|
}
|
|
@@ -154,13 +200,13 @@ export function savePage(callback = () => {}) {
|
|
|
154
200
|
// Single capture: clone once, get both versions
|
|
155
201
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
156
202
|
// forComparison additionally strips every autosave-off region
|
|
157
|
-
let forSave, forComparison;
|
|
203
|
+
let forSave, forComparison, snapshotHtml;
|
|
158
204
|
try {
|
|
159
|
-
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
205
|
+
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
160
206
|
} catch (err) {
|
|
161
207
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
162
208
|
setSaveState('error', err.message);
|
|
163
|
-
const result = { msg: err.message, msgType: 'error' };
|
|
209
|
+
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
164
210
|
if (typeof callback === 'function') {
|
|
165
211
|
callback(result);
|
|
166
212
|
}
|
|
@@ -173,7 +219,7 @@ export function savePage(callback = () => {}) {
|
|
|
173
219
|
|
|
174
220
|
// Skip if content hasn't changed
|
|
175
221
|
if (!unsavedChanges) {
|
|
176
|
-
const skipped =
|
|
222
|
+
const skipped = skipped_('No changes to save');
|
|
177
223
|
callback(skipped);
|
|
178
224
|
return resolve(skipped);
|
|
179
225
|
}
|
|
@@ -182,32 +228,14 @@ export function savePage(callback = () => {}) {
|
|
|
182
228
|
setSavingState();
|
|
183
229
|
|
|
184
230
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
185
|
-
saveHtml(forSave, (
|
|
186
|
-
|
|
187
|
-
// SUCCESS - store stripped version for future comparisons
|
|
188
|
-
lastSavedContents = forComparison;
|
|
189
|
-
unsavedChanges = false;
|
|
190
|
-
setSaveState('saved', data?.msg || 'Saved');
|
|
191
|
-
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
192
|
-
} else {
|
|
193
|
-
// FAILED - determine if it's offline or server error
|
|
194
|
-
if (!navigator.onLine) {
|
|
195
|
-
setSaveState('offline', err.message);
|
|
196
|
-
} else {
|
|
197
|
-
setSaveState('error', err.message);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Call user callback if provided (preserve server's msgType)
|
|
202
|
-
const result = {
|
|
203
|
-
msg: err?.message || data?.msg,
|
|
204
|
-
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
205
|
-
};
|
|
231
|
+
saveHtml(forSave, (result) => {
|
|
232
|
+
applySaveResult(result, forComparison, 'updated after save');
|
|
206
233
|
if (typeof callback === 'function') {
|
|
207
234
|
callback(result);
|
|
208
235
|
}
|
|
209
236
|
resolve(result);
|
|
210
|
-
|
|
237
|
+
drainPendingSave();
|
|
238
|
+
}, { snapshotHtml });
|
|
211
239
|
});
|
|
212
240
|
}
|
|
213
241
|
|
|
@@ -220,13 +248,14 @@ export function savePage(callback = () => {}) {
|
|
|
220
248
|
export function savePageForce(callback = () => {}) {
|
|
221
249
|
return new Promise((resolve) => {
|
|
222
250
|
if (!isEditMode && !window.clay?.testMode) {
|
|
223
|
-
const skipped =
|
|
251
|
+
const skipped = skipped_('Not in edit mode');
|
|
224
252
|
callback(skipped);
|
|
225
253
|
return resolve(skipped);
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
if (isSaveInProgress()) {
|
|
229
|
-
|
|
257
|
+
pendingSave = true;
|
|
258
|
+
const skipped = skipped_('Save already in progress');
|
|
230
259
|
callback(skipped);
|
|
231
260
|
return resolve(skipped);
|
|
232
261
|
}
|
|
@@ -236,13 +265,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
236
265
|
setOfflineStateQuiet();
|
|
237
266
|
}
|
|
238
267
|
|
|
239
|
-
let forSave, forComparison;
|
|
268
|
+
let forSave, forComparison, snapshotHtml;
|
|
240
269
|
try {
|
|
241
|
-
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
270
|
+
({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
|
|
242
271
|
} catch (err) {
|
|
243
272
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
244
273
|
setSaveState('error', err.message);
|
|
245
|
-
const result = { msg: err.message, msgType: 'error' };
|
|
274
|
+
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
246
275
|
if (typeof callback === 'function') {
|
|
247
276
|
callback(result);
|
|
248
277
|
}
|
|
@@ -251,29 +280,14 @@ export function savePageForce(callback = () => {}) {
|
|
|
251
280
|
|
|
252
281
|
setSavingState();
|
|
253
282
|
|
|
254
|
-
saveHtml(forSave, (
|
|
255
|
-
|
|
256
|
-
lastSavedContents = forComparison;
|
|
257
|
-
unsavedChanges = false;
|
|
258
|
-
setSaveState('saved', data?.msg || 'Saved');
|
|
259
|
-
logBaseline('updated after force save', `${lastSavedContents.length} chars`);
|
|
260
|
-
} else {
|
|
261
|
-
if (!navigator.onLine) {
|
|
262
|
-
setSaveState('offline', err.message);
|
|
263
|
-
} else {
|
|
264
|
-
setSaveState('error', err.message);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const result = {
|
|
269
|
-
msg: err?.message || data?.msg,
|
|
270
|
-
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
271
|
-
};
|
|
283
|
+
saveHtml(forSave, (result) => {
|
|
284
|
+
applySaveResult(result, forComparison, 'updated after force save');
|
|
272
285
|
if (typeof callback === 'function') {
|
|
273
286
|
callback(result);
|
|
274
287
|
}
|
|
275
288
|
resolve(result);
|
|
276
|
-
|
|
289
|
+
drainPendingSave();
|
|
290
|
+
}, { snapshotHtml });
|
|
277
291
|
});
|
|
278
292
|
}
|
|
279
293
|
|
|
@@ -288,13 +302,16 @@ export function replacePageWith(url) {
|
|
|
288
302
|
return;
|
|
289
303
|
}
|
|
290
304
|
|
|
291
|
-
replacePageWithCore(url, (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// 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) {
|
|
297
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');
|
|
298
315
|
}
|
|
299
316
|
});
|
|
300
317
|
}
|
|
@@ -304,6 +321,9 @@ const throttledSave = throttle(savePage, 1200);
|
|
|
304
321
|
|
|
305
322
|
// Baseline for autosave comparison
|
|
306
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;
|
|
307
327
|
|
|
308
328
|
// ============================================
|
|
309
329
|
// BASELINE CAPTURE (Settled Signal)
|
|
@@ -371,6 +391,14 @@ function initBaselineCapture() {
|
|
|
371
391
|
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
372
392
|
}
|
|
373
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
|
+
|
|
374
402
|
document.documentElement.setAttribute('savestatus', 'saved');
|
|
375
403
|
};
|
|
376
404
|
|
|
@@ -407,29 +435,41 @@ if (document.readyState === 'loading') {
|
|
|
407
435
|
*/
|
|
408
436
|
export function savePageThrottled(callback = () => {}) {
|
|
409
437
|
if (!isEditMode) {
|
|
410
|
-
const skipped =
|
|
438
|
+
const skipped = skipped_('Not in edit mode');
|
|
411
439
|
callback(skipped);
|
|
412
440
|
return Promise.resolve(skipped);
|
|
413
441
|
}
|
|
414
442
|
|
|
415
|
-
// For autosave:
|
|
416
|
-
//
|
|
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.
|
|
417
448
|
// Compare directly - stored versions are already stripped
|
|
418
449
|
const currentForCompare = captureForComparison();
|
|
419
|
-
const differsFromBaseline = currentForCompare !== baselineContents;
|
|
450
|
+
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
420
451
|
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
421
452
|
|
|
422
453
|
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
423
454
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
424
455
|
|
|
425
456
|
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
426
|
-
const skipped =
|
|
457
|
+
const skipped = skipped_('No changes to save');
|
|
427
458
|
callback(skipped);
|
|
428
459
|
return Promise.resolve(skipped);
|
|
429
460
|
}
|
|
430
461
|
|
|
431
462
|
unsavedChanges = true;
|
|
432
|
-
|
|
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
|
+
});
|
|
433
473
|
}
|
|
434
474
|
|
|
435
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,20 +155,20 @@ export function captureSnapshot() {
|
|
|
129
155
|
*/
|
|
130
156
|
function prepareCloneForSave(clone) {
|
|
131
157
|
// Run inline [onbeforesave] handlers
|
|
132
|
-
|
|
133
|
-
|
|
158
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
159
|
+
|
|
160
|
+
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
161
|
+
for (const hook of documentTransforms) {
|
|
162
|
+
hook(clone);
|
|
134
163
|
}
|
|
135
164
|
|
|
136
|
-
//
|
|
165
|
+
// Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
|
|
166
|
+
// prepare hook (freeze restore) can re-inject [no-save] content into the clone,
|
|
167
|
+
// so the strip must run after the hooks or that content leaks to disk.
|
|
137
168
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
138
169
|
el.remove();
|
|
139
170
|
}
|
|
140
171
|
|
|
141
|
-
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
142
|
-
for (const hook of prepareForSaveHooks) {
|
|
143
|
-
hook(clone);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
172
|
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
147
173
|
}
|
|
148
174
|
|
|
@@ -159,9 +185,7 @@ export function captureForComparison() {
|
|
|
159
185
|
const clone = captureSnapshot();
|
|
160
186
|
|
|
161
187
|
// Run inline [onbeforesave] handlers
|
|
162
|
-
|
|
163
|
-
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
164
|
-
}
|
|
188
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
165
189
|
|
|
166
190
|
// Strip before hooks (hooks see the "final" state)
|
|
167
191
|
for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
@@ -169,7 +193,7 @@ export function captureForComparison() {
|
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
// Run registered prepare hooks
|
|
172
|
-
for (const hook of
|
|
196
|
+
for (const hook of documentTransforms) {
|
|
173
197
|
hook(clone);
|
|
174
198
|
}
|
|
175
199
|
|
|
@@ -184,53 +208,56 @@ export function captureForComparison() {
|
|
|
184
208
|
*
|
|
185
209
|
* @param {Object} options
|
|
186
210
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
187
|
-
* @returns {{ forSave: string, forComparison: string }}
|
|
211
|
+
* @returns {{ forSave: string, forComparison: string, snapshotHtml: ?string }}
|
|
188
212
|
*/
|
|
189
213
|
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
190
214
|
const clone = captureSnapshot();
|
|
191
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
|
+
|
|
192
228
|
// Emit for live-sync before any stripping
|
|
193
229
|
if (emitForSync) {
|
|
194
230
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
195
231
|
detail: { documentElement: clone }
|
|
196
232
|
}));
|
|
197
|
-
|
|
198
|
-
// Store snapshot HTML for Hyperclay Local platform sync
|
|
199
|
-
// This allows the save system to send both stripped and full versions
|
|
200
|
-
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
201
|
-
window.location.hostname === '127.0.0.1';
|
|
202
|
-
if (isHyperclayLocal) {
|
|
203
|
-
window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
|
|
204
|
-
}
|
|
205
233
|
}
|
|
206
234
|
|
|
207
235
|
// Run inline [onbeforesave] handlers
|
|
208
|
-
|
|
209
|
-
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
210
|
-
}
|
|
236
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
211
237
|
|
|
212
238
|
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
213
239
|
const compareClone = clonePreventingOnclone(clone);
|
|
214
240
|
|
|
215
|
-
// Save clone: strip [no-save]/[save-remove]
|
|
241
|
+
// Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
|
|
242
|
+
// LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
|
|
243
|
+
for (const hook of documentTransforms) {
|
|
244
|
+
hook(clone);
|
|
245
|
+
}
|
|
216
246
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
217
247
|
el.remove();
|
|
218
248
|
}
|
|
219
|
-
for (const hook of prepareForSaveHooks) {
|
|
220
|
-
hook(clone);
|
|
221
|
-
}
|
|
222
249
|
const forSave = "<!DOCTYPE html>" + clone.outerHTML;
|
|
223
250
|
|
|
224
251
|
// Compare clone: strip every autosave-off region, then run hooks
|
|
225
252
|
for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
226
253
|
el.remove();
|
|
227
254
|
}
|
|
228
|
-
for (const hook of
|
|
255
|
+
for (const hook of documentTransforms) {
|
|
229
256
|
hook(compareClone);
|
|
230
257
|
}
|
|
231
258
|
const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
|
|
232
259
|
|
|
233
|
-
return { forSave, forComparison };
|
|
260
|
+
return { forSave, forComparison, snapshotHtml };
|
|
234
261
|
}
|
|
235
262
|
|
|
236
263
|
/**
|
|
@@ -257,6 +284,35 @@ export function captureForSave({ emitForSync = true } = {}) {
|
|
|
257
284
|
return prepareCloneForSave(clone);
|
|
258
285
|
}
|
|
259
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
|
+
|
|
260
316
|
/**
|
|
261
317
|
* PHASE 1-2 (body only): For live-sync between admin users.
|
|
262
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();
|