@panphora/clayjs 1.5.3 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
package/src/core/save.js
CHANGED
|
@@ -18,15 +18,22 @@ import {
|
|
|
18
18
|
replacePageWith as replacePageWithCore,
|
|
19
19
|
addDocumentTransform,
|
|
20
20
|
isSaveInProgress,
|
|
21
|
-
saveFateIsUnknown
|
|
21
|
+
saveFateIsUnknown,
|
|
22
|
+
withKeepalive
|
|
22
23
|
} from "./save-core.js";
|
|
23
24
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
|
-
import { seedEtag } from "./etag.js";
|
|
25
|
+
import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
25
26
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
27
|
+
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
26
28
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
27
29
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
28
30
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
29
31
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
32
|
+
// A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
|
|
33
|
+
// flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
|
|
34
|
+
// Both read across it at call time, never while a module is evaluating, so neither
|
|
35
|
+
// side sees a half-built module.
|
|
36
|
+
import { bytesUnsaved } from "./unsaved-warning.js";
|
|
30
37
|
|
|
31
38
|
// Keep this library's own root state out of the saved bytes.
|
|
32
39
|
//
|
|
@@ -206,6 +213,45 @@ export function resumeAutosave() {
|
|
|
206
213
|
savePageThrottled();
|
|
207
214
|
}
|
|
208
215
|
|
|
216
|
+
// ============================================
|
|
217
|
+
// THE PREVIEW HOLD
|
|
218
|
+
// ============================================
|
|
219
|
+
//
|
|
220
|
+
// While an AI edit's rewrite is on screen and not yet kept, the DOM holds words the
|
|
221
|
+
// person has not accepted. Every save would write them, the explicit ones included,
|
|
222
|
+
// so for that window no save goes out at all. A save asked for meanwhile is
|
|
223
|
+
// remembered and runs on release; Keep releases without the replay because it saves
|
|
224
|
+
// right after.
|
|
225
|
+
let previewHold = 0;
|
|
226
|
+
let previewMissed = false;
|
|
227
|
+
|
|
228
|
+
export function holdAllSaves() {
|
|
229
|
+
previewHold++;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function releaseAllSaves({ replay = true } = {}) {
|
|
233
|
+
if (previewHold === 0) return;
|
|
234
|
+
previewHold--;
|
|
235
|
+
if (previewHold > 0) return;
|
|
236
|
+
const missed = previewMissed;
|
|
237
|
+
previewMissed = false;
|
|
238
|
+
if (missed && replay) savePage();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function savesHeld() {
|
|
242
|
+
return previewHold > 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function heldForPreview(callback, resolve) {
|
|
246
|
+
if (previewHold === 0) return false;
|
|
247
|
+
previewMissed = true;
|
|
248
|
+
clearExplicitSave();
|
|
249
|
+
const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
|
|
250
|
+
callback(skipped);
|
|
251
|
+
resolve(skipped);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
209
255
|
// ============================================
|
|
210
256
|
// THE CONFLICT HOLD
|
|
211
257
|
// ============================================
|
|
@@ -257,7 +303,8 @@ export function isSaveConflicted() {
|
|
|
257
303
|
* missed save. On a manual-save page with unsaved work the root stays in
|
|
258
304
|
* 'conflict' and the notice stays up until the person saves, since nothing has
|
|
259
305
|
* been written yet. Not clay:save-saved: no save happened, and that event runs
|
|
260
|
-
* every [onaftersave] handler.
|
|
306
|
+
* every [onaftersave] handler. Either way clay:save-conflict-released fires, so a
|
|
307
|
+
* notice can drop the refusal it was showing.
|
|
261
308
|
*/
|
|
262
309
|
export function conflictResolvedBySync(etag) {
|
|
263
310
|
if (!conflictHold) return;
|
|
@@ -273,6 +320,7 @@ export function conflictResolvedBySync(etag) {
|
|
|
273
320
|
detail: { timestamp: Date.now() }
|
|
274
321
|
}));
|
|
275
322
|
}
|
|
323
|
+
document.dispatchEvent(new CustomEvent('clay:save-conflict-released', { detail: { etag: etag ?? null } }));
|
|
276
324
|
resumeAutosave();
|
|
277
325
|
}
|
|
278
326
|
|
|
@@ -391,6 +439,8 @@ export function savePage(callback = () => {}) {
|
|
|
391
439
|
return resolve(skipped);
|
|
392
440
|
}
|
|
393
441
|
|
|
442
|
+
if (heldForPreview(callback, resolve)) return;
|
|
443
|
+
|
|
394
444
|
// A save is already on the wire. Remember that a newer state is waiting rather
|
|
395
445
|
// than dropping it: the in-flight request carries the older bytes, and if no
|
|
396
446
|
// further mutation happens to retrigger autosave, the newer ones would never
|
|
@@ -476,6 +526,8 @@ export function savePageForce(callback = () => {}) {
|
|
|
476
526
|
return resolve(skipped);
|
|
477
527
|
}
|
|
478
528
|
|
|
529
|
+
if (heldForPreview(callback, resolve)) return;
|
|
530
|
+
|
|
479
531
|
if (isSaveInProgress()) {
|
|
480
532
|
pendingSave = true;
|
|
481
533
|
const skipped = skipped_('Save already in progress');
|
|
@@ -515,6 +567,101 @@ export function savePageForce(callback = () => {}) {
|
|
|
515
567
|
});
|
|
516
568
|
}
|
|
517
569
|
|
|
570
|
+
// Resolve on the next save that settles, or reject once the deadline has passed.
|
|
571
|
+
// Every settled state is one, not just success: a save that failed is an answer about
|
|
572
|
+
// the page's bytes too, and the caller decides what to do with it.
|
|
573
|
+
function settled(deadline) {
|
|
574
|
+
const events = ['clay:save-saved', 'clay:save-error', 'clay:save-offline', 'clay:save-conflict'];
|
|
575
|
+
return new Promise((resolve, reject) => {
|
|
576
|
+
let timer = null;
|
|
577
|
+
const cleanup = () => {
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
for (const name of events) document.removeEventListener(name, onSettle);
|
|
580
|
+
};
|
|
581
|
+
const onSettle = () => {
|
|
582
|
+
cleanup();
|
|
583
|
+
resolve();
|
|
584
|
+
};
|
|
585
|
+
const remaining = deadline - Date.now();
|
|
586
|
+
if (remaining <= 0) {
|
|
587
|
+
reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
timer = setTimeout(() => {
|
|
591
|
+
cleanup();
|
|
592
|
+
reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
|
|
593
|
+
}, remaining);
|
|
594
|
+
for (const name of events) document.addEventListener(name, onSettle);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function flushError(message, state) {
|
|
599
|
+
return Object.assign(new Error(message), { state });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Resolve when `name` fires on document, or reject at the deadline.
|
|
603
|
+
function nextEvent(name, deadline) {
|
|
604
|
+
return new Promise((resolve, reject) => {
|
|
605
|
+
const remaining = deadline - Date.now();
|
|
606
|
+
if (remaining <= 0) return reject(flushError('Save timed out.', 'failed'));
|
|
607
|
+
const timer = setTimeout(() => {
|
|
608
|
+
document.removeEventListener(name, onEvent);
|
|
609
|
+
reject(flushError('Save timed out.', 'failed'));
|
|
610
|
+
}, remaining);
|
|
611
|
+
const onEvent = () => { clearTimeout(timer); resolve(); };
|
|
612
|
+
document.addEventListener(name, onEvent, { once: true });
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Save now; answer only when the host has accepted the bytes the page holds now.
|
|
617
|
+
// Resolves { state: 'view' | 'clean' | 'saved', etag }. Rejects with an Error carrying
|
|
618
|
+
// state 'failed' (including a timeout), 'conflict', or 'blocked' (the bytes are on the
|
|
619
|
+
// host but the page holds work a save cannot write, registered through
|
|
620
|
+
// clay.registerUnsavedState; only a person can resolve that). Never overwrites.
|
|
621
|
+
// `keepalive` is a transport hint honoured under 60 KB of UTF-8.
|
|
622
|
+
export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
|
|
623
|
+
if (!isEditMode) return { state: 'view', etag: null };
|
|
624
|
+
const deadline = Date.now() + timeoutMs;
|
|
625
|
+
let timer = null;
|
|
626
|
+
const timeout = new Promise((_, reject) => {
|
|
627
|
+
timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
|
|
628
|
+
});
|
|
629
|
+
try {
|
|
630
|
+
return await Promise.race([flushBytes(keepalive, deadline), timeout]);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
if (err && typeof err.state === 'string') throw err;
|
|
633
|
+
throw flushError(err?.message || 'Save failed', 'failed');
|
|
634
|
+
} finally {
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function flushBytes(keepalive, deadline) {
|
|
640
|
+
// Load-time module churn is not the person's work: autosave never saves it, and
|
|
641
|
+
// neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
|
|
642
|
+
if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
|
|
643
|
+
|
|
644
|
+
let saved = false;
|
|
645
|
+
let etag = null;
|
|
646
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
647
|
+
if (isSaveInProgress()) await settled(deadline);
|
|
648
|
+
if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
|
|
649
|
+
const token = gateCaptureToken();
|
|
650
|
+
if (!bytesUnsaved()) {
|
|
651
|
+
gateClearIfUnchanged(token);
|
|
652
|
+
if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
|
|
653
|
+
return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
|
|
654
|
+
}
|
|
655
|
+
const result = await withKeepalive(keepalive, () => savePage());
|
|
656
|
+
// The lane was busy (a drained pending save is on the wire): wait it out next turn.
|
|
657
|
+
if (result.msgType === 'skipped') continue;
|
|
658
|
+
if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
|
|
659
|
+
saved = true;
|
|
660
|
+
etag = result.etag ?? null;
|
|
661
|
+
}
|
|
662
|
+
throw flushError('The document kept changing while saving.', 'failed');
|
|
663
|
+
}
|
|
664
|
+
|
|
518
665
|
/**
|
|
519
666
|
* Keep this tab's version, over the one the host is holding.
|
|
520
667
|
*
|
|
@@ -705,6 +852,13 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
705
852
|
return Promise.resolve(skipped);
|
|
706
853
|
}
|
|
707
854
|
|
|
855
|
+
if (previewHold > 0) {
|
|
856
|
+
previewMissed = true;
|
|
857
|
+
const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
|
|
858
|
+
callback(skipped);
|
|
859
|
+
return Promise.resolve(skipped);
|
|
860
|
+
}
|
|
861
|
+
|
|
708
862
|
// Every autosave path lands here — the mutation-driven one, the [persist] input
|
|
709
863
|
// timer, and live-sync's convergence save after a protected apply — which is
|
|
710
864
|
// why the suspension lives at this one entry rather than at each caller.
|
package/src/core/snapshot.js
CHANGED
|
@@ -679,3 +679,24 @@ export function captureBodyForSync() {
|
|
|
679
679
|
export function getPageContents() {
|
|
680
680
|
return captureForSave({ emitForSync: false });
|
|
681
681
|
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The page as this tab had it before a merge replaced some of it, as the bytes a
|
|
685
|
+
* save would have written. The ledger's clone is shared and must come back
|
|
686
|
+
* untouched, so this works on a copy. A snapshot-domain clone still needs the save
|
|
687
|
+
* preparation (transforms, the [no-save] strip) but not the authored onbeforesave
|
|
688
|
+
* handlers: this is a download, not a save. A save-domain clone is already prepared.
|
|
689
|
+
*/
|
|
690
|
+
export function serializeRecoveryClone(clone, { prepared = false } = {}) {
|
|
691
|
+
const copy = clonePreventingOnclone(clone);
|
|
692
|
+
return serializeClone(prepared ? copy : prepareCloneForSave(copy, { authored: false }));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The live page as a save would write it, for a download: the save transforms and
|
|
697
|
+
* the [no-save] strip, but no authored handler runs and the undo batch stays open.
|
|
698
|
+
* Nothing is sent and no event fires.
|
|
699
|
+
*/
|
|
700
|
+
export function captureRecoveryPage() {
|
|
701
|
+
return serializeSaveClone(captureSaveClone({ flushUndo: false, authored: false }));
|
|
702
|
+
}
|
|
@@ -19,17 +19,16 @@ import { set, make } from "../lib/hostile-css.js";
|
|
|
19
19
|
// One line, no choice to make: nothing on this page can fix it, so offering a button
|
|
20
20
|
// would be a lie. Dismissable, because after you have read it, it is only in the way.
|
|
21
21
|
|
|
22
|
-
const BG = "var(--clay-notice-bg,#222)";
|
|
23
|
-
const INK = "var(--clay-notice-ink,#fff)";
|
|
24
|
-
const EDGE = "var(--clay-notice-edge,rgba(255,255,255,.28))";
|
|
25
|
-
const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
26
|
-
|
|
27
22
|
const MESSAGE =
|
|
28
23
|
"This page can't be edited: the app serving it is out of date. " +
|
|
29
24
|
"Update HTML Clay to 1.9.0 or newer.";
|
|
30
25
|
|
|
31
26
|
let root = null;
|
|
32
27
|
|
|
28
|
+
// Bevel is fetched only when the warning is actually shown. This module loads on every
|
|
29
|
+
// page, view mode included, and almost none of them ever show it.
|
|
30
|
+
let ui = null;
|
|
31
|
+
|
|
33
32
|
// A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
|
|
34
33
|
// layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
|
|
35
34
|
// as the conflict notice.
|
|
@@ -48,32 +47,24 @@ function dismiss() {
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
function build() {
|
|
51
|
-
root =
|
|
50
|
+
root = ui.bevelSurface("div", [
|
|
52
51
|
"position:fixed", "left:50%", "transform:translateX(-50%)",
|
|
53
|
-
"z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
|
|
54
|
-
"max-width:calc(100vw - 24px)", "flex-wrap:wrap",
|
|
55
|
-
"padding:
|
|
56
|
-
`background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
|
|
57
|
-
"box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
|
|
58
|
-
`font:${FONT}`, "text-align:left",
|
|
52
|
+
"z-index:2147483001", "display:flex", "align-items:center", "gap:10px 12px",
|
|
53
|
+
"width:max-content", "max-width:calc(100vw - 24px)", "flex-wrap:wrap",
|
|
54
|
+
"padding:8px 8px 8px 14px", "text-align:left", `color-scheme:${ui.pageScheme()}`,
|
|
59
55
|
]);
|
|
60
56
|
// Three markers, and each is load-bearing on a page that CAN save: this element is
|
|
61
57
|
// injected, so it is in no document on disk and must never reach one, never wake the
|
|
62
58
|
// watcher, and never ride out in a snapshot to somebody else's browser.
|
|
63
|
-
root.setAttribute("clay",
|
|
59
|
+
root.setAttribute("clay", ui.RUNTIME_ONLY);
|
|
64
60
|
root.setAttribute("data-clay-stale-host", "");
|
|
65
61
|
root.setAttribute("role", "alert");
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const close =
|
|
70
|
-
"all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
|
|
71
|
-
"color:" + INK, "opacity:.72", "padding:2px 6px", "border-radius:6px", "flex:none",
|
|
72
|
-
], "Dismiss");
|
|
73
|
-
close.type = "button";
|
|
63
|
+
const dot = make("span", ["all:initial", "color-scheme:inherit", "display:inline-block", "flex:none", "width:8px", "height:8px", "border-radius:50%", `background:${ui.TOKENS.ox}`]);
|
|
64
|
+
dot.setAttribute("clay", ui.RUNTIME_ONLY);
|
|
65
|
+
const close = ui.bevelButton("Dismiss", { small: true, variant: "quiet", onClick: dismiss });
|
|
74
66
|
close.setAttribute("aria-label", "Dismiss this message");
|
|
75
|
-
|
|
76
|
-
root.append(close);
|
|
67
|
+
root.append(dot, ui.bevelText("span", [], MESSAGE), close);
|
|
77
68
|
|
|
78
69
|
document.body.appendChild(root);
|
|
79
70
|
place();
|
|
@@ -81,7 +72,7 @@ function build() {
|
|
|
81
72
|
window.visualViewport?.addEventListener("scroll", place);
|
|
82
73
|
}
|
|
83
74
|
|
|
84
|
-
function init() {
|
|
75
|
+
async function init() {
|
|
85
76
|
if (!servedStaleToken()) return;
|
|
86
77
|
// ?editmode=true outranks the stale-host check by design: that is a person at the
|
|
87
78
|
// keyboard asking for editing on this load, and is-edit-mode.js gives it to them.
|
|
@@ -89,7 +80,13 @@ function init() {
|
|
|
89
80
|
// told its reader the opposite. The message is only true while editing is actually
|
|
90
81
|
// off, so it is shown only then.
|
|
91
82
|
if (isEditMode) return;
|
|
83
|
+
const [controls, bevel] = await Promise.all([import("../ui/bevel-controls.js"), import("../ui/bevel.js")]);
|
|
84
|
+
ui = { ...controls, TOKENS: bevel.TOKENS };
|
|
92
85
|
build();
|
|
93
86
|
}
|
|
94
87
|
|
|
95
|
-
|
|
88
|
+
let shown = Promise.resolve();
|
|
89
|
+
onDomReady(() => { shown = init(); });
|
|
90
|
+
|
|
91
|
+
// Settles once the warning is on the page, or straight away when there is none to show.
|
|
92
|
+
export const whenShown = () => shown;
|
|
@@ -19,6 +19,20 @@ import { isEditMode } from "./is-edit-mode.js";
|
|
|
19
19
|
import { captureForDirtyCheck } from "./snapshot.js";
|
|
20
20
|
import { getLastSavedDirty } from "./save.js";
|
|
21
21
|
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
22
|
+
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
23
|
+
|
|
24
|
+
// One beforeunload let through, for the reload a person confirmed with two presses
|
|
25
|
+
// in the conflict notice. Nothing else is switched off: the next close still warns.
|
|
26
|
+
let discardPermit = false;
|
|
27
|
+
export function reloadAfterDiscard({ isCurrent, reload }) {
|
|
28
|
+
if (!isCurrent()) return false;
|
|
29
|
+
discardPermit = true;
|
|
30
|
+
// Browsers fire beforeunload for a reload on their own schedule; a permit still
|
|
31
|
+
// unused a second later means the navigation did not happen.
|
|
32
|
+
setTimeout(() => { discardPermit = false; }, 1000);
|
|
33
|
+
reload();
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
// Pre-load diff library if debug mode is on (so it's ready for unload)
|
|
24
38
|
preloadIfEnabled();
|
|
@@ -29,23 +43,57 @@ preloadIfEnabled();
|
|
|
29
43
|
// sandboxed document, which cannot read cookies at all. Those are exactly the
|
|
30
44
|
// documents where an unsaved edit is easiest to lose. If the page is editable,
|
|
31
45
|
// the person editing it deserves the warning.
|
|
46
|
+
//
|
|
47
|
+
// The same predicate is exposed as `clay.hasUnsavedChanges`, which a host asks
|
|
48
|
+
// synchronously before deciding whether a save is worth asking for.
|
|
49
|
+
export function hasUnsavedChanges() {
|
|
50
|
+
if (!isEditMode) return false;
|
|
51
|
+
|
|
52
|
+
// Work outside the DOM first: it needs no capture, and a capture that throws
|
|
53
|
+
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
54
|
+
if (hasUnsavedState()) return true;
|
|
55
|
+
|
|
56
|
+
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
57
|
+
// leaving loses none of them.
|
|
58
|
+
if (window.clay?.demo) return false;
|
|
59
|
+
|
|
60
|
+
return bytesUnsaved();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The page's own bytes against the last bytes the host accepted. A capture that throws
|
|
64
|
+
// counts as unsaved: something on the page is broken, and "nothing to lose" is the one
|
|
65
|
+
// answer that can lose work.
|
|
66
|
+
//
|
|
67
|
+
// The DIRTY domain, not the autosave domain. An edit inside a no-trigger-autosave
|
|
68
|
+
// region never starts a save by itself, which is exactly why closing the tab on one has
|
|
69
|
+
// to warn: nothing else is going to write it.
|
|
70
|
+
export function bytesUnsaved() {
|
|
71
|
+
if (!isEditMode) return false;
|
|
72
|
+
try {
|
|
73
|
+
return captureForDirtyCheck() !== getLastSavedDirty();
|
|
74
|
+
} catch {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
32
79
|
window.addEventListener('beforeunload', (event) => {
|
|
33
80
|
if (!isEditMode) return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
|
|
82
|
+
if (discardPermit) {
|
|
83
|
+
discardPermit = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!hasUnsavedChanges()) return;
|
|
88
|
+
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.returnValue = '';
|
|
91
|
+
// Debug only, after the decision: a capture that throws here must not cancel the warning.
|
|
92
|
+
if (!window.clay?.demo && !hasUnsavedState()) {
|
|
93
|
+
try {
|
|
94
|
+
const currentForCompare = captureForDirtyCheck();
|
|
95
|
+
const lastSaved = getLastSavedDirty();
|
|
96
|
+
if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
97
|
+
} catch {}
|
|
50
98
|
}
|
|
51
99
|
});
|
package/src/lib/content-dom.js
CHANGED
|
@@ -27,8 +27,10 @@ function matchesWithin(node, selector, boundary, inherit) {
|
|
|
27
27
|
|
|
28
28
|
function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
|
|
29
29
|
if (source.nodeType === 1 && (
|
|
30
|
-
(inherit
|
|
31
|
-
|
|
30
|
+
(inherit
|
|
31
|
+
? hasCapability(source, capability)
|
|
32
|
+
: matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
33
|
+
(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
|
|
32
34
|
)) return null
|
|
33
35
|
const copy = targetDocument.importNode(source, false)
|
|
34
36
|
maps.cloneToLive.set(copy, source)
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -35,6 +35,26 @@ let clearedAt = 0;
|
|
|
35
35
|
let paused = false;
|
|
36
36
|
let started = false;
|
|
37
37
|
|
|
38
|
+
// The state change, announced on the document: `clay:dirty` when work nothing has
|
|
39
|
+
// written appears, `clay:clean` when a save (or the oracle) has accounted for it. A
|
|
40
|
+
// burst of edits is ONE announcement, since what a listener wants is the state, not
|
|
41
|
+
// the edit. Only a transition is dispatched.
|
|
42
|
+
let announcedDirty = false;
|
|
43
|
+
let queued = false;
|
|
44
|
+
|
|
45
|
+
function scheduleAnnounce() {
|
|
46
|
+
if (!isEditMode || queued) return;
|
|
47
|
+
queued = true;
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
queued = false;
|
|
50
|
+
if (typeof document === 'undefined') return;
|
|
51
|
+
const dirty = pageMaybeDirty();
|
|
52
|
+
if (dirty === announcedDirty) return;
|
|
53
|
+
announcedDirty = dirty;
|
|
54
|
+
document.dispatchEvent(new CustomEvent(dirty ? 'clay:dirty' : 'clay:clean'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
|
|
39
59
|
|
|
40
60
|
// Regions the loss oracle never sees. The hub feed already skips them, through
|
|
@@ -81,6 +101,7 @@ function onUserInput(event) {
|
|
|
81
101
|
if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
|
|
82
102
|
if (el.closest(GATE_IGNORE)) return;
|
|
83
103
|
changes++;
|
|
104
|
+
scheduleAnnounce();
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
export function startDirtyGate() {
|
|
@@ -89,7 +110,10 @@ export function startDirtyGate() {
|
|
|
89
110
|
Mutation.onAnyChange(
|
|
90
111
|
{ omitChangeDetails: true, require: 'dirty' },
|
|
91
112
|
() => {
|
|
92
|
-
if (!paused)
|
|
113
|
+
if (!paused) {
|
|
114
|
+
changes++;
|
|
115
|
+
scheduleAnnounce();
|
|
116
|
+
}
|
|
93
117
|
}
|
|
94
118
|
);
|
|
95
119
|
document.addEventListener('input', onUserInput, true);
|
|
@@ -151,6 +175,7 @@ export function probeMarkClean() {
|
|
|
151
175
|
for (const el of gatedPersistControls()) {
|
|
152
176
|
probeCache.set(el, controlSignature(el));
|
|
153
177
|
}
|
|
178
|
+
scheduleAnnounce();
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
export function pageMaybeDirty() {
|
|
@@ -173,6 +198,7 @@ export function gateCaptureToken() {
|
|
|
173
198
|
/** A change nothing in the DOM recorded: the page holds text a save has not written. */
|
|
174
199
|
export function gateMarkDirty() {
|
|
175
200
|
changes++;
|
|
201
|
+
scheduleAnnounce();
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
export function gateClearIfUnchanged(token) {
|
|
@@ -186,8 +212,18 @@ export function gateClearIfUnchanged(token) {
|
|
|
186
212
|
for (const [el, sig] of token.probe) {
|
|
187
213
|
if (controlSignature(el) === sig) probeCache.set(el, sig);
|
|
188
214
|
}
|
|
215
|
+
scheduleAnnounce();
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
if (typeof document !== 'undefined' && isEditMode) {
|
|
192
219
|
startDirtyGate();
|
|
193
220
|
}
|
|
221
|
+
|
|
222
|
+
// Public for editors whose own surface is editor-ui (so neither gate feed sees it):
|
|
223
|
+
// a person's edit made while a live-sync frame waits on a resource would otherwise be
|
|
224
|
+
// recorded as saved. Set here, not in the loader, because the sync plugin is what
|
|
225
|
+
// loads this module.
|
|
226
|
+
if (typeof window !== 'undefined') {
|
|
227
|
+
window.clay = window.clay || {};
|
|
228
|
+
window.clay.markDirty = gateMarkDirty;
|
|
229
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unsaved-state.js: "this tab holds work its saved bytes cannot show".
|
|
3
|
+
*
|
|
4
|
+
* The close warning compares the page's bytes with the last save. Some work is
|
|
5
|
+
* not in those bytes: text a live-sync merge replaced, kept in memory until the
|
|
6
|
+
* person reviews it, or an editor's model ahead of the DOM it writes into. Its
|
|
7
|
+
* owner registers a synchronous check here, and the close warning fires while
|
|
8
|
+
* any check says pending. Checks run fresh each time, so a forgotten state can
|
|
9
|
+
* never outlive what it describes, and a save never clears one.
|
|
10
|
+
*/
|
|
11
|
+
const sources = new Map();
|
|
12
|
+
let nextKey = 0;
|
|
13
|
+
|
|
14
|
+
export function registerUnsavedState({ id = '', isPending }) {
|
|
15
|
+
const key = ++nextKey;
|
|
16
|
+
sources.set(key, { id, isPending });
|
|
17
|
+
let disposed = false;
|
|
18
|
+
return {
|
|
19
|
+
changed() {
|
|
20
|
+
if (disposed) return;
|
|
21
|
+
document.dispatchEvent(new CustomEvent('clay:unsaved-state-changed', {
|
|
22
|
+
detail: { pending: pendingIds() },
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
dispose() {
|
|
26
|
+
if (disposed) return;
|
|
27
|
+
disposed = true;
|
|
28
|
+
sources.delete(key);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A check that throws counts as pending: unknown is not clean.
|
|
34
|
+
function isPending(source) {
|
|
35
|
+
try {
|
|
36
|
+
return !!source.isPending();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(`[clay] unsaved-state check "${source.id}" threw`, err);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pendingIds() {
|
|
44
|
+
return [...sources.values()].filter(isPending).map((s) => s.id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hasUnsavedState() {
|
|
48
|
+
for (const source of sources.values()) if (isPending(source)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof window !== 'undefined') {
|
|
53
|
+
window.clay = window.clay || {};
|
|
54
|
+
window.clay.registerUnsavedState = registerUnsavedState;
|
|
55
|
+
}
|