@panphora/clayjs 0.6.1 → 0.7.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/LICENSE +12 -17
- package/README.md +21 -11
- package/THIRD-PARTY-NOTICES.md +40 -0
- package/package.json +4 -4
- 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/save-core.js +26 -27
- package/src/core/save.js +105 -26
- 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 +11 -4
- package/src/plugins/demo.js +14 -8
- package/src/sync/live-sync.js +157 -12
- package/src/sync/splice-merge.js +21 -7
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/NOTICE +0 -19
package/src/core/save.js
CHANGED
|
@@ -19,10 +19,11 @@ 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
24
|
import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
|
|
25
25
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
26
|
+
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
26
27
|
|
|
27
28
|
// Keep this library's own root state out of the saved bytes.
|
|
28
29
|
//
|
|
@@ -99,9 +100,18 @@ window.addEventListener('offline', () => {
|
|
|
99
100
|
});
|
|
100
101
|
|
|
101
102
|
window.addEventListener('online', () => {
|
|
102
|
-
if (document.documentElement.getAttribute('savestatus')
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
});
|
|
105
115
|
});
|
|
106
116
|
|
|
107
117
|
// ============================================
|
|
@@ -116,16 +126,36 @@ window.addEventListener('online', () => {
|
|
|
116
126
|
// ever sending them: the edit was gone, with no error, no dirty flag, and no
|
|
117
127
|
// close-tab warning.
|
|
118
128
|
//
|
|
119
|
-
// Post-save mutators are made invisible to the comparison instead
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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.
|
|
123
140
|
|
|
124
141
|
// Re-export from core for backward compatibility
|
|
125
142
|
export { addDocumentTransform, getPageContents };
|
|
126
143
|
|
|
127
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.
|
|
128
157
|
let lastSavedContents = '';
|
|
158
|
+
let lastSavedDirty = '';
|
|
129
159
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
130
160
|
let pendingSave = false;
|
|
131
161
|
|
|
@@ -173,9 +203,12 @@ function skipped_(msg) {
|
|
|
173
203
|
* where the write may or may not have landed, so it is not treated as success
|
|
174
204
|
* either.
|
|
175
205
|
*/
|
|
176
|
-
function applySaveResult(result, forComparison, label, gateToken) {
|
|
206
|
+
function applySaveResult(result, forComparison, forDirty, label, gateToken) {
|
|
177
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.
|
|
178
210
|
lastSavedContents = forComparison;
|
|
211
|
+
lastSavedDirty = forDirty;
|
|
179
212
|
unsavedChanges = false;
|
|
180
213
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
181
214
|
// changed while this save was on the wire.
|
|
@@ -206,7 +239,28 @@ function drainPendingSave() {
|
|
|
206
239
|
export function getUnsavedChanges() { return unsavedChanges; }
|
|
207
240
|
export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
208
241
|
export function getLastSavedContents() { return lastSavedContents; }
|
|
209
|
-
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
|
+
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
212
266
|
* Save the current page with change detection and state management.
|
|
@@ -221,6 +275,7 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
|
221
275
|
export function savePage(callback = () => {}) {
|
|
222
276
|
return new Promise((resolve) => {
|
|
223
277
|
if (!isEditMode && !window.clay?.testMode) {
|
|
278
|
+
clearExplicitSave();
|
|
224
279
|
const skipped = skipped_('Not in edit mode');
|
|
225
280
|
callback(skipped);
|
|
226
281
|
return resolve(skipped);
|
|
@@ -230,6 +285,8 @@ export function savePage(callback = () => {}) {
|
|
|
230
285
|
// than dropping it: the in-flight request carries the older bytes, and if no
|
|
231
286
|
// further mutation happens to retrigger autosave, the newer ones would never
|
|
232
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.
|
|
233
290
|
if (isSaveInProgress()) {
|
|
234
291
|
pendingSave = true;
|
|
235
292
|
const skipped = skipped_('Save already in progress');
|
|
@@ -247,12 +304,13 @@ export function savePage(callback = () => {}) {
|
|
|
247
304
|
// Single capture: clone once, get both versions
|
|
248
305
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
249
306
|
// forComparison additionally strips every autosave-off region
|
|
250
|
-
let forSave, forComparison,
|
|
307
|
+
let forSave, forComparison, forDirty;
|
|
251
308
|
const gateToken = gateCaptureToken();
|
|
252
309
|
try {
|
|
253
|
-
({ forSave, forComparison,
|
|
310
|
+
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
254
311
|
} catch (err) {
|
|
255
312
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
313
|
+
clearExplicitSave();
|
|
256
314
|
setSaveState('error', err.message);
|
|
257
315
|
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
258
316
|
if (typeof callback === 'function') {
|
|
@@ -261,12 +319,18 @@ export function savePage(callback = () => {}) {
|
|
|
261
319
|
return resolve(result);
|
|
262
320
|
}
|
|
263
321
|
|
|
264
|
-
//
|
|
265
|
-
|
|
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);
|
|
266
326
|
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
267
327
|
|
|
268
|
-
// 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.
|
|
269
332
|
if (!unsavedChanges) {
|
|
333
|
+
clearExplicitSave();
|
|
270
334
|
gateClearIfUnchanged(gateToken);
|
|
271
335
|
const skipped = skipped_('No changes to save');
|
|
272
336
|
callback(skipped);
|
|
@@ -278,13 +342,13 @@ export function savePage(callback = () => {}) {
|
|
|
278
342
|
|
|
279
343
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
280
344
|
saveHtml(forSave, (result) => {
|
|
281
|
-
applySaveResult(result, forComparison, 'updated after save', gateToken);
|
|
345
|
+
applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken);
|
|
282
346
|
if (typeof callback === 'function') {
|
|
283
347
|
callback(result);
|
|
284
348
|
}
|
|
285
349
|
resolve(result);
|
|
286
350
|
drainPendingSave();
|
|
287
|
-
}
|
|
351
|
+
});
|
|
288
352
|
});
|
|
289
353
|
}
|
|
290
354
|
|
|
@@ -314,10 +378,10 @@ export function savePageForce(callback = () => {}) {
|
|
|
314
378
|
setOfflineStateQuiet();
|
|
315
379
|
}
|
|
316
380
|
|
|
317
|
-
let forSave, forComparison,
|
|
381
|
+
let forSave, forComparison, forDirty;
|
|
318
382
|
const gateToken = gateCaptureToken();
|
|
319
383
|
try {
|
|
320
|
-
({ forSave, forComparison,
|
|
384
|
+
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
321
385
|
} catch (err) {
|
|
322
386
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
323
387
|
setSaveState('error', err.message);
|
|
@@ -331,13 +395,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
331
395
|
setSavingState();
|
|
332
396
|
|
|
333
397
|
saveHtml(forSave, (result) => {
|
|
334
|
-
applySaveResult(result, forComparison, 'updated after force save', gateToken);
|
|
398
|
+
applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken);
|
|
335
399
|
if (typeof callback === 'function') {
|
|
336
400
|
callback(result);
|
|
337
401
|
}
|
|
338
402
|
resolve(result);
|
|
339
403
|
drainPendingSave();
|
|
340
|
-
}
|
|
404
|
+
});
|
|
341
405
|
});
|
|
342
406
|
}
|
|
343
407
|
|
|
@@ -403,8 +467,9 @@ function initBaselineCapture() {
|
|
|
403
467
|
// Take immediate snapshot and set as baseline right away
|
|
404
468
|
// This ensures saves during settle window work correctly
|
|
405
469
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
406
|
-
const immediateContents =
|
|
470
|
+
const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
|
|
407
471
|
lastSavedContents = immediateContents;
|
|
472
|
+
lastSavedDirty = immediateDirty;
|
|
408
473
|
baselineContents = immediateContents;
|
|
409
474
|
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
410
475
|
|
|
@@ -429,13 +494,18 @@ function initBaselineCapture() {
|
|
|
429
494
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
430
495
|
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
431
496
|
|
|
432
|
-
// Only update if no user edits AND no saves occurred during settle
|
|
433
|
-
//
|
|
434
|
-
|
|
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) {
|
|
435
504
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
436
505
|
const gateToken = gateCaptureToken();
|
|
437
|
-
const contents =
|
|
506
|
+
const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
|
|
438
507
|
lastSavedContents = contents;
|
|
508
|
+
lastSavedDirty = contentsDirty;
|
|
439
509
|
baselineContents = contents;
|
|
440
510
|
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
441
511
|
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
|
@@ -547,6 +617,9 @@ export function initSaveKeyboardShortcut() {
|
|
|
547
617
|
let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
548
618
|
if (metaKeyPressed && event.keyCode == 83) {
|
|
549
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();
|
|
550
623
|
savePage();
|
|
551
624
|
}
|
|
552
625
|
});
|
|
@@ -559,6 +632,8 @@ export function initSaveKeyboardShortcut() {
|
|
|
559
632
|
export function initHyperclaySaveButton() {
|
|
560
633
|
document.addEventListener("click", event => {
|
|
561
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();
|
|
562
637
|
savePage();
|
|
563
638
|
}
|
|
564
639
|
});
|
|
@@ -571,6 +646,10 @@ export function initHyperclaySaveButton() {
|
|
|
571
646
|
export function init() {
|
|
572
647
|
if (!isEditMode) return;
|
|
573
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();
|
|
574
653
|
initSaveKeyboardShortcut();
|
|
575
654
|
initHyperclaySaveButton();
|
|
576
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
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authored-url.js — keeping a runtime URL rewrite out of the saved file.
|
|
3
|
+
*
|
|
4
|
+
* cacheBust and refetch-on-save both rewrite an href/src after a save, so the
|
|
5
|
+
* browser re-fetches the asset. That write lands AFTER the save baseline was
|
|
6
|
+
* taken, so without help it reads as an edit the person made: the page goes
|
|
7
|
+
* dirty the instant a save succeeds, warns on close about work that was already
|
|
8
|
+
* written, and on an autosave page saves again immediately.
|
|
9
|
+
*
|
|
10
|
+
* The old answer stamped the element `no-trigger-autosave` on the way past. It
|
|
11
|
+
* worked only from the SECOND save on — the baseline already held the element
|
|
12
|
+
* unmarked, so the first comparison stripped something the baseline contained
|
|
13
|
+
* and the page read dirty anyway. It also spent a region marker, which changes
|
|
14
|
+
* how undo, autosave and live-sync treat that subtree forever, to paper over a
|
|
15
|
+
* serialization problem.
|
|
16
|
+
*
|
|
17
|
+
* This fixes the bytes instead. Remember what the URL was authored as, restore
|
|
18
|
+
* it on every snapshot clone, and leave the region model alone. The live DOM
|
|
19
|
+
* keeps the busted URL, the file keeps the authored one, and no comparison ever
|
|
20
|
+
* sees a difference.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const AUTHORED = 'clay-authored-url';
|
|
24
|
+
const RUNTIME = 'clay-runtime-url';
|
|
25
|
+
const ATTR = 'clay-url-attr';
|
|
26
|
+
const SUPERSEDED = 'clay-superseded';
|
|
27
|
+
|
|
28
|
+
// Every attribute this module owns. They live on the live element and are
|
|
29
|
+
// stripped from every clone, so none of them ever reaches the file.
|
|
30
|
+
const OWNED = [AUTHORED, RUNTIME, ATTR, SUPERSEDED];
|
|
31
|
+
|
|
32
|
+
/** Which attribute carries this element's URL, matching both call sites' rule. */
|
|
33
|
+
export function urlAttrFor(el) {
|
|
34
|
+
return el.hasAttribute('href') ? 'href' : el.hasAttribute('src') ? 'src' : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write a clay-generated URL to the live DOM, remembering the authored one.
|
|
39
|
+
*
|
|
40
|
+
* Record-if-absent is the important part. A second cache-bust must not record
|
|
41
|
+
* the first bust's output as the authored value, or the file accumulates `?v=`
|
|
42
|
+
* stamps and the restore puts back a stale URL. It also makes the two helpers
|
|
43
|
+
* compose: when refetch clones an element cacheBust already touched, the clone
|
|
44
|
+
* carries the original authored value and keeps it.
|
|
45
|
+
*/
|
|
46
|
+
export function writeRuntimeUrl(el, attr, value) {
|
|
47
|
+
if (!el.hasAttribute(AUTHORED)) {
|
|
48
|
+
el.setAttribute(AUTHORED, el.getAttribute(attr) ?? '');
|
|
49
|
+
el.setAttribute(ATTR, attr);
|
|
50
|
+
}
|
|
51
|
+
el.setAttribute(attr, value);
|
|
52
|
+
el.setAttribute(RUNTIME, value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mark an element that a replacement has been inserted next to.
|
|
57
|
+
*
|
|
58
|
+
* refetch-on-save leaves the old element in the DOM until the new one loads, so
|
|
59
|
+
* for up to two seconds the page holds both. Every snapshot taken in that window
|
|
60
|
+
* must serialize exactly one, or a capture differs from the baseline by a whole
|
|
61
|
+
* duplicated element.
|
|
62
|
+
*/
|
|
63
|
+
export function markSuperseded(el) {
|
|
64
|
+
el.setAttribute(SUPERSEDED, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isSuperseded(el) {
|
|
68
|
+
return el.hasAttribute(SUPERSEDED);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restore authored URLs on a snapshot clone and drop every attribute this
|
|
73
|
+
* module owns. Runs inside captureSnapshot, so it reaches the save clone, both
|
|
74
|
+
* comparison clones, and the live-sync broadcast alike.
|
|
75
|
+
*/
|
|
76
|
+
export function restoreAuthoredUrls(clone) {
|
|
77
|
+
for (const el of clone.querySelectorAll(`[${SUPERSEDED}]`)) {
|
|
78
|
+
el.remove();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const el of clone.querySelectorAll(`[${AUTHORED}]`)) {
|
|
82
|
+
const attr = el.getAttribute(ATTR);
|
|
83
|
+
const authored = el.getAttribute(AUTHORED);
|
|
84
|
+
const runtime = el.getAttribute(RUNTIME);
|
|
85
|
+
|
|
86
|
+
// Restore only while the live value is still the one clay wrote. If the page
|
|
87
|
+
// has changed it since, that is a real authored edit: restoring would
|
|
88
|
+
// discard it silently, and keep discarding it on every save afterwards.
|
|
89
|
+
if (attr && el.getAttribute(attr) === runtime) {
|
|
90
|
+
if (authored) el.setAttribute(attr, authored);
|
|
91
|
+
else el.removeAttribute(attr);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const name of OWNED) el.removeAttribute(name);
|
|
95
|
+
}
|
|
96
|
+
}
|