@panphora/clayjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/clay.js +21 -0
- package/package.json +27 -0
- package/src/attrs/onaftersave.js +38 -0
- package/src/attrs/refetch-on-save.js +36 -0
- package/src/attrs/save-freeze.js +102 -0
- package/src/core/admin-attrs.js +22 -0
- package/src/core/admin-contenteditable.js +47 -0
- package/src/core/admin-inputs.js +48 -0
- package/src/core/admin-onclick.js +50 -0
- package/src/core/admin-resources.js +49 -0
- package/src/core/autosave.js +61 -0
- package/src/core/edit-mode.js +38 -0
- package/src/core/is-edit-mode.js +30 -0
- package/src/core/persist.js +103 -0
- package/src/core/save-core.js +385 -0
- package/src/core/save.js +475 -0
- package/src/core/snapshot.js +282 -0
- package/src/core/unsaved-warning.js +38 -0
- package/src/lib/autosave-debug.js +223 -0
- package/src/lib/cache-bust.js +12 -0
- package/src/lib/cookie.js +37 -0
- package/src/lib/dom-ready.js +9 -0
- package/src/lib/extension-noise.js +63 -0
- package/src/lib/load-vendor-script.js +57 -0
- package/src/lib/mutation.js +719 -0
- package/src/lib/query.js +3 -0
- package/src/lib/region-policy.js +220 -0
- package/src/lib/throttle.js +41 -0
- package/src/lib/user-gesture.js +126 -0
- package/src/loader-logic.js +62 -0
- package/src/loader.js +123 -0
- package/src/plugins/indicator.js +51 -0
- package/src/plugins/sortable.js +119 -0
- package/src/plugins/undo.js +23 -0
- package/src/sync/live-sync.js +752 -0
- package/src/vendor/Sortable.vendor.js +2 -0
- package/src/vendor/control-serialize.vendor.js +88 -0
- package/src/vendor/hyper-morph.vendor.js +22 -0
- package/src/vendor/hyper-undo.vendor.js +11 -0
- package/src/vendor/hypercms.vendor.js +1751 -0
- package/src/vendor/richclay.vendor.js +456 -0
package/src/core/save.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save system for clayjs
|
|
3
|
+
*
|
|
4
|
+
* Manual save with change detection, state management,
|
|
5
|
+
* keyboard shortcuts, and save button support.
|
|
6
|
+
*
|
|
7
|
+
* For auto-save on DOM changes, also load the 'autosave' module.
|
|
8
|
+
*
|
|
9
|
+
* Built on top of save-core.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import throttle from "../lib/throttle.js";
|
|
13
|
+
import Mutation from "../lib/mutation.js";
|
|
14
|
+
import { isEditMode, isOwner } from "./is-edit-mode.js";
|
|
15
|
+
import {
|
|
16
|
+
saveHtml,
|
|
17
|
+
getPageContents,
|
|
18
|
+
replacePageWith as replacePageWithCore,
|
|
19
|
+
beforeSave,
|
|
20
|
+
isSaveInProgress
|
|
21
|
+
} from "./save-core.js";
|
|
22
|
+
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
23
|
+
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
24
|
+
|
|
25
|
+
// Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
|
|
26
|
+
beforeSave(clone => {
|
|
27
|
+
clone.setAttribute('savestatus', 'saved');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ============================================
|
|
31
|
+
// SAVE STATE MANAGEMENT
|
|
32
|
+
// ============================================
|
|
33
|
+
|
|
34
|
+
let savingTimeout = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sets the save status on <html> and dispatches an event.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
|
|
40
|
+
* @param {string} msg - Optional message (e.g., error details)
|
|
41
|
+
*/
|
|
42
|
+
function setSaveState(state, msg = '') {
|
|
43
|
+
if (savingTimeout) {
|
|
44
|
+
clearTimeout(savingTimeout);
|
|
45
|
+
savingTimeout = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
document.documentElement.setAttribute('savestatus', state);
|
|
49
|
+
|
|
50
|
+
const event = new CustomEvent(`clay:save-${state}`, {
|
|
51
|
+
detail: { msg, timestamp: Date.now() }
|
|
52
|
+
});
|
|
53
|
+
document.dispatchEvent(event);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sets DOM state to 'offline' immediately, but does NOT fire an event.
|
|
58
|
+
* Used for instant UI feedback before we know the final state.
|
|
59
|
+
*/
|
|
60
|
+
function setOfflineStateQuiet() {
|
|
61
|
+
if (savingTimeout) {
|
|
62
|
+
clearTimeout(savingTimeout);
|
|
63
|
+
savingTimeout = null;
|
|
64
|
+
}
|
|
65
|
+
document.documentElement.setAttribute('savestatus', 'offline');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Starts a debounced 'saving' state.
|
|
70
|
+
* Only shows 'saving' if the save takes longer than 500ms.
|
|
71
|
+
* This prevents UI flicker on fast saves.
|
|
72
|
+
*/
|
|
73
|
+
function setSavingState() {
|
|
74
|
+
savingTimeout = setTimeout(() => {
|
|
75
|
+
setSaveState('saving');
|
|
76
|
+
}, 500);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// OFFLINE DETECTION
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
window.addEventListener('offline', () => {
|
|
84
|
+
setOfflineStateQuiet();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
window.addEventListener('online', () => {
|
|
88
|
+
if (document.documentElement.getAttribute('savestatus') === 'offline') {
|
|
89
|
+
savePage();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ============================================
|
|
94
|
+
// POST-SAVE BASELINE RECAPTURE
|
|
95
|
+
// ============================================
|
|
96
|
+
// After a successful save, onaftersave handlers may modify the live DOM
|
|
97
|
+
// (e.g., cacheBust updates ?v= query params). We recapture the baseline
|
|
98
|
+
// after these sync handlers complete to prevent false "unsaved changes" warnings.
|
|
99
|
+
|
|
100
|
+
document.addEventListener('clay:save-saved', () => {
|
|
101
|
+
// Use setTimeout(0) to run after all sync onaftersave handlers complete
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
104
|
+
const contents = captureForComparison();
|
|
105
|
+
lastSavedContents = contents;
|
|
106
|
+
logBaseline('recaptured after onaftersave', `${contents.length} chars`);
|
|
107
|
+
}, 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Re-export from core for backward compatibility
|
|
111
|
+
export { beforeSave, getPageContents };
|
|
112
|
+
|
|
113
|
+
let unsavedChanges = false;
|
|
114
|
+
let lastSavedContents = '';
|
|
115
|
+
|
|
116
|
+
// State accessors for autosave module
|
|
117
|
+
export function getUnsavedChanges() { return unsavedChanges; }
|
|
118
|
+
export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
119
|
+
export function getLastSavedContents() { return lastSavedContents; }
|
|
120
|
+
export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Save the current page with change detection and state management.
|
|
124
|
+
*
|
|
125
|
+
* Returns a Promise that resolves with {msg, msgType} — the same object
|
|
126
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
127
|
+
* msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
|
|
128
|
+
*
|
|
129
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
130
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
131
|
+
*/
|
|
132
|
+
export function savePage(callback = () => {}) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
if (!isEditMode && !window.clay?.testMode) {
|
|
135
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
136
|
+
callback(skipped);
|
|
137
|
+
return resolve(skipped);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Don't start a new save if one is already in progress
|
|
141
|
+
if (isSaveInProgress()) {
|
|
142
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
143
|
+
callback(skipped);
|
|
144
|
+
return resolve(skipped);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if offline - set DOM state immediately for UI feedback
|
|
148
|
+
// but still try the fetch (navigator.onLine can be wrong)
|
|
149
|
+
const wasOffline = !navigator.onLine;
|
|
150
|
+
if (wasOffline) {
|
|
151
|
+
setOfflineStateQuiet();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Single capture: clone once, get both versions
|
|
155
|
+
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
156
|
+
// forComparison additionally strips every autosave-off region
|
|
157
|
+
let forSave, forComparison;
|
|
158
|
+
try {
|
|
159
|
+
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
162
|
+
setSaveState('error', err.message);
|
|
163
|
+
const result = { msg: err.message, msgType: 'error' };
|
|
164
|
+
if (typeof callback === 'function') {
|
|
165
|
+
callback(result);
|
|
166
|
+
}
|
|
167
|
+
return resolve(result);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Compare directly - lastSavedContents is already stripped
|
|
171
|
+
unsavedChanges = (forComparison !== lastSavedContents);
|
|
172
|
+
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
173
|
+
|
|
174
|
+
// Skip if content hasn't changed
|
|
175
|
+
if (!unsavedChanges) {
|
|
176
|
+
const skipped = { msg: 'No changes to save', msgType: 'skipped' };
|
|
177
|
+
callback(skipped);
|
|
178
|
+
return resolve(skipped);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
182
|
+
setSavingState();
|
|
183
|
+
|
|
184
|
+
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
185
|
+
saveHtml(forSave, (err, data) => {
|
|
186
|
+
if (!err) {
|
|
187
|
+
// SUCCESS - store stripped version for future comparisons
|
|
188
|
+
lastSavedContents = forComparison;
|
|
189
|
+
unsavedChanges = false;
|
|
190
|
+
setSaveState('saved', data?.msg || 'Saved');
|
|
191
|
+
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
192
|
+
} else {
|
|
193
|
+
// FAILED - determine if it's offline or server error
|
|
194
|
+
if (!navigator.onLine) {
|
|
195
|
+
setSaveState('offline', err.message);
|
|
196
|
+
} else {
|
|
197
|
+
setSaveState('error', err.message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Call user callback if provided (preserve server's msgType)
|
|
202
|
+
const result = {
|
|
203
|
+
msg: err?.message || data?.msg,
|
|
204
|
+
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
205
|
+
};
|
|
206
|
+
if (typeof callback === 'function') {
|
|
207
|
+
callback(result);
|
|
208
|
+
}
|
|
209
|
+
resolve(result);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Force-save the current page (skips dirty check).
|
|
216
|
+
*
|
|
217
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
218
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
219
|
+
*/
|
|
220
|
+
export function savePageForce(callback = () => {}) {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
if (!isEditMode && !window.clay?.testMode) {
|
|
223
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
224
|
+
callback(skipped);
|
|
225
|
+
return resolve(skipped);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (isSaveInProgress()) {
|
|
229
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
230
|
+
callback(skipped);
|
|
231
|
+
return resolve(skipped);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const wasOffline = !navigator.onLine;
|
|
235
|
+
if (wasOffline) {
|
|
236
|
+
setOfflineStateQuiet();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let forSave, forComparison;
|
|
240
|
+
try {
|
|
241
|
+
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
244
|
+
setSaveState('error', err.message);
|
|
245
|
+
const result = { msg: err.message, msgType: 'error' };
|
|
246
|
+
if (typeof callback === 'function') {
|
|
247
|
+
callback(result);
|
|
248
|
+
}
|
|
249
|
+
return resolve(result);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setSavingState();
|
|
253
|
+
|
|
254
|
+
saveHtml(forSave, (err, data) => {
|
|
255
|
+
if (!err) {
|
|
256
|
+
lastSavedContents = forComparison;
|
|
257
|
+
unsavedChanges = false;
|
|
258
|
+
setSaveState('saved', data?.msg || 'Saved');
|
|
259
|
+
logBaseline('updated after force save', `${lastSavedContents.length} chars`);
|
|
260
|
+
} else {
|
|
261
|
+
if (!navigator.onLine) {
|
|
262
|
+
setSaveState('offline', err.message);
|
|
263
|
+
} else {
|
|
264
|
+
setSaveState('error', err.message);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = {
|
|
269
|
+
msg: err?.message || data?.msg,
|
|
270
|
+
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
271
|
+
};
|
|
272
|
+
if (typeof callback === 'function') {
|
|
273
|
+
callback(result);
|
|
274
|
+
}
|
|
275
|
+
resolve(result);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Fetch HTML from a URL and save it, then reload
|
|
282
|
+
* Emits error event if save fails
|
|
283
|
+
*
|
|
284
|
+
* @param {string} url - URL to fetch from
|
|
285
|
+
*/
|
|
286
|
+
export function replacePageWith(url) {
|
|
287
|
+
if (!isEditMode) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
replacePageWithCore(url, (err, data) => {
|
|
292
|
+
if (err) {
|
|
293
|
+
// Emit error event (save-toast will show toast if loaded)
|
|
294
|
+
setSaveState('error', err.message || "Failed to save template");
|
|
295
|
+
} else {
|
|
296
|
+
// Only reload if save was successful
|
|
297
|
+
window.location.reload();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Throttled version of savePage for auto-save
|
|
303
|
+
const throttledSave = throttle(savePage, 1200);
|
|
304
|
+
|
|
305
|
+
// Baseline for autosave comparison
|
|
306
|
+
let baselineContents = '';
|
|
307
|
+
|
|
308
|
+
// ============================================
|
|
309
|
+
// BASELINE CAPTURE (Settled Signal)
|
|
310
|
+
// ============================================
|
|
311
|
+
//
|
|
312
|
+
// WHY SETTLED SIGNAL:
|
|
313
|
+
// Modules run on load and mutate the DOM (add styles, modify attributes).
|
|
314
|
+
// A fixed delay (e.g., 1500ms) is arbitrary and either too short (misses slow
|
|
315
|
+
// mutations) or too long (delays baseline). Instead, we wait for mutations to
|
|
316
|
+
// stop, meaning all modules have finished their setup work.
|
|
317
|
+
//
|
|
318
|
+
// WHY IMMEDIATE + CONDITIONAL UPDATE:
|
|
319
|
+
// We set baseline immediately as a safety net. If the user edits or saves
|
|
320
|
+
// before settle completes, we don't overwrite their work. The settled snapshot
|
|
321
|
+
// only replaces baseline if nothing changed (lastSavedContents === immediateContents).
|
|
322
|
+
|
|
323
|
+
const SETTLE_MS = 500; // Wait for no mutations for this long
|
|
324
|
+
const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
|
|
325
|
+
|
|
326
|
+
function initBaselineCapture() {
|
|
327
|
+
if (!isEditMode) return;
|
|
328
|
+
|
|
329
|
+
let userEdited = false;
|
|
330
|
+
let settled = false;
|
|
331
|
+
let unsubscribeMutation = null;
|
|
332
|
+
|
|
333
|
+
// Take immediate snapshot and set as baseline right away
|
|
334
|
+
// This ensures saves during settle window work correctly
|
|
335
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
336
|
+
const immediateContents = captureForComparison();
|
|
337
|
+
lastSavedContents = immediateContents;
|
|
338
|
+
baselineContents = immediateContents;
|
|
339
|
+
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
340
|
+
|
|
341
|
+
// Track user edits to avoid overwriting real changes
|
|
342
|
+
const userEditEvents = ['input', 'change', 'paste'];
|
|
343
|
+
const markUserEdited = (e) => {
|
|
344
|
+
const target = e.target;
|
|
345
|
+
const isEditable = target.isContentEditable ||
|
|
346
|
+
target.tagName === 'INPUT' ||
|
|
347
|
+
target.tagName === 'TEXTAREA' ||
|
|
348
|
+
target.tagName === 'SELECT';
|
|
349
|
+
if (isEditable) userEdited = true;
|
|
350
|
+
};
|
|
351
|
+
userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
|
|
352
|
+
|
|
353
|
+
// Called when mutations settle OR max timeout reached
|
|
354
|
+
const captureBaseline = () => {
|
|
355
|
+
if (settled) return;
|
|
356
|
+
settled = true;
|
|
357
|
+
|
|
358
|
+
// Cleanup listeners
|
|
359
|
+
if (unsubscribeMutation) unsubscribeMutation();
|
|
360
|
+
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
361
|
+
|
|
362
|
+
// Only update if no user edits AND no saves occurred during settle
|
|
363
|
+
// (if a save happened, lastSavedContents would differ from immediateContents)
|
|
364
|
+
if (!userEdited && lastSavedContents === immediateContents) {
|
|
365
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
366
|
+
const contents = captureForComparison();
|
|
367
|
+
lastSavedContents = contents;
|
|
368
|
+
baselineContents = contents;
|
|
369
|
+
logBaseline('settled capture', `${contents.length} chars`);
|
|
370
|
+
} else {
|
|
371
|
+
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Start settle observer - fires when no mutations for SETTLE_MS.
|
|
378
|
+
// require:'autosave' so churn in no-save / save-* / no-watch regions doesn't
|
|
379
|
+
// keep resetting the settle timer or count toward the baseline.
|
|
380
|
+
unsubscribeMutation = Mutation.onAnyChange(
|
|
381
|
+
{ debounce: SETTLE_MS, omitChangeDetails: true, require: 'autosave' },
|
|
382
|
+
captureBaseline
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Max timeout fallback
|
|
386
|
+
setTimeout(() => {
|
|
387
|
+
if (!settled) captureBaseline();
|
|
388
|
+
}, MAX_SETTLE_MS);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Run when DOM is ready
|
|
392
|
+
if (document.readyState === 'loading') {
|
|
393
|
+
document.addEventListener('DOMContentLoaded', initBaselineCapture);
|
|
394
|
+
} else {
|
|
395
|
+
initBaselineCapture();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Save the page with throttling, for use with auto-save.
|
|
400
|
+
* Checks both baseline and last saved content to prevent saves from initial setup.
|
|
401
|
+
*
|
|
402
|
+
* Returns a Promise resolving with {msg, msgType}. Within-throttle-window calls
|
|
403
|
+
* piggyback on the trailing-edge save and resolve with its result.
|
|
404
|
+
*
|
|
405
|
+
* @param {Function} callback - Optional callback
|
|
406
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
407
|
+
*/
|
|
408
|
+
export function savePageThrottled(callback = () => {}) {
|
|
409
|
+
if (!isEditMode) {
|
|
410
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
411
|
+
callback(skipped);
|
|
412
|
+
return Promise.resolve(skipped);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// For autosave: check both that content changed from baseline AND from last save
|
|
416
|
+
// This prevents saves from initial setup mutations
|
|
417
|
+
// Compare directly - stored versions are already stripped
|
|
418
|
+
const currentForCompare = captureForComparison();
|
|
419
|
+
const differsFromBaseline = currentForCompare !== baselineContents;
|
|
420
|
+
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
421
|
+
|
|
422
|
+
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
423
|
+
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
424
|
+
|
|
425
|
+
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
426
|
+
const skipped = { msg: 'No changes to save', msgType: 'skipped' };
|
|
427
|
+
callback(skipped);
|
|
428
|
+
return Promise.resolve(skipped);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
unsavedChanges = true;
|
|
432
|
+
return throttledSave(callback);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Initialize keyboard shortcut for save (CMD/CTRL+S)
|
|
437
|
+
*/
|
|
438
|
+
export function initSaveKeyboardShortcut() {
|
|
439
|
+
document.addEventListener("keydown", function(event) {
|
|
440
|
+
let isMac = window.navigator.platform.match("Mac");
|
|
441
|
+
let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
442
|
+
if (metaKeyPressed && event.keyCode == 83) {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
savePage();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Initialize save button handler
|
|
451
|
+
* Looks for elements with [trigger-save] attribute
|
|
452
|
+
*/
|
|
453
|
+
export function initHyperclaySaveButton() {
|
|
454
|
+
document.addEventListener("click", event => {
|
|
455
|
+
if (event.target.closest("[trigger-save]")) {
|
|
456
|
+
savePage();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Initialize the save system (keyboard shortcut and save button)
|
|
463
|
+
* For auto-save, also load the 'autosave' module
|
|
464
|
+
*/
|
|
465
|
+
export function init() {
|
|
466
|
+
if (!isEditMode) return;
|
|
467
|
+
|
|
468
|
+
initSaveKeyboardShortcut();
|
|
469
|
+
initHyperclaySaveButton();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Auto-init when module is imported
|
|
473
|
+
init();
|
|
474
|
+
|
|
475
|
+
export default savePage;
|