@panphora/clayjs 1.6.0 → 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/dist/clay.standalone.js +258 -125
- package/package.json +1 -1
- package/src/core/save-core.js +35 -2
- package/src/core/save.js +104 -2
- package/src/core/unsaved-warning.js +43 -20
- package/src/lib/dirty-gate.js +28 -1
- package/src/loader.js +6 -0
package/dist/clay.standalone.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* clayjs 1.6.
|
|
1
|
+
/* clayjs 1.6.1 standalone build: every clayjs module in one file. https://clayjs.com/offline
|
|
2
2
|
Third-party code inside keeps its own license (Sortable MIT, Squire MIT, DOMPurify Apache-2.0 OR MPL-2.0, MicroModal MIT, parse5 MIT): https://clayjs.com/THIRD-PARTY-NOTICES.md */
|
|
3
3
|
(() => {
|
|
4
4
|
var __create = Object.create;
|
|
@@ -2537,8 +2537,17 @@
|
|
|
2537
2537
|
replacePageWith: () => replacePageWith,
|
|
2538
2538
|
resetSaveAttempts: () => resetSaveAttempts,
|
|
2539
2539
|
saveFateIsUnknown: () => saveFateIsUnknown,
|
|
2540
|
-
saveHtml: () => saveHtml
|
|
2540
|
+
saveHtml: () => saveHtml,
|
|
2541
|
+
withKeepalive: () => withKeepalive
|
|
2541
2542
|
});
|
|
2543
|
+
async function withKeepalive(keepalive, run) {
|
|
2544
|
+
nextSaveKeepalive = Boolean(keepalive);
|
|
2545
|
+
try {
|
|
2546
|
+
return await run();
|
|
2547
|
+
} finally {
|
|
2548
|
+
nextSaveKeepalive = false;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2542
2551
|
function isSaveInProgress() {
|
|
2543
2552
|
return saveInProgress;
|
|
2544
2553
|
}
|
|
@@ -2612,7 +2621,7 @@
|
|
|
2612
2621
|
function skippedResult(msg) {
|
|
2613
2622
|
return { ok: false, msg, msgType: "skipped", code: null, etag: null };
|
|
2614
2623
|
}
|
|
2615
|
-
function buildSaveRequest(content, userDriven, signal, saveId, etag) {
|
|
2624
|
+
function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
|
|
2616
2625
|
const token = saveToken();
|
|
2617
2626
|
const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
|
|
2618
2627
|
const options = {
|
|
@@ -2637,6 +2646,7 @@
|
|
|
2637
2646
|
body: content
|
|
2638
2647
|
};
|
|
2639
2648
|
if (etag) options.headers["If-Match"] = etag;
|
|
2649
|
+
if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
|
|
2640
2650
|
return { url: resolveSaveUrl(path), options };
|
|
2641
2651
|
}
|
|
2642
2652
|
function stampToSend() {
|
|
@@ -2654,7 +2664,9 @@
|
|
|
2654
2664
|
const gestureDriven = consumeUserDriven();
|
|
2655
2665
|
const explicitlyAsked = consumeExplicitSave();
|
|
2656
2666
|
const userDriven = gestureDriven || explicitlyAsked;
|
|
2657
|
-
const
|
|
2667
|
+
const keepalive = nextSaveKeepalive;
|
|
2668
|
+
nextSaveKeepalive = false;
|
|
2669
|
+
const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
|
|
2658
2670
|
rememberSentId(saveId);
|
|
2659
2671
|
return fetch(url, options).then((res) => res.text().then((text) => {
|
|
2660
2672
|
let data = {};
|
|
@@ -2779,7 +2791,7 @@
|
|
|
2779
2791
|
});
|
|
2780
2792
|
});
|
|
2781
2793
|
}
|
|
2782
|
-
var saveInProgress, SAVE_PATH, SAVE_TIMEOUT_MS, saveAcceptedHooks, sentIds, SENT_ID_MEMORY, unknownAttempt;
|
|
2794
|
+
var saveInProgress, SAVE_PATH, SAVE_TIMEOUT_MS, KEEPALIVE_MAX_BODY, nextSaveKeepalive, saveAcceptedHooks, sentIds, SENT_ID_MEMORY, unknownAttempt;
|
|
2783
2795
|
var init_save_core = __esm({
|
|
2784
2796
|
"src/core/save-core.js"() {
|
|
2785
2797
|
init_is_edit_mode();
|
|
@@ -2791,6 +2803,8 @@
|
|
|
2791
2803
|
saveInProgress = false;
|
|
2792
2804
|
SAVE_PATH = "/_/save";
|
|
2793
2805
|
SAVE_TIMEOUT_MS = 12e3;
|
|
2806
|
+
KEEPALIVE_MAX_BODY = 6e4;
|
|
2807
|
+
nextSaveKeepalive = false;
|
|
2794
2808
|
saveAcceptedHooks = [];
|
|
2795
2809
|
sentIds = [];
|
|
2796
2810
|
SENT_ID_MEMORY = 8;
|
|
@@ -2864,6 +2878,18 @@
|
|
|
2864
2878
|
});
|
|
2865
2879
|
|
|
2866
2880
|
// src/lib/dirty-gate.js
|
|
2881
|
+
function scheduleAnnounce() {
|
|
2882
|
+
if (!isEditMode || queued) return;
|
|
2883
|
+
queued = true;
|
|
2884
|
+
queueMicrotask(() => {
|
|
2885
|
+
queued = false;
|
|
2886
|
+
if (typeof document === "undefined") return;
|
|
2887
|
+
const dirty = pageMaybeDirty();
|
|
2888
|
+
if (dirty === announcedDirty) return;
|
|
2889
|
+
announcedDirty = dirty;
|
|
2890
|
+
document.dispatchEvent(new CustomEvent(dirty ? "clay:dirty" : "clay:clean"));
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2867
2893
|
function gatedPersistControls() {
|
|
2868
2894
|
const out = [];
|
|
2869
2895
|
for (const el5 of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
@@ -2878,6 +2904,7 @@
|
|
|
2878
2904
|
if (!(el5.matches("input, textarea, select") || el5.isContentEditable)) return;
|
|
2879
2905
|
if (el5.closest(GATE_IGNORE)) return;
|
|
2880
2906
|
changes++;
|
|
2907
|
+
scheduleAnnounce();
|
|
2881
2908
|
}
|
|
2882
2909
|
function startDirtyGate() {
|
|
2883
2910
|
if (started) return;
|
|
@@ -2885,7 +2912,10 @@
|
|
|
2885
2912
|
mutation_default.onAnyChange(
|
|
2886
2913
|
{ omitChangeDetails: true, require: "dirty" },
|
|
2887
2914
|
() => {
|
|
2888
|
-
if (!paused)
|
|
2915
|
+
if (!paused) {
|
|
2916
|
+
changes++;
|
|
2917
|
+
scheduleAnnounce();
|
|
2918
|
+
}
|
|
2889
2919
|
}
|
|
2890
2920
|
);
|
|
2891
2921
|
document.addEventListener("input", onUserInput, true);
|
|
@@ -2948,6 +2978,7 @@
|
|
|
2948
2978
|
}
|
|
2949
2979
|
function gateMarkDirty() {
|
|
2950
2980
|
changes++;
|
|
2981
|
+
scheduleAnnounce();
|
|
2951
2982
|
}
|
|
2952
2983
|
function gateClearIfUnchanged(token) {
|
|
2953
2984
|
if (!token) return;
|
|
@@ -2957,8 +2988,9 @@
|
|
|
2957
2988
|
for (const [el5, sig] of token.probe) {
|
|
2958
2989
|
if (controlSignature(el5) === sig) probeCache.set(el5, sig);
|
|
2959
2990
|
}
|
|
2991
|
+
scheduleAnnounce();
|
|
2960
2992
|
}
|
|
2961
|
-
var changes, clearedAt, paused, started, PERSIST_CONTROLS, GATE_IGNORE, probeCache;
|
|
2993
|
+
var changes, clearedAt, paused, started, announcedDirty, queued, PERSIST_CONTROLS, GATE_IGNORE, probeCache;
|
|
2962
2994
|
var init_dirty_gate = __esm({
|
|
2963
2995
|
"src/lib/dirty-gate.js"() {
|
|
2964
2996
|
init_mutation();
|
|
@@ -2969,6 +3001,8 @@
|
|
|
2969
3001
|
clearedAt = 0;
|
|
2970
3002
|
paused = false;
|
|
2971
3003
|
started = false;
|
|
3004
|
+
announcedDirty = false;
|
|
3005
|
+
queued = false;
|
|
2972
3006
|
PERSIST_CONTROLS = `input${PERSIST2}, textarea${PERSIST2}, select${PERSIST2}`;
|
|
2973
3007
|
GATE_IGNORE = `${STRIP_FROM_DIRTY_CHECK}, ${SNAPSHOT_REMOVE_SELECTOR}`;
|
|
2974
3008
|
probeCache = /* @__PURE__ */ new WeakMap();
|
|
@@ -2982,6 +3016,52 @@
|
|
|
2982
3016
|
}
|
|
2983
3017
|
});
|
|
2984
3018
|
|
|
3019
|
+
// src/lib/unsaved-state.js
|
|
3020
|
+
function registerUnsavedState({ id = "", isPending: isPending2 }) {
|
|
3021
|
+
const key2 = ++nextKey;
|
|
3022
|
+
sources.set(key2, { id, isPending: isPending2 });
|
|
3023
|
+
let disposed = false;
|
|
3024
|
+
return {
|
|
3025
|
+
changed() {
|
|
3026
|
+
if (disposed) return;
|
|
3027
|
+
document.dispatchEvent(new CustomEvent("clay:unsaved-state-changed", {
|
|
3028
|
+
detail: { pending: pendingIds() }
|
|
3029
|
+
}));
|
|
3030
|
+
},
|
|
3031
|
+
dispose() {
|
|
3032
|
+
if (disposed) return;
|
|
3033
|
+
disposed = true;
|
|
3034
|
+
sources.delete(key2);
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
function isPending(source2) {
|
|
3039
|
+
try {
|
|
3040
|
+
return !!source2.isPending();
|
|
3041
|
+
} catch (err) {
|
|
3042
|
+
console.error(`[clay] unsaved-state check "${source2.id}" threw`, err);
|
|
3043
|
+
return true;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
function pendingIds() {
|
|
3047
|
+
return [...sources.values()].filter(isPending).map((s2) => s2.id);
|
|
3048
|
+
}
|
|
3049
|
+
function hasUnsavedState() {
|
|
3050
|
+
for (const source2 of sources.values()) if (isPending(source2)) return true;
|
|
3051
|
+
return false;
|
|
3052
|
+
}
|
|
3053
|
+
var sources, nextKey;
|
|
3054
|
+
var init_unsaved_state = __esm({
|
|
3055
|
+
"src/lib/unsaved-state.js"() {
|
|
3056
|
+
sources = /* @__PURE__ */ new Map();
|
|
3057
|
+
nextKey = 0;
|
|
3058
|
+
if (typeof window !== "undefined") {
|
|
3059
|
+
window.clay = window.clay || {};
|
|
3060
|
+
window.clay.registerUnsavedState = registerUnsavedState;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
3064
|
+
|
|
2985
3065
|
// src/lib/autosave-state.js
|
|
2986
3066
|
function setAutosaveActive(value) {
|
|
2987
3067
|
active = !!value;
|
|
@@ -3094,6 +3174,67 @@
|
|
|
3094
3174
|
}
|
|
3095
3175
|
});
|
|
3096
3176
|
|
|
3177
|
+
// src/core/unsaved-warning.js
|
|
3178
|
+
var unsaved_warning_exports = {};
|
|
3179
|
+
__export(unsaved_warning_exports, {
|
|
3180
|
+
bytesUnsaved: () => bytesUnsaved,
|
|
3181
|
+
hasUnsavedChanges: () => hasUnsavedChanges,
|
|
3182
|
+
reloadAfterDiscard: () => reloadAfterDiscard
|
|
3183
|
+
});
|
|
3184
|
+
function reloadAfterDiscard({ isCurrent, reload }) {
|
|
3185
|
+
if (!isCurrent()) return false;
|
|
3186
|
+
discardPermit = true;
|
|
3187
|
+
setTimeout(() => {
|
|
3188
|
+
discardPermit = false;
|
|
3189
|
+
}, 1e3);
|
|
3190
|
+
reload();
|
|
3191
|
+
return true;
|
|
3192
|
+
}
|
|
3193
|
+
function hasUnsavedChanges() {
|
|
3194
|
+
if (!isEditMode) return false;
|
|
3195
|
+
if (hasUnsavedState()) return true;
|
|
3196
|
+
if (window.clay?.demo) return false;
|
|
3197
|
+
return bytesUnsaved();
|
|
3198
|
+
}
|
|
3199
|
+
function bytesUnsaved() {
|
|
3200
|
+
if (!isEditMode) return false;
|
|
3201
|
+
try {
|
|
3202
|
+
return captureForDirtyCheck() !== getLastSavedDirty();
|
|
3203
|
+
} catch {
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
var discardPermit;
|
|
3208
|
+
var init_unsaved_warning = __esm({
|
|
3209
|
+
"src/core/unsaved-warning.js"() {
|
|
3210
|
+
init_is_edit_mode();
|
|
3211
|
+
init_snapshot();
|
|
3212
|
+
init_save();
|
|
3213
|
+
init_autosave_debug();
|
|
3214
|
+
init_unsaved_state();
|
|
3215
|
+
discardPermit = false;
|
|
3216
|
+
preloadIfEnabled();
|
|
3217
|
+
window.addEventListener("beforeunload", (event) => {
|
|
3218
|
+
if (!isEditMode) return;
|
|
3219
|
+
if (discardPermit) {
|
|
3220
|
+
discardPermit = false;
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
if (!hasUnsavedChanges()) return;
|
|
3224
|
+
event.preventDefault();
|
|
3225
|
+
event.returnValue = "";
|
|
3226
|
+
if (!window.clay?.demo && !hasUnsavedState()) {
|
|
3227
|
+
try {
|
|
3228
|
+
const currentForCompare = captureForDirtyCheck();
|
|
3229
|
+
const lastSaved = getLastSavedDirty();
|
|
3230
|
+
if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
3231
|
+
} catch {
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3097
3238
|
// src/core/save.js
|
|
3098
3239
|
var save_exports = {};
|
|
3099
3240
|
__export(save_exports, {
|
|
@@ -3101,6 +3242,7 @@
|
|
|
3101
3242
|
baselineSettled: () => baselineSettled,
|
|
3102
3243
|
conflictResolvedBySync: () => conflictResolvedBySync,
|
|
3103
3244
|
default: () => save_default,
|
|
3245
|
+
flushSave: () => flushSave,
|
|
3104
3246
|
getLastSavedBytes: () => getLastSavedBytes,
|
|
3105
3247
|
getLastSavedContents: () => getLastSavedContents,
|
|
3106
3248
|
getLastSavedDirty: () => getLastSavedDirty,
|
|
@@ -3366,6 +3508,85 @@
|
|
|
3366
3508
|
});
|
|
3367
3509
|
});
|
|
3368
3510
|
}
|
|
3511
|
+
function settled(deadline) {
|
|
3512
|
+
const events = ["clay:save-saved", "clay:save-error", "clay:save-offline", "clay:save-conflict"];
|
|
3513
|
+
return new Promise((resolve, reject) => {
|
|
3514
|
+
let timer = null;
|
|
3515
|
+
const cleanup = () => {
|
|
3516
|
+
clearTimeout(timer);
|
|
3517
|
+
for (const name of events) document.removeEventListener(name, onSettle);
|
|
3518
|
+
};
|
|
3519
|
+
const onSettle = () => {
|
|
3520
|
+
cleanup();
|
|
3521
|
+
resolve();
|
|
3522
|
+
};
|
|
3523
|
+
const remaining = deadline - Date.now();
|
|
3524
|
+
if (remaining <= 0) {
|
|
3525
|
+
reject(Object.assign(new Error("Save timed out."), { state: "failed" }));
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
timer = setTimeout(() => {
|
|
3529
|
+
cleanup();
|
|
3530
|
+
reject(Object.assign(new Error("Save timed out."), { state: "failed" }));
|
|
3531
|
+
}, remaining);
|
|
3532
|
+
for (const name of events) document.addEventListener(name, onSettle);
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
function flushError(message, state2) {
|
|
3536
|
+
return Object.assign(new Error(message), { state: state2 });
|
|
3537
|
+
}
|
|
3538
|
+
function nextEvent(name, deadline) {
|
|
3539
|
+
return new Promise((resolve, reject) => {
|
|
3540
|
+
const remaining = deadline - Date.now();
|
|
3541
|
+
if (remaining <= 0) return reject(flushError("Save timed out.", "failed"));
|
|
3542
|
+
const timer = setTimeout(() => {
|
|
3543
|
+
document.removeEventListener(name, onEvent);
|
|
3544
|
+
reject(flushError("Save timed out.", "failed"));
|
|
3545
|
+
}, remaining);
|
|
3546
|
+
const onEvent = () => {
|
|
3547
|
+
clearTimeout(timer);
|
|
3548
|
+
resolve();
|
|
3549
|
+
};
|
|
3550
|
+
document.addEventListener(name, onEvent, { once: true });
|
|
3551
|
+
});
|
|
3552
|
+
}
|
|
3553
|
+
async function flushSave({ keepalive = false, timeoutMs = 15e3 } = {}) {
|
|
3554
|
+
if (!isEditMode) return { state: "view", etag: null };
|
|
3555
|
+
const deadline = Date.now() + timeoutMs;
|
|
3556
|
+
let timer = null;
|
|
3557
|
+
const timeout = new Promise((_, reject) => {
|
|
3558
|
+
timer = setTimeout(() => reject(flushError("Save timed out.", "failed")), Math.max(0, timeoutMs));
|
|
3559
|
+
});
|
|
3560
|
+
try {
|
|
3561
|
+
return await Promise.race([flushBytes(keepalive, deadline), timeout]);
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
if (err && typeof err.state === "string") throw err;
|
|
3564
|
+
throw flushError(err?.message || "Save failed", "failed");
|
|
3565
|
+
} finally {
|
|
3566
|
+
clearTimeout(timer);
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
async function flushBytes(keepalive, deadline) {
|
|
3570
|
+
if (!baselineSettled()) await nextEvent("clay:baseline-settled", deadline);
|
|
3571
|
+
let saved = false;
|
|
3572
|
+
let etag = null;
|
|
3573
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
3574
|
+
if (isSaveInProgress()) await settled(deadline);
|
|
3575
|
+
if (isSaveConflicted()) throw flushError("This document has a conflict to resolve.", "conflict");
|
|
3576
|
+
const token = gateCaptureToken();
|
|
3577
|
+
if (!bytesUnsaved()) {
|
|
3578
|
+
gateClearIfUnchanged(token);
|
|
3579
|
+
if (hasUnsavedState()) throw flushError("This document has unsaved work a save cannot write.", "blocked");
|
|
3580
|
+
return { state: saved ? "saved" : "clean", etag: etag ?? lastSeenEtag() };
|
|
3581
|
+
}
|
|
3582
|
+
const result2 = await withKeepalive(keepalive, () => savePage());
|
|
3583
|
+
if (result2.msgType === "skipped") continue;
|
|
3584
|
+
if (!result2.ok) throw flushError(result2.msg || "Save failed", result2.msgType === "conflict" || isSaveConflicted() ? "conflict" : "failed");
|
|
3585
|
+
saved = true;
|
|
3586
|
+
etag = result2.etag ?? null;
|
|
3587
|
+
}
|
|
3588
|
+
throw flushError("The document kept changing while saving.", "failed");
|
|
3589
|
+
}
|
|
3369
3590
|
async function saveOverwritingConflict(callback = () => {
|
|
3370
3591
|
}) {
|
|
3371
3592
|
await seedEtag({ fresh: true });
|
|
@@ -3391,7 +3612,7 @@
|
|
|
3391
3612
|
function initBaselineCapture() {
|
|
3392
3613
|
if (!isEditMode) return;
|
|
3393
3614
|
let userEdited = false;
|
|
3394
|
-
let
|
|
3615
|
+
let settled2 = false;
|
|
3395
3616
|
let unsubscribeMutation = null;
|
|
3396
3617
|
const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
|
|
3397
3618
|
lastSavedContents = immediateContents;
|
|
@@ -3406,8 +3627,8 @@
|
|
|
3406
3627
|
};
|
|
3407
3628
|
userEditEvents.forEach((evt) => document.addEventListener(evt, markUserEdited, true));
|
|
3408
3629
|
const captureBaseline = () => {
|
|
3409
|
-
if (
|
|
3410
|
-
|
|
3630
|
+
if (settled2) return;
|
|
3631
|
+
settled2 = true;
|
|
3411
3632
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
3412
3633
|
userEditEvents.forEach((evt) => document.removeEventListener(evt, markUserEdited, true));
|
|
3413
3634
|
if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
|
|
@@ -3431,7 +3652,7 @@
|
|
|
3431
3652
|
captureBaseline
|
|
3432
3653
|
);
|
|
3433
3654
|
setTimeout(() => {
|
|
3434
|
-
if (!
|
|
3655
|
+
if (!settled2) captureBaseline();
|
|
3435
3656
|
}, MAX_SETTLE_MS);
|
|
3436
3657
|
}
|
|
3437
3658
|
function savePageThrottled(callback = () => {
|
|
@@ -3508,10 +3729,12 @@
|
|
|
3508
3729
|
init_snapshot();
|
|
3509
3730
|
init_etag();
|
|
3510
3731
|
init_dirty_gate();
|
|
3732
|
+
init_unsaved_state();
|
|
3511
3733
|
init_autosave_state();
|
|
3512
3734
|
init_root_attrs();
|
|
3513
3735
|
init_autosave_debug();
|
|
3514
3736
|
init_user_gesture();
|
|
3737
|
+
init_unsaved_warning();
|
|
3515
3738
|
addDocumentTransform((clone) => {
|
|
3516
3739
|
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
3517
3740
|
});
|
|
@@ -3965,98 +4188,6 @@
|
|
|
3965
4188
|
}
|
|
3966
4189
|
});
|
|
3967
4190
|
|
|
3968
|
-
// src/lib/unsaved-state.js
|
|
3969
|
-
function registerUnsavedState({ id = "", isPending: isPending2 }) {
|
|
3970
|
-
const key2 = ++nextKey;
|
|
3971
|
-
sources.set(key2, { id, isPending: isPending2 });
|
|
3972
|
-
let disposed = false;
|
|
3973
|
-
return {
|
|
3974
|
-
changed() {
|
|
3975
|
-
if (disposed) return;
|
|
3976
|
-
document.dispatchEvent(new CustomEvent("clay:unsaved-state-changed", {
|
|
3977
|
-
detail: { pending: pendingIds() }
|
|
3978
|
-
}));
|
|
3979
|
-
},
|
|
3980
|
-
dispose() {
|
|
3981
|
-
if (disposed) return;
|
|
3982
|
-
disposed = true;
|
|
3983
|
-
sources.delete(key2);
|
|
3984
|
-
}
|
|
3985
|
-
};
|
|
3986
|
-
}
|
|
3987
|
-
function isPending(source2) {
|
|
3988
|
-
try {
|
|
3989
|
-
return !!source2.isPending();
|
|
3990
|
-
} catch (err) {
|
|
3991
|
-
console.error(`[clay] unsaved-state check "${source2.id}" threw`, err);
|
|
3992
|
-
return true;
|
|
3993
|
-
}
|
|
3994
|
-
}
|
|
3995
|
-
function pendingIds() {
|
|
3996
|
-
return [...sources.values()].filter(isPending).map((s2) => s2.id);
|
|
3997
|
-
}
|
|
3998
|
-
function hasUnsavedState() {
|
|
3999
|
-
for (const source2 of sources.values()) if (isPending(source2)) return true;
|
|
4000
|
-
return false;
|
|
4001
|
-
}
|
|
4002
|
-
var sources, nextKey;
|
|
4003
|
-
var init_unsaved_state = __esm({
|
|
4004
|
-
"src/lib/unsaved-state.js"() {
|
|
4005
|
-
sources = /* @__PURE__ */ new Map();
|
|
4006
|
-
nextKey = 0;
|
|
4007
|
-
if (typeof window !== "undefined") {
|
|
4008
|
-
window.clay = window.clay || {};
|
|
4009
|
-
window.clay.registerUnsavedState = registerUnsavedState;
|
|
4010
|
-
}
|
|
4011
|
-
}
|
|
4012
|
-
});
|
|
4013
|
-
|
|
4014
|
-
// src/core/unsaved-warning.js
|
|
4015
|
-
var unsaved_warning_exports = {};
|
|
4016
|
-
__export(unsaved_warning_exports, {
|
|
4017
|
-
reloadAfterDiscard: () => reloadAfterDiscard
|
|
4018
|
-
});
|
|
4019
|
-
function reloadAfterDiscard({ isCurrent, reload }) {
|
|
4020
|
-
if (!isCurrent()) return false;
|
|
4021
|
-
discardPermit = true;
|
|
4022
|
-
setTimeout(() => {
|
|
4023
|
-
discardPermit = false;
|
|
4024
|
-
}, 1e3);
|
|
4025
|
-
reload();
|
|
4026
|
-
return true;
|
|
4027
|
-
}
|
|
4028
|
-
var discardPermit;
|
|
4029
|
-
var init_unsaved_warning = __esm({
|
|
4030
|
-
"src/core/unsaved-warning.js"() {
|
|
4031
|
-
init_is_edit_mode();
|
|
4032
|
-
init_snapshot();
|
|
4033
|
-
init_save();
|
|
4034
|
-
init_autosave_debug();
|
|
4035
|
-
init_unsaved_state();
|
|
4036
|
-
discardPermit = false;
|
|
4037
|
-
preloadIfEnabled();
|
|
4038
|
-
window.addEventListener("beforeunload", (event) => {
|
|
4039
|
-
if (!isEditMode) return;
|
|
4040
|
-
if (discardPermit) {
|
|
4041
|
-
discardPermit = false;
|
|
4042
|
-
return;
|
|
4043
|
-
}
|
|
4044
|
-
const held = hasUnsavedState();
|
|
4045
|
-
let bytesDiffer = false;
|
|
4046
|
-
if (!held && !window.clay?.demo) {
|
|
4047
|
-
const currentForCompare = captureForDirtyCheck();
|
|
4048
|
-
const lastSaved = getLastSavedDirty();
|
|
4049
|
-
bytesDiffer = currentForCompare !== lastSaved;
|
|
4050
|
-
if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
4051
|
-
}
|
|
4052
|
-
if (held || bytesDiffer) {
|
|
4053
|
-
event.preventDefault();
|
|
4054
|
-
event.returnValue = "";
|
|
4055
|
-
}
|
|
4056
|
-
});
|
|
4057
|
-
}
|
|
4058
|
-
});
|
|
4059
|
-
|
|
4060
4191
|
// src/vendor/hyper-morph.vendor.js
|
|
4061
4192
|
var hyper_morph_vendor_exports = {};
|
|
4062
4193
|
__export(hyper_morph_vendor_exports, {
|
|
@@ -10603,10 +10734,10 @@
|
|
|
10603
10734
|
}
|
|
10604
10735
|
_flushQueuedSend() {
|
|
10605
10736
|
if (this.isDestroyed || this._sendInFlight) return;
|
|
10606
|
-
const
|
|
10607
|
-
if (!
|
|
10737
|
+
const queued2 = this._queuedSend;
|
|
10738
|
+
if (!queued2) return;
|
|
10608
10739
|
this._queuedSend = null;
|
|
10609
|
-
this._postUpdate(
|
|
10740
|
+
this._postUpdate(queued2.html, queued2.identityMap, queued2.etag);
|
|
10610
10741
|
}
|
|
10611
10742
|
/**
|
|
10612
10743
|
* Tell the other editors what this tab's save stored, and under which stamp.
|
|
@@ -10660,10 +10791,10 @@
|
|
|
10660
10791
|
retry = true;
|
|
10661
10792
|
}).finally(() => {
|
|
10662
10793
|
this._sendInFlight = false;
|
|
10663
|
-
const
|
|
10794
|
+
const queued2 = this._queuedSend;
|
|
10664
10795
|
this._queuedSend = null;
|
|
10665
|
-
if (
|
|
10666
|
-
this._postUpdate(
|
|
10796
|
+
if (queued2) {
|
|
10797
|
+
this._postUpdate(queued2.html, queued2.identityMap, queued2.etag);
|
|
10667
10798
|
return;
|
|
10668
10799
|
}
|
|
10669
10800
|
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
@@ -25244,10 +25375,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
25244
25375
|
);
|
|
25245
25376
|
return canvas;
|
|
25246
25377
|
}
|
|
25247
|
-
let
|
|
25378
|
+
let settled2 = false;
|
|
25248
25379
|
const finishOnce = () => {
|
|
25249
|
-
if (
|
|
25250
|
-
|
|
25380
|
+
if (settled2) return false;
|
|
25381
|
+
settled2 = true;
|
|
25251
25382
|
host.close();
|
|
25252
25383
|
URL.revokeObjectURL(url);
|
|
25253
25384
|
active2 = false;
|
|
@@ -25257,7 +25388,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
25257
25388
|
content: stage,
|
|
25258
25389
|
confirmLabel,
|
|
25259
25390
|
onConfirm() {
|
|
25260
|
-
if (
|
|
25391
|
+
if (settled2) return;
|
|
25261
25392
|
let canvas, dataURL;
|
|
25262
25393
|
try {
|
|
25263
25394
|
canvas = renderCrop();
|
|
@@ -25606,10 +25737,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
25606
25737
|
const path = token ? `${UPLOAD_PATH}/${token}` : UPLOAD_PATH;
|
|
25607
25738
|
const url = new URL(path, window.location.origin).href;
|
|
25608
25739
|
const xhr = new XMLHttpRequest();
|
|
25609
|
-
let
|
|
25740
|
+
let settled2 = false;
|
|
25610
25741
|
const finish2 = (value) => {
|
|
25611
|
-
if (
|
|
25612
|
-
|
|
25742
|
+
if (settled2) return;
|
|
25743
|
+
settled2 = true;
|
|
25613
25744
|
clearTimeout(timeoutId);
|
|
25614
25745
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
25615
25746
|
resolve(value);
|
|
@@ -25779,10 +25910,10 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
25779
25910
|
stream.onerror = () => {
|
|
25780
25911
|
};
|
|
25781
25912
|
streamReady = new Promise((resolve) => {
|
|
25782
|
-
let
|
|
25913
|
+
let settled2 = false;
|
|
25783
25914
|
const finish2 = () => {
|
|
25784
|
-
if (
|
|
25785
|
-
|
|
25915
|
+
if (settled2) return;
|
|
25916
|
+
settled2 = true;
|
|
25786
25917
|
resolve();
|
|
25787
25918
|
};
|
|
25788
25919
|
stream.onopen = finish2;
|
|
@@ -25957,7 +26088,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
25957
26088
|
setTimeout(settle3, timeout);
|
|
25958
26089
|
});
|
|
25959
26090
|
}
|
|
25960
|
-
async function
|
|
26091
|
+
async function flushSave2() {
|
|
25961
26092
|
if (!isEditMode) return;
|
|
25962
26093
|
const clay4 = window.clay;
|
|
25963
26094
|
if (!clay4 || typeof clay4.save !== "function") return;
|
|
@@ -26076,7 +26207,7 @@ tr.hcms-shell.hcms-inline-ghost { display: table-row; }
|
|
|
26076
26207
|
if (rec.document === "edit") {
|
|
26077
26208
|
await holdSaving(rec);
|
|
26078
26209
|
if (rec.state !== "sent") return;
|
|
26079
|
-
await
|
|
26210
|
+
await flushSave2();
|
|
26080
26211
|
if (rec.state !== "sent") return;
|
|
26081
26212
|
}
|
|
26082
26213
|
await openStream();
|
|
@@ -36080,10 +36211,10 @@ ${t}` : `"use strict"; return (${t});`;
|
|
|
36080
36211
|
onYes = [];
|
|
36081
36212
|
onNo = [];
|
|
36082
36213
|
onOpen = [];
|
|
36083
|
-
let
|
|
36214
|
+
let settled2 = false;
|
|
36084
36215
|
const dismiss3 = () => {
|
|
36085
|
-
if (
|
|
36086
|
-
|
|
36216
|
+
if (settled2) return;
|
|
36217
|
+
settled2 = true;
|
|
36087
36218
|
myOnNo.forEach((cb) => cb());
|
|
36088
36219
|
};
|
|
36089
36220
|
if (this.isShowing || document.querySelector("[data-clay-modal]")) {
|
|
@@ -36149,7 +36280,7 @@ ${t}` : `"use strict"; return (${t});`;
|
|
|
36149
36280
|
}
|
|
36150
36281
|
}
|
|
36151
36282
|
if (shouldClose) {
|
|
36152
|
-
|
|
36283
|
+
settled2 = true;
|
|
36153
36284
|
MicroModal.close();
|
|
36154
36285
|
}
|
|
36155
36286
|
}
|
|
@@ -37110,8 +37241,10 @@ ${t}` : `"use strict"; return (${t});`;
|
|
|
37110
37241
|
const saveFn = save.savePage || save.default;
|
|
37111
37242
|
saveFn.force = save.savePageForce;
|
|
37112
37243
|
saveFn.overwrite = save.saveOverwritingConflict;
|
|
37244
|
+
saveFn.flush = save.flushSave;
|
|
37113
37245
|
clay4.save = saveFn;
|
|
37114
37246
|
}
|
|
37247
|
+
clay4.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
|
|
37115
37248
|
if (snapshot) {
|
|
37116
37249
|
clay4.getHTML = snapshot.getPageContents;
|
|
37117
37250
|
clay4.addDocumentTransform = snapshot.addDocumentTransform;
|
package/package.json
CHANGED
package/src/core/save-core.js
CHANGED
|
@@ -25,6 +25,12 @@ import {
|
|
|
25
25
|
let saveInProgress = false;
|
|
26
26
|
const SAVE_PATH = '/_/save';
|
|
27
27
|
const SAVE_TIMEOUT_MS = 12000;
|
|
28
|
+
// The browser caps a keepalive body (Chromium refuses one over 64 KB), and it counts
|
|
29
|
+
// BYTES: the hint is only attached to a save whose UTF-8 encoding fits well inside that.
|
|
30
|
+
const KEEPALIVE_MAX_BODY = 60000;
|
|
31
|
+
// Set by `withKeepalive`, read once by the next `sendOnce`: the hint belongs to one
|
|
32
|
+
// request, not to the save pipeline.
|
|
33
|
+
let nextSaveKeepalive = false;
|
|
28
34
|
|
|
29
35
|
// Listeners for "the host took these exact bytes". This is the only place in the
|
|
30
36
|
// library that knows both the body that went out and that the host accepted it, and
|
|
@@ -37,6 +43,26 @@ const SAVE_TIMEOUT_MS = 12000;
|
|
|
37
43
|
// CustomEvent would put it on a bus any script on the page can listen to.
|
|
38
44
|
const saveAcceptedHooks = [];
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Send the next save with `keepalive`, so a request whose page is going away (a tab
|
|
48
|
+
* closing, a navigation) is still finished by the transport.
|
|
49
|
+
*
|
|
50
|
+
* A transport hint and nothing more: the save is the same save, and the body has to
|
|
51
|
+
* fit the browser's keepalive cap for the hint to go on the wire at all.
|
|
52
|
+
*
|
|
53
|
+
* @param {boolean} keepalive
|
|
54
|
+
* @param {Function} run - the save to run with the hint set
|
|
55
|
+
* @returns {Promise<Object>} whatever `run` resolves with
|
|
56
|
+
*/
|
|
57
|
+
export async function withKeepalive(keepalive, run) {
|
|
58
|
+
nextSaveKeepalive = Boolean(keepalive);
|
|
59
|
+
try {
|
|
60
|
+
return await run();
|
|
61
|
+
} finally {
|
|
62
|
+
nextSaveKeepalive = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Check if a save is currently in progress.
|
|
42
68
|
* @returns {boolean}
|
|
@@ -258,9 +284,10 @@ function skippedResult(msg) {
|
|
|
258
284
|
* @param {AbortSignal} signal
|
|
259
285
|
* @param {string} saveId - this attempt's §6 receipt id
|
|
260
286
|
* @param {?string} etag - the stamp to send as If-Match, or null
|
|
287
|
+
* @param {boolean} keepalive - whether to ask the transport to see the request through
|
|
261
288
|
* @returns {{url: string, options: Object}}
|
|
262
289
|
*/
|
|
263
|
-
function buildSaveRequest(content, userDriven, signal, saveId, etag) {
|
|
290
|
+
function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
|
|
264
291
|
const token = saveToken();
|
|
265
292
|
const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
|
|
266
293
|
const options = {
|
|
@@ -287,6 +314,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
|
|
|
287
314
|
|
|
288
315
|
if (etag) options.headers['If-Match'] = etag;
|
|
289
316
|
|
|
317
|
+
if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
|
|
318
|
+
|
|
290
319
|
return { url: resolveSaveUrl(path), options };
|
|
291
320
|
}
|
|
292
321
|
|
|
@@ -357,7 +386,11 @@ function sendOnce(content, saveId, etag) {
|
|
|
357
386
|
const gestureDriven = consumeUserDriven();
|
|
358
387
|
const explicitlyAsked = consumeExplicitSave();
|
|
359
388
|
const userDriven = gestureDriven || explicitlyAsked;
|
|
360
|
-
|
|
389
|
+
// Read-and-reset here too, at the one send that can still honour it: a save that
|
|
390
|
+
// returns early never reaches this point, so the hint cannot leak onto a later one.
|
|
391
|
+
const keepalive = nextSaveKeepalive;
|
|
392
|
+
nextSaveKeepalive = false;
|
|
393
|
+
const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
|
|
361
394
|
rememberSentId(saveId);
|
|
362
395
|
|
|
363
396
|
return fetch(url, options)
|
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
|
//
|
|
@@ -560,6 +567,101 @@ export function savePageForce(callback = () => {}) {
|
|
|
560
567
|
});
|
|
561
568
|
}
|
|
562
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
|
+
|
|
563
665
|
/**
|
|
564
666
|
* Keep this tab's version, over the one the host is holding.
|
|
565
667
|
*
|
|
@@ -43,6 +43,39 @@ preloadIfEnabled();
|
|
|
43
43
|
// sandboxed document, which cannot read cookies at all. Those are exactly the
|
|
44
44
|
// documents where an unsaved edit is easiest to lose. If the page is editable,
|
|
45
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
|
+
|
|
46
79
|
window.addEventListener('beforeunload', (event) => {
|
|
47
80
|
if (!isEditMode) return;
|
|
48
81
|
|
|
@@ -51,26 +84,16 @@ window.addEventListener('beforeunload', (event) => {
|
|
|
51
84
|
return;
|
|
52
85
|
}
|
|
53
86
|
|
|
54
|
-
|
|
55
|
-
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
56
|
-
const held = hasUnsavedState();
|
|
57
|
-
|
|
58
|
-
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
59
|
-
// leaving loses none of them.
|
|
60
|
-
let bytesDiffer = false;
|
|
61
|
-
if (!held && !window.clay?.demo) {
|
|
62
|
-
// The DIRTY domain, not the autosave domain. An edit inside a
|
|
63
|
-
// no-trigger-autosave region never starts a save by itself, which is exactly
|
|
64
|
-
// why closing the tab on one has to warn: nothing else is going to write it.
|
|
65
|
-
const currentForCompare = captureForDirtyCheck();
|
|
66
|
-
const lastSaved = getLastSavedDirty();
|
|
67
|
-
bytesDiffer = currentForCompare !== lastSaved;
|
|
68
|
-
// Debug: log what's different before showing the warning
|
|
69
|
-
if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
70
|
-
}
|
|
87
|
+
if (!hasUnsavedChanges()) return;
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 {}
|
|
75
98
|
}
|
|
76
99
|
});
|
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,6 +212,7 @@ 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) {
|
package/src/loader.js
CHANGED
|
@@ -88,8 +88,14 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
88
88
|
const saveFn = save.savePage || save.default;
|
|
89
89
|
saveFn.force = save.savePageForce;
|
|
90
90
|
saveFn.overwrite = save.saveOverwritingConflict;
|
|
91
|
+
saveFn.flush = save.flushSave;
|
|
91
92
|
clay.save = saveFn;
|
|
92
93
|
}
|
|
94
|
+
// The close warning's own predicate, reachable without a capture. It falls back to
|
|
95
|
+
// false rather than to absent: the module is edit-only, and a document that cannot
|
|
96
|
+
// save has nothing unsaved. Older clayjs has no such member, and that is the answer
|
|
97
|
+
// a host feature-detects.
|
|
98
|
+
clay.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
|
|
93
99
|
if (snapshot) {
|
|
94
100
|
clay.getHTML = snapshot.getPageContents;
|
|
95
101
|
clay.addDocumentTransform = snapshot.addDocumentTransform;
|