@panphora/clayjs 0.6.0 → 0.7.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/LICENSE +12 -17
- package/README.md +33 -15
- package/THIRD-PARTY-NOTICES.md +40 -0
- package/_headers +12 -0
- package/clay-events.js +9 -0
- package/package.json +3 -3
- package/src/attrs/onaftersave.js +23 -9
- package/src/attrs/refetch-on-save.js +38 -18
- package/src/core/autosave.js +11 -4
- package/src/core/host-attrs.js +1 -16
- package/src/core/host-meta.js +107 -0
- package/src/core/save-core.js +26 -27
- package/src/core/save.js +119 -28
- package/src/core/snapshot.js +121 -26
- package/src/core/unsaved-warning.js +11 -7
- package/src/internals/index.js +3 -26
- package/src/lib/authored-url.js +96 -0
- package/src/lib/cache-bust.js +16 -12
- package/src/lib/dirty-gate.js +28 -10
- package/src/lib/mutation.js +5 -3
- package/src/lib/region-policy.js +98 -18
- package/src/lib/root-attrs.js +0 -5
- package/src/lib/user-gesture.js +33 -1
- package/src/loader-logic.js +29 -1
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +14 -8
- package/src/plugins/upload.js +185 -0
- package/src/sync/live-sync.js +157 -12
- package/src/sync/splice-merge.js +21 -7
- package/src/vendor/hypercms.vendor.js +53 -11
- package/src/vendor/quickcrop.vendor.js +401 -0
- package/NOTICE +0 -19
package/src/core/save.js
CHANGED
|
@@ -19,13 +19,26 @@ import {
|
|
|
19
19
|
addDocumentTransform,
|
|
20
20
|
isSaveInProgress
|
|
21
21
|
} from "./save-core.js";
|
|
22
|
-
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
22
|
+
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
23
|
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
24
|
+
import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
|
|
24
25
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
26
|
+
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
25
27
|
|
|
26
|
-
//
|
|
28
|
+
// Keep this library's own root state out of the saved bytes.
|
|
29
|
+
//
|
|
30
|
+
// This used to write `savestatus="saved"` onto the clone instead of removing it,
|
|
31
|
+
// which stopped a mid-save "saving" from being baked in but still put a library
|
|
32
|
+
// attribute on disk. It is there today in four LOCAL_APPS documents. These three
|
|
33
|
+
// are this tab's UI truth, re-stamped on every load by edit-mode and by the save
|
|
34
|
+
// lane itself, so a stored copy is at best noise and at worst a lie: a file whose
|
|
35
|
+
// disk bytes say `savestatus="saved"` reads as saved before clayjs has booted.
|
|
36
|
+
//
|
|
37
|
+
// Scoped to the ROOT on purpose. `savestatus` on any other element is an authored
|
|
38
|
+
// attribute that `option:savestatus` reads, and stripping those would delete page
|
|
39
|
+
// content. Both clones get this, so the dirty comparison sees no difference.
|
|
27
40
|
addDocumentTransform(clone => {
|
|
28
|
-
clone.
|
|
41
|
+
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
29
42
|
});
|
|
30
43
|
|
|
31
44
|
// ============================================
|
|
@@ -87,9 +100,18 @@ window.addEventListener('offline', () => {
|
|
|
87
100
|
});
|
|
88
101
|
|
|
89
102
|
window.addEventListener('online', () => {
|
|
90
|
-
if (document.documentElement.getAttribute('savestatus')
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if (document.documentElement.getAttribute('savestatus') !== 'offline') return;
|
|
104
|
+
savePage().then((result) => {
|
|
105
|
+
// The page went offline with everything already saved, so there is nothing
|
|
106
|
+
// to send and the offline chip is simply stale. Clear it directly rather
|
|
107
|
+
// than through setSaveState: no save happened, and dispatching
|
|
108
|
+
// clay:save-saved would run every [onaftersave] handler and light every
|
|
109
|
+
// status chip as though one had.
|
|
110
|
+
if (result.msgType === 'skipped' &&
|
|
111
|
+
document.documentElement.getAttribute('savestatus') === 'offline') {
|
|
112
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
93
115
|
});
|
|
94
116
|
|
|
95
117
|
// ============================================
|
|
@@ -104,16 +126,36 @@ window.addEventListener('online', () => {
|
|
|
104
126
|
// ever sending them: the edit was gone, with no error, no dirty flag, and no
|
|
105
127
|
// close-tab warning.
|
|
106
128
|
//
|
|
107
|
-
// Post-save mutators are made invisible to the comparison instead
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
129
|
+
// Post-save mutators are made invisible to the comparison instead. They remember
|
|
130
|
+
// what the URL was authored as and restore it on every snapshot clone, so the live
|
|
131
|
+
// DOM carries the busted URL and the file carries the authored one (authored-url.js,
|
|
132
|
+
// used by cache-bust.js and refetch-on-save.js). That leaves the baseline free to
|
|
133
|
+
// stay exactly what savePage sent, which is the only value true by construction.
|
|
134
|
+
//
|
|
135
|
+
// They used to mark what they touched `no-trigger-autosave` instead. That hid the
|
|
136
|
+
// rewrite from the comparison only once the marker had reached the baseline, so the
|
|
137
|
+
// FIRST save on any page using either helper was followed by a spurious dirty state
|
|
138
|
+
// and a false close warning. It self-healed on the next save, which is why it went
|
|
139
|
+
// unnoticed.
|
|
111
140
|
|
|
112
141
|
// Re-export from core for backward compatibility
|
|
113
142
|
export { addDocumentTransform, getPageContents };
|
|
114
143
|
|
|
115
144
|
let unsavedChanges = false;
|
|
145
|
+
// TWO SAVED BASELINES, one per comparison domain.
|
|
146
|
+
//
|
|
147
|
+
// lastSavedContents — the AUTOSAVE domain (no-trigger-autosave stripped).
|
|
148
|
+
// Answers "should this edit start a save on its own?" Autosave, the load
|
|
149
|
+
// settle guard, the live-sync baseline and the scoped-sync merge oracle all
|
|
150
|
+
// read this one, and none of them changes.
|
|
151
|
+
// lastSavedDirty — the DIRTY domain (no-trigger-autosave kept). Answers "is
|
|
152
|
+
// there anything here the person would lose?" Only an explicit savePage()
|
|
153
|
+
// and the close warning read it.
|
|
154
|
+
//
|
|
155
|
+
// On a page with no batching region the two are byte-identical, so nothing about
|
|
156
|
+
// the split is observable there.
|
|
116
157
|
let lastSavedContents = '';
|
|
158
|
+
let lastSavedDirty = '';
|
|
117
159
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
118
160
|
let pendingSave = false;
|
|
119
161
|
|
|
@@ -161,9 +203,12 @@ function skipped_(msg) {
|
|
|
161
203
|
* where the write may or may not have landed, so it is not treated as success
|
|
162
204
|
* either.
|
|
163
205
|
*/
|
|
164
|
-
function applySaveResult(result, forComparison, label, gateToken) {
|
|
206
|
+
function applySaveResult(result, forComparison, forDirty, label, gateToken) {
|
|
165
207
|
if (result.ok) {
|
|
208
|
+
// Both baselines advance from the SAME pre-request capture, never from the
|
|
209
|
+
// live DOM, so an edit made while the request was on the wire stays unsaved.
|
|
166
210
|
lastSavedContents = forComparison;
|
|
211
|
+
lastSavedDirty = forDirty;
|
|
167
212
|
unsavedChanges = false;
|
|
168
213
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
169
214
|
// changed while this save was on the wire.
|
|
@@ -194,7 +239,28 @@ function drainPendingSave() {
|
|
|
194
239
|
export function getUnsavedChanges() { return unsavedChanges; }
|
|
195
240
|
export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
196
241
|
export function getLastSavedContents() { return lastSavedContents; }
|
|
197
|
-
export function
|
|
242
|
+
export function getLastSavedDirty() { return lastSavedDirty; }
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Install both saved baselines from one post-morph capture.
|
|
246
|
+
*
|
|
247
|
+
* live-sync calls this after applying a frame it verified clean, where the page
|
|
248
|
+
* now IS the file on disk. Setting only the autosave baseline would leave the
|
|
249
|
+
* dirty baseline describing the pre-frame page, and the close warning would then
|
|
250
|
+
* warn about the frame's own content.
|
|
251
|
+
*/
|
|
252
|
+
export function setLastSavedBaselines(forComparison, forDirty) {
|
|
253
|
+
lastSavedContents = forComparison;
|
|
254
|
+
lastSavedDirty = forDirty;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Kept for back-compat: a caller with one string means the page has no batching
|
|
258
|
+
// region, or it does not know about the split. Setting both from it is the safe
|
|
259
|
+
// reading, since the domains coincide exactly when there is no such region.
|
|
260
|
+
export function setLastSavedContents(val) {
|
|
261
|
+
lastSavedContents = val;
|
|
262
|
+
lastSavedDirty = val;
|
|
263
|
+
}
|
|
198
264
|
|
|
199
265
|
/**
|
|
200
266
|
* Save the current page with change detection and state management.
|
|
@@ -209,6 +275,7 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
|
209
275
|
export function savePage(callback = () => {}) {
|
|
210
276
|
return new Promise((resolve) => {
|
|
211
277
|
if (!isEditMode && !window.clay?.testMode) {
|
|
278
|
+
clearExplicitSave();
|
|
212
279
|
const skipped = skipped_('Not in edit mode');
|
|
213
280
|
callback(skipped);
|
|
214
281
|
return resolve(skipped);
|
|
@@ -218,6 +285,8 @@ export function savePage(callback = () => {}) {
|
|
|
218
285
|
// than dropping it: the in-flight request carries the older bytes, and if no
|
|
219
286
|
// further mutation happens to retrigger autosave, the newer ones would never
|
|
220
287
|
// reach disk at all.
|
|
288
|
+
// Not cleared here: pendingSave means this request is deferred, not
|
|
289
|
+
// abandoned, and drainPendingSave is what eventually sends it.
|
|
221
290
|
if (isSaveInProgress()) {
|
|
222
291
|
pendingSave = true;
|
|
223
292
|
const skipped = skipped_('Save already in progress');
|
|
@@ -235,12 +304,13 @@ export function savePage(callback = () => {}) {
|
|
|
235
304
|
// Single capture: clone once, get both versions
|
|
236
305
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
237
306
|
// forComparison additionally strips every autosave-off region
|
|
238
|
-
let forSave, forComparison,
|
|
307
|
+
let forSave, forComparison, forDirty;
|
|
239
308
|
const gateToken = gateCaptureToken();
|
|
240
309
|
try {
|
|
241
|
-
({ forSave, forComparison,
|
|
310
|
+
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
242
311
|
} catch (err) {
|
|
243
312
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
313
|
+
clearExplicitSave();
|
|
244
314
|
setSaveState('error', err.message);
|
|
245
315
|
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
246
316
|
if (typeof callback === 'function') {
|
|
@@ -249,12 +319,18 @@ export function savePage(callback = () => {}) {
|
|
|
249
319
|
return resolve(result);
|
|
250
320
|
}
|
|
251
321
|
|
|
252
|
-
//
|
|
253
|
-
|
|
322
|
+
// An explicit save asks the DIRTY question: write anything the person would
|
|
323
|
+
// otherwise lose, including an edit inside a batching region that was never
|
|
324
|
+
// going to autosave itself.
|
|
325
|
+
unsavedChanges = (forDirty !== lastSavedDirty);
|
|
254
326
|
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
255
327
|
|
|
256
|
-
// Skip if content hasn't changed
|
|
328
|
+
// Skip if content hasn't changed. Clearing the explicit intent here is the
|
|
329
|
+
// whole point of scoping it: pressing Save on a clean page sends nothing, and
|
|
330
|
+
// leaving "a human asked for this" armed would hand it to the next
|
|
331
|
+
// background write and hide exactly the clobber the guard watches for.
|
|
257
332
|
if (!unsavedChanges) {
|
|
333
|
+
clearExplicitSave();
|
|
258
334
|
gateClearIfUnchanged(gateToken);
|
|
259
335
|
const skipped = skipped_('No changes to save');
|
|
260
336
|
callback(skipped);
|
|
@@ -266,13 +342,13 @@ export function savePage(callback = () => {}) {
|
|
|
266
342
|
|
|
267
343
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
268
344
|
saveHtml(forSave, (result) => {
|
|
269
|
-
applySaveResult(result, forComparison, 'updated after save', gateToken);
|
|
345
|
+
applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken);
|
|
270
346
|
if (typeof callback === 'function') {
|
|
271
347
|
callback(result);
|
|
272
348
|
}
|
|
273
349
|
resolve(result);
|
|
274
350
|
drainPendingSave();
|
|
275
|
-
}
|
|
351
|
+
});
|
|
276
352
|
});
|
|
277
353
|
}
|
|
278
354
|
|
|
@@ -302,10 +378,10 @@ export function savePageForce(callback = () => {}) {
|
|
|
302
378
|
setOfflineStateQuiet();
|
|
303
379
|
}
|
|
304
380
|
|
|
305
|
-
let forSave, forComparison,
|
|
381
|
+
let forSave, forComparison, forDirty;
|
|
306
382
|
const gateToken = gateCaptureToken();
|
|
307
383
|
try {
|
|
308
|
-
({ forSave, forComparison,
|
|
384
|
+
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
309
385
|
} catch (err) {
|
|
310
386
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
311
387
|
setSaveState('error', err.message);
|
|
@@ -319,13 +395,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
319
395
|
setSavingState();
|
|
320
396
|
|
|
321
397
|
saveHtml(forSave, (result) => {
|
|
322
|
-
applySaveResult(result, forComparison, 'updated after force save', gateToken);
|
|
398
|
+
applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken);
|
|
323
399
|
if (typeof callback === 'function') {
|
|
324
400
|
callback(result);
|
|
325
401
|
}
|
|
326
402
|
resolve(result);
|
|
327
403
|
drainPendingSave();
|
|
328
|
-
}
|
|
404
|
+
});
|
|
329
405
|
});
|
|
330
406
|
}
|
|
331
407
|
|
|
@@ -391,8 +467,9 @@ function initBaselineCapture() {
|
|
|
391
467
|
// Take immediate snapshot and set as baseline right away
|
|
392
468
|
// This ensures saves during settle window work correctly
|
|
393
469
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
394
|
-
const immediateContents =
|
|
470
|
+
const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
|
|
395
471
|
lastSavedContents = immediateContents;
|
|
472
|
+
lastSavedDirty = immediateDirty;
|
|
396
473
|
baselineContents = immediateContents;
|
|
397
474
|
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
398
475
|
|
|
@@ -417,13 +494,18 @@ function initBaselineCapture() {
|
|
|
417
494
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
418
495
|
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
419
496
|
|
|
420
|
-
// Only update if no user edits AND no saves occurred during settle
|
|
421
|
-
//
|
|
422
|
-
|
|
497
|
+
// Only update if no user edits AND no saves occurred during settle.
|
|
498
|
+
// BOTH baselines have to be untouched, not just the autosave one: a save
|
|
499
|
+
// whose only change was inside a batching region advances lastSavedDirty
|
|
500
|
+
// while leaving lastSavedContents byte-identical to the immediate capture.
|
|
501
|
+
// Checking one would let a second, unsent edit in that region be captured
|
|
502
|
+
// here as though it had been saved.
|
|
503
|
+
if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
|
|
423
504
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
424
505
|
const gateToken = gateCaptureToken();
|
|
425
|
-
const contents =
|
|
506
|
+
const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
|
|
426
507
|
lastSavedContents = contents;
|
|
508
|
+
lastSavedDirty = contentsDirty;
|
|
427
509
|
baselineContents = contents;
|
|
428
510
|
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
429
511
|
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
|
@@ -535,6 +617,9 @@ export function initSaveKeyboardShortcut() {
|
|
|
535
617
|
let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
536
618
|
if (metaKeyPressed && event.keyCode == 83) {
|
|
537
619
|
event.preventDefault();
|
|
620
|
+
// isTrusted, so a script calling dispatchEvent cannot manufacture human
|
|
621
|
+
// provenance for a write the person never asked for.
|
|
622
|
+
if (event.isTrusted) markExplicitSave();
|
|
538
623
|
savePage();
|
|
539
624
|
}
|
|
540
625
|
});
|
|
@@ -547,6 +632,8 @@ export function initSaveKeyboardShortcut() {
|
|
|
547
632
|
export function initHyperclaySaveButton() {
|
|
548
633
|
document.addEventListener("click", event => {
|
|
549
634
|
if (event.target.closest("[trigger-save]")) {
|
|
635
|
+
// el.click() is isTrusted:false, so this cannot be faked from script.
|
|
636
|
+
if (event.isTrusted) markExplicitSave();
|
|
550
637
|
savePage();
|
|
551
638
|
}
|
|
552
639
|
});
|
|
@@ -559,6 +646,10 @@ export function initHyperclaySaveButton() {
|
|
|
559
646
|
export function init() {
|
|
560
647
|
if (!isEditMode) return;
|
|
561
648
|
|
|
649
|
+
// Every editable page, not just autosave pages. A manual-save page makes
|
|
650
|
+
// exactly the saves a person asked for, and used to report all of them as
|
|
651
|
+
// background writes because this was installed behind the autosave gate.
|
|
652
|
+
initUserGesture();
|
|
562
653
|
initSaveKeyboardShortcut();
|
|
563
654
|
initHyperclaySaveButton();
|
|
564
655
|
}
|
package/src/core/snapshot.js
CHANGED
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
40
|
+
import { restoreAuthoredUrls } from '../lib/authored-url.js';
|
|
41
|
+
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
42
42
|
import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
43
43
|
|
|
44
44
|
// =============================================================================
|
|
@@ -135,6 +135,11 @@ export function captureSnapshot({ flushUndo = true } = {}) {
|
|
|
135
135
|
hook(clone);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
|
|
139
|
+
// the file keeps what the page author wrote. Before onbeforesnapshot, so an
|
|
140
|
+
// authored handler sees the same URLs the file will.
|
|
141
|
+
restoreAuthoredUrls(clone);
|
|
142
|
+
|
|
138
143
|
runAuthoredHandlers(clone, 'onbeforesnapshot');
|
|
139
144
|
|
|
140
145
|
for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
|
|
@@ -203,6 +208,78 @@ export function captureForComparison({ flushUndo = true } = {}) {
|
|
|
203
208
|
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Capture in the DIRTY domain: like captureForComparison, but it KEEPS
|
|
213
|
+
* no-trigger-autosave regions.
|
|
214
|
+
*
|
|
215
|
+
* The two domains answer different questions. "Should this edit start an
|
|
216
|
+
* autosave?" is the autosave domain (captureForComparison). "Is there anything
|
|
217
|
+
* here the person would lose?" is this one — an edit inside a batching region is
|
|
218
|
+
* a real edit that an explicit save must write and a close must warn about.
|
|
219
|
+
*
|
|
220
|
+
* On a document with no such region this returns bytes identical to
|
|
221
|
+
* captureForComparison, which is what keeps the two baselines comparable.
|
|
222
|
+
*
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
export function captureForDirtyCheck({ flushUndo = true } = {}) {
|
|
226
|
+
const clone = captureSnapshot({ flushUndo });
|
|
227
|
+
|
|
228
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
229
|
+
|
|
230
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
|
|
231
|
+
el.remove();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const hook of documentTransforms) {
|
|
235
|
+
hook(clone);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Both comparison domains from ONE snapshot, with no save clone and no
|
|
243
|
+
* snapshot-ready event.
|
|
244
|
+
*
|
|
245
|
+
* For the callers that need to install or check both baselines without sending
|
|
246
|
+
* anything: the load-time baseline capture and live-sync's post-morph baseline
|
|
247
|
+
* setter. Taking two separate snapshots there would cost two full DOM clones per
|
|
248
|
+
* boot and per applied frame, for bytes that are usually identical.
|
|
249
|
+
*
|
|
250
|
+
* @returns {{ forComparison: string, forDirty: string }}
|
|
251
|
+
*/
|
|
252
|
+
export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
253
|
+
const clone = captureSnapshot({ flushUndo });
|
|
254
|
+
|
|
255
|
+
runAuthoredHandlers(clone, 'onbeforesave');
|
|
256
|
+
|
|
257
|
+
const dirtyClone = clone.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
|
|
258
|
+
? clonePreventingOnclone(clone)
|
|
259
|
+
: null;
|
|
260
|
+
|
|
261
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
262
|
+
el.remove();
|
|
263
|
+
}
|
|
264
|
+
for (const hook of documentTransforms) {
|
|
265
|
+
hook(clone);
|
|
266
|
+
}
|
|
267
|
+
const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
|
|
268
|
+
|
|
269
|
+
let forDirty = forComparison;
|
|
270
|
+
if (dirtyClone) {
|
|
271
|
+
for (const el of dirtyClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
|
|
272
|
+
el.remove();
|
|
273
|
+
}
|
|
274
|
+
for (const hook of documentTransforms) {
|
|
275
|
+
hook(dirtyClone);
|
|
276
|
+
}
|
|
277
|
+
forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { forComparison, forDirty };
|
|
281
|
+
}
|
|
282
|
+
|
|
206
283
|
/**
|
|
207
284
|
* Single-capture function for both saving and comparison.
|
|
208
285
|
*
|
|
@@ -211,23 +288,11 @@ export function captureForComparison({ flushUndo = true } = {}) {
|
|
|
211
288
|
*
|
|
212
289
|
* @param {Object} options
|
|
213
290
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
214
|
-
* @returns {{ forSave: string, forComparison: string,
|
|
291
|
+
* @returns {{ forSave: string, forComparison: string, forDirty: string }}
|
|
215
292
|
*/
|
|
216
293
|
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
217
294
|
const clone = captureSnapshot();
|
|
218
295
|
|
|
219
|
-
// The unstripped snapshot, for a host that asked for the desktop JSON envelope:
|
|
220
|
-
// the save then sends both the stripped document and this. Returned to the caller
|
|
221
|
-
// rather than parked on a window global, so it can only ever be paired with the
|
|
222
|
-
// content captured alongside it. As a global it was cleared on success only, so
|
|
223
|
-
// two captures without an intervening successful save shipped a stale snapshot
|
|
224
|
-
// next to fresh content. Captured only when the document DECLARES the transport;
|
|
225
|
-
// this used to key off `location.hostname`, which set it on every localhost page
|
|
226
|
-
// whether or not its host wanted it.
|
|
227
|
-
const snapshotHtml = saveTransport() === DESKTOP_JSON
|
|
228
|
-
? '<!DOCTYPE html>' + clone.outerHTML
|
|
229
|
-
: null;
|
|
230
|
-
|
|
231
296
|
// Emit for live-sync before any stripping
|
|
232
297
|
if (emitForSync) {
|
|
233
298
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
@@ -241,6 +306,15 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
241
306
|
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
242
307
|
const compareClone = clonePreventingOnclone(clone);
|
|
243
308
|
|
|
309
|
+
// The dirty domain differs from the autosave domain only inside
|
|
310
|
+
// no-trigger-autosave regions, so a document without one pays nothing: the two
|
|
311
|
+
// strip sets remove exactly the same nodes and one string serves both. The
|
|
312
|
+
// predicate reads the captured clone after authored handlers have run, and
|
|
313
|
+
// covers all three spellings via the policy's own selector.
|
|
314
|
+
const dirtyClone = clone.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
|
|
315
|
+
? clonePreventingOnclone(clone)
|
|
316
|
+
: null;
|
|
317
|
+
|
|
244
318
|
// Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
|
|
245
319
|
// LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
|
|
246
320
|
for (const hook of documentTransforms) {
|
|
@@ -260,21 +334,42 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
260
334
|
}
|
|
261
335
|
const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
|
|
262
336
|
|
|
263
|
-
|
|
337
|
+
// Dirty clone: same shape as the compare clone, one selector weaker.
|
|
338
|
+
let forDirty = forComparison;
|
|
339
|
+
if (dirtyClone) {
|
|
340
|
+
for (const el of dirtyClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
|
|
341
|
+
el.remove();
|
|
342
|
+
}
|
|
343
|
+
for (const hook of documentTransforms) {
|
|
344
|
+
hook(dirtyClone);
|
|
345
|
+
}
|
|
346
|
+
forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { forSave, forComparison, forDirty };
|
|
264
350
|
}
|
|
265
351
|
|
|
266
352
|
/**
|
|
267
|
-
* Capture for a protected live-sync merge: the save-domain and
|
|
268
|
-
*
|
|
269
|
-
*
|
|
353
|
+
* Capture for a protected live-sync merge: the save-domain and loss-domain
|
|
354
|
+
* clones of ONE snapshot, plus a WeakMap pairing every loss-domain element to
|
|
355
|
+
* its save-clone twin.
|
|
270
356
|
*
|
|
271
|
-
* The pairing is recorded immediately after the
|
|
357
|
+
* The pairing is recorded immediately after the compare clone is created,
|
|
272
358
|
* while the two trees are still isomorphic; each side's strips then remove
|
|
273
|
-
* nodes independently without disturbing it.
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
359
|
+
* nodes independently without disturbing it.
|
|
360
|
+
*
|
|
361
|
+
* The compare clone strips STRIP_FROM_DIRTY_CHECK, not STRIP_FROM_COMPARISON:
|
|
362
|
+
* the merge asks "would applying this frame destroy work?", which is the same
|
|
363
|
+
* question the close warning asks, so it must use the same domain. The autosave
|
|
364
|
+
* domain is the wrong one here because it strips no-trigger-autosave, and an
|
|
365
|
+
* unsaved edit in a batching region is exactly the work the merge exists to
|
|
366
|
+
* protect. Disposable churn stays out of both domains via no-dirty, which is
|
|
367
|
+
* what keeps a self-churning region from promoting a dirty root to <body> and
|
|
368
|
+
* holding every frame forever.
|
|
369
|
+
*
|
|
370
|
+
* Dirty roots map through pairMap to save-domain subtrees (the same domain as
|
|
371
|
+
* the file on disk), which still carry the freeze / no-watch children the
|
|
372
|
+
* compare clone strips.
|
|
278
373
|
*
|
|
279
374
|
* Never emits snapshot-ready (this capture must not feed the send pipeline)
|
|
280
375
|
* and never flushes the undo batch (it runs per incoming frame, not per save).
|
|
@@ -305,7 +400,7 @@ export function captureForMerge() {
|
|
|
305
400
|
el.remove();
|
|
306
401
|
}
|
|
307
402
|
|
|
308
|
-
for (const el of compareClone.querySelectorAll(
|
|
403
|
+
for (const el of compareClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
|
|
309
404
|
el.remove();
|
|
310
405
|
}
|
|
311
406
|
for (const hook of documentTransforms) {
|
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
* Works independently of autosave - no mutation observer needed during editing,
|
|
8
8
|
* just a single comparison when the user tries to leave.
|
|
9
9
|
*
|
|
10
|
-
* Both current and stored content have [save-remove]
|
|
11
|
-
*
|
|
10
|
+
* Both current and stored content have [save-remove] stripped, so comparison is
|
|
11
|
+
* direct with no parsing needed. [save-ignore] / no-trigger-autosave regions are
|
|
12
|
+
* KEPT here: they don't trigger an autosave, so an edit in one is precisely the
|
|
13
|
+
* kind that would be lost without a warning.
|
|
12
14
|
*
|
|
13
15
|
* Requires the 'save-system' module (automatically included as dependency).
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import { isEditMode } from "./is-edit-mode.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
+
import { captureForDirtyCheck } from "./snapshot.js";
|
|
20
|
+
import { getLastSavedDirty } from "./save.js";
|
|
19
21
|
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
20
22
|
|
|
21
23
|
// Pre-load diff library if debug mode is on (so it's ready for unload)
|
|
@@ -30,9 +32,11 @@ preloadIfEnabled();
|
|
|
30
32
|
window.addEventListener('beforeunload', (event) => {
|
|
31
33
|
if (!isEditMode) return;
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// The DIRTY domain, not the autosave domain. An edit inside a
|
|
36
|
+
// no-trigger-autosave region never starts a save by itself, which is exactly
|
|
37
|
+
// why closing the tab on one has to warn: nothing else is going to write it.
|
|
38
|
+
const currentForCompare = captureForDirtyCheck();
|
|
39
|
+
const lastSaved = getLastSavedDirty();
|
|
36
40
|
|
|
37
41
|
if (currentForCompare !== lastSaved) {
|
|
38
42
|
// Debug: log what's different before showing the warning
|
package/src/internals/index.js
CHANGED
|
@@ -12,18 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { captureSnapshot, captureForSave, addDocumentTransform } from "../core/snapshot.js";
|
|
14
14
|
import { saveHtml, replacePageWith, isSaveInProgress } from "../core/save-core.js";
|
|
15
|
-
import {
|
|
16
|
-
addRegionToken,
|
|
17
|
-
resolveRegionPolicy,
|
|
18
|
-
isInert,
|
|
19
|
-
isSnapshotRemoved,
|
|
20
|
-
PERSIST,
|
|
21
|
-
REGION_ATTRS,
|
|
22
|
-
STRIP_FROM_SAVE,
|
|
23
|
-
STRIP_FROM_COMPARISON,
|
|
24
|
-
SNAPSHOT_REMOVE_SELECTOR,
|
|
25
|
-
FREEZE_SELECTOR,
|
|
26
|
-
} from "../lib/region-policy.js";
|
|
15
|
+
import { regionShape } from "../lib/region-policy.js";
|
|
27
16
|
|
|
28
17
|
const clay = (window.clay = window.clay || {});
|
|
29
18
|
|
|
@@ -40,20 +29,8 @@ clay.internals = {
|
|
|
40
29
|
|
|
41
30
|
// Write your own attribute without hardcoding our selectors. Doing it by hand is
|
|
42
31
|
// how a custom attribute quietly stops respecting [no-save] two releases later.
|
|
43
|
-
region
|
|
44
|
-
|
|
45
|
-
resolveRegionPolicy,
|
|
46
|
-
isInert,
|
|
47
|
-
isSnapshotRemoved,
|
|
48
|
-
PERSIST,
|
|
49
|
-
REGION_ATTRS,
|
|
50
|
-
selectors: {
|
|
51
|
-
stripFromSave: STRIP_FROM_SAVE,
|
|
52
|
-
stripFromComparison: STRIP_FROM_COMPARISON,
|
|
53
|
-
snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
|
|
54
|
-
freeze: FREEZE_SELECTOR,
|
|
55
|
-
},
|
|
56
|
-
},
|
|
32
|
+
// The same object clay.region holds, so the two can no longer drift.
|
|
33
|
+
region: regionShape,
|
|
57
34
|
|
|
58
35
|
// The save lane under clay.save. saveHtml sends bytes you supply, so it bypasses
|
|
59
36
|
// the snapshot pipeline entirely: whatever you hand it is what lands in the
|