@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/clay.js +21 -0
  4. package/package.json +27 -0
  5. package/src/attrs/onaftersave.js +38 -0
  6. package/src/attrs/refetch-on-save.js +36 -0
  7. package/src/attrs/save-freeze.js +102 -0
  8. package/src/core/admin-attrs.js +22 -0
  9. package/src/core/admin-contenteditable.js +47 -0
  10. package/src/core/admin-inputs.js +48 -0
  11. package/src/core/admin-onclick.js +50 -0
  12. package/src/core/admin-resources.js +49 -0
  13. package/src/core/autosave.js +61 -0
  14. package/src/core/edit-mode.js +38 -0
  15. package/src/core/is-edit-mode.js +30 -0
  16. package/src/core/persist.js +103 -0
  17. package/src/core/save-core.js +385 -0
  18. package/src/core/save.js +475 -0
  19. package/src/core/snapshot.js +282 -0
  20. package/src/core/unsaved-warning.js +38 -0
  21. package/src/lib/autosave-debug.js +223 -0
  22. package/src/lib/cache-bust.js +12 -0
  23. package/src/lib/cookie.js +37 -0
  24. package/src/lib/dom-ready.js +9 -0
  25. package/src/lib/extension-noise.js +63 -0
  26. package/src/lib/load-vendor-script.js +57 -0
  27. package/src/lib/mutation.js +719 -0
  28. package/src/lib/query.js +3 -0
  29. package/src/lib/region-policy.js +220 -0
  30. package/src/lib/throttle.js +41 -0
  31. package/src/lib/user-gesture.js +126 -0
  32. package/src/loader-logic.js +62 -0
  33. package/src/loader.js +123 -0
  34. package/src/plugins/indicator.js +51 -0
  35. package/src/plugins/sortable.js +119 -0
  36. package/src/plugins/undo.js +23 -0
  37. package/src/sync/live-sync.js +752 -0
  38. package/src/vendor/Sortable.vendor.js +2 -0
  39. package/src/vendor/control-serialize.vendor.js +88 -0
  40. package/src/vendor/hyper-morph.vendor.js +22 -0
  41. package/src/vendor/hyper-undo.vendor.js +11 -0
  42. package/src/vendor/hypercms.vendor.js +1751 -0
  43. package/src/vendor/richclay.vendor.js +456 -0
@@ -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;