hyperclayjs 1.15.0 → 1.17.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 +3 -3
- package/README.md +18 -18
- package/package.json +1 -1
- package/src/communication/live-sync.js +72 -139
- package/src/core/autosave.js +14 -0
- package/src/core/savePage.js +76 -19
- package/src/core/savePageCore.js +25 -3
- package/src/core/snapshot.js +91 -2
- package/src/core/tailwindInject.js +43 -2
- package/src/core/unsavedWarning.js +15 -3
- package/src/custom-attributes/autosize.esm.js +1 -0
- package/src/custom-attributes/autosize.js +15 -12
- package/src/custom-attributes/sortable.js +1 -1
- package/src/dom-utilities/insertStyleTag.js +14 -4
- package/src/hyperclay.js +40 -5
- package/src/ui/theModal.js +1 -1
- package/src/ui/toast-hyperclay.js +14 -6
- package/src/ui/toast.js +20 -7
- package/src/utilities/autosaveDebug.js +223 -0
- package/src/utilities/loadVendorScript.js +4 -4
- package/src/utilities/mutation.js +33 -8
- package/src/vendor/hyper-morph.vendor.js +22 -0
- package/src/vendor/idiomorph.min.js +0 -20
package/LICENSE
CHANGED
|
@@ -26,10 +26,10 @@ SOFTWARE.
|
|
|
26
26
|
|
|
27
27
|
This project includes the following third-party libraries:
|
|
28
28
|
|
|
29
|
-
###
|
|
30
|
-
- Copyright (c)
|
|
29
|
+
### HyperMorph
|
|
30
|
+
- Copyright (c) Hyperclay
|
|
31
31
|
- License: 0BSD (Zero-Clause BSD)
|
|
32
|
-
- https://github.com/
|
|
32
|
+
- https://github.com/hyperclay/hyper-morph
|
|
33
33
|
|
|
34
34
|
### Sortable.js
|
|
35
35
|
- Copyright (c) All contributors to Sortable
|
package/README.md
CHANGED
|
@@ -57,17 +57,17 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
57
57
|
|
|
58
58
|
| Module | Size | Description |
|
|
59
59
|
|--------|------|-------------|
|
|
60
|
-
| autosave |
|
|
60
|
+
| autosave | 1.4KB | Auto-save on DOM changes |
|
|
61
61
|
| edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
|
|
62
62
|
| edit-mode-helpers | 7.5KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
|
|
63
63
|
| option-visibility | 7.8KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
|
|
64
64
|
| persist | 2.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
|
|
65
|
-
| save-core |
|
|
66
|
-
| save-system |
|
|
65
|
+
| save-core | 7.4KB | Basic save function only - hyperclay.savePage() |
|
|
66
|
+
| save-system | 12.1KB | CMD+S, [trigger-save] button, savestatus attribute |
|
|
67
67
|
| save-toast | 0.9KB | Toast notifications for save events |
|
|
68
|
-
| snapshot |
|
|
69
|
-
| tailwind-inject |
|
|
70
|
-
| unsaved-warning |
|
|
68
|
+
| snapshot | 10.2KB | Source of truth for page state - captures DOM snapshots for save and sync |
|
|
69
|
+
| tailwind-inject | 1.4KB | Injects tailwind CSS link with cache-bust on save |
|
|
70
|
+
| unsaved-warning | 1.3KB | Warn before leaving page with unsaved changes |
|
|
71
71
|
|
|
72
72
|
### Custom Attributes (HTML enhancements)
|
|
73
73
|
|
|
@@ -76,7 +76,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
76
76
|
| ajax-elements | 2.6KB | [ajax-form], [ajax-button] for async form submissions |
|
|
77
77
|
| dom-helpers | 6.2KB | el.nearest, el.val, el.text, el.exec, el.cycle |
|
|
78
78
|
| event-attrs | 4.6KB | [onclickaway], [onclickchildren], [onclone], [onpagemutation], [onrender] |
|
|
79
|
-
| input-helpers |
|
|
79
|
+
| input-helpers | 3.9KB | [prevent-enter], [autosize] for textareas |
|
|
80
80
|
| onaftersave | 1KB | [onaftersave] attribute - run JS when save status changes |
|
|
81
81
|
| sortable | 3.4KB | Drag-drop sorting with [sortable], lazy-loads ~118KB Sortable.js in edit mode |
|
|
82
82
|
|
|
@@ -86,7 +86,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
86
86
|
|--------|------|-------------|
|
|
87
87
|
| dialogs | 7.7KB | ask(), consent(), tell(), snippet() dialog functions |
|
|
88
88
|
| the-modal | 21KB | Full modal window creation system - window.theModal |
|
|
89
|
-
| toast |
|
|
89
|
+
| toast | 8.3KB | Success/error message notifications, toast(msg, msgType) |
|
|
90
90
|
|
|
91
91
|
### Utilities (Core utilities (often auto-included))
|
|
92
92
|
|
|
@@ -95,7 +95,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
95
95
|
| cache-bust | 0.6KB | Cache-bust href/src attributes |
|
|
96
96
|
| cookie | 1.4KB | Cookie management (often auto-included) |
|
|
97
97
|
| debounce | 0.4KB | Function debouncing |
|
|
98
|
-
| mutation |
|
|
98
|
+
| mutation | 13.5KB | DOM mutation observation (often auto-included) |
|
|
99
99
|
| nearest | 3.4KB | Find nearest elements (often auto-included) |
|
|
100
100
|
| throttle | 0.8KB | Function throttling |
|
|
101
101
|
|
|
@@ -106,7 +106,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
106
106
|
| all-js | 14.4KB | Full DOM manipulation library |
|
|
107
107
|
| dom-ready | 0.4KB | DOM ready callback |
|
|
108
108
|
| form-data | 2KB | Extract form data as an object |
|
|
109
|
-
| style-injection |
|
|
109
|
+
| style-injection | 4.2KB | Dynamic stylesheet injection |
|
|
110
110
|
|
|
111
111
|
### String Utilities (String manipulation helpers)
|
|
112
112
|
|
|
@@ -121,28 +121,28 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
121
121
|
| Module | Size | Description |
|
|
122
122
|
|--------|------|-------------|
|
|
123
123
|
| file-upload | 10.7KB | File upload with progress |
|
|
124
|
-
| live-sync |
|
|
124
|
+
| live-sync | 10.4KB | Real-time DOM sync across browsers |
|
|
125
125
|
| send-message | 1.3KB | Message sending utility |
|
|
126
126
|
|
|
127
127
|
### Vendor Libraries (Third-party libraries)
|
|
128
128
|
|
|
129
129
|
| Module | Size | Description |
|
|
130
130
|
|--------|------|-------------|
|
|
131
|
-
|
|
|
131
|
+
| hyper-morph | 17.2KB | DOM morphing with content-based element matching |
|
|
132
132
|
|
|
133
133
|
## Presets
|
|
134
134
|
|
|
135
|
-
### Minimal (~
|
|
135
|
+
### Minimal (~46.8KB)
|
|
136
136
|
Essential features for basic editing
|
|
137
137
|
|
|
138
138
|
**Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
|
|
139
139
|
|
|
140
|
-
### Standard (~
|
|
140
|
+
### Standard (~69.1KB)
|
|
141
141
|
Standard feature set for most use cases
|
|
142
142
|
|
|
143
143
|
**Modules:** `save-core`, `snapshot`, `save-system`, `unsaved-warning`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
|
|
144
144
|
|
|
145
|
-
### Everything (~
|
|
145
|
+
### Everything (~201.8KB)
|
|
146
146
|
All available features
|
|
147
147
|
|
|
148
148
|
Includes all available modules across all categories.
|
|
@@ -157,9 +157,9 @@ Some modules with large vendor dependencies are **lazy-loaded** to optimize page
|
|
|
157
157
|
|
|
158
158
|
**How it works:**
|
|
159
159
|
- The wrapper module checks if the page is in edit mode (`isEditMode`)
|
|
160
|
-
- If true, it injects a `<script save-
|
|
160
|
+
- If true, it injects a `<script save-remove>` tag that loads the vendor script
|
|
161
161
|
- If false, nothing is loaded - viewers don't download the heavy scripts
|
|
162
|
-
- The `save-
|
|
162
|
+
- The `save-remove` attribute strips the script tag when the page is saved
|
|
163
163
|
|
|
164
164
|
This means:
|
|
165
165
|
- **Editors** get full functionality when needed
|
|
@@ -371,7 +371,7 @@ MIT © Hyperclay
|
|
|
371
371
|
|
|
372
372
|
This project includes the following open-source libraries:
|
|
373
373
|
|
|
374
|
-
- **[
|
|
374
|
+
- **[HyperMorph](https://github.com/hyperclay/hyper-morph)** - DOM morphing with content-based element matching (0BSD)
|
|
375
375
|
- **[Sortable.js](https://github.com/SortableJS/Sortable)** - Drag-and-drop library (MIT)
|
|
376
376
|
|
|
377
377
|
## Links
|
package/package.json
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ┌─────────────────────────────────────────────────────────┐
|
|
7
7
|
* │ 1. LISTEN snapshot-ready event from save │
|
|
8
|
-
* │ (
|
|
8
|
+
* │ (full document with form values) │
|
|
9
9
|
* └─────────────────────────────────────────────────────────┘
|
|
10
10
|
* │
|
|
11
11
|
* ▼
|
|
12
12
|
* ┌─────────────────────────────────────────────────────────┐
|
|
13
|
-
* │ 2. SEND POST
|
|
13
|
+
* │ 2. SEND POST html to /live-sync/save │
|
|
14
14
|
* │ (debounced, skip if unchanged) │
|
|
15
15
|
* └─────────────────────────────────────────────────────────┘
|
|
16
16
|
* │
|
|
@@ -22,25 +22,27 @@
|
|
|
22
22
|
* │
|
|
23
23
|
* ▼
|
|
24
24
|
* ┌─────────────────────────────────────────────────────────┐
|
|
25
|
-
* │ 4. MORPH
|
|
25
|
+
* │ 4. MORPH HyperMorph full documentElement │
|
|
26
26
|
* │ (preserves focus, input values) │
|
|
27
27
|
* └─────────────────────────────────────────────────────────┘
|
|
28
28
|
*
|
|
29
|
-
* DEPENDS ON: Idiomorph (for intelligent DOM morphing)
|
|
30
29
|
* INTEGRATES WITH: snapshot.js (receives snapshot-ready events)
|
|
31
30
|
*/
|
|
32
31
|
|
|
32
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
33
|
+
import Mutation from "../utilities/mutation.js";
|
|
34
|
+
|
|
33
35
|
class LiveSync {
|
|
34
36
|
constructor() {
|
|
35
37
|
this.sse = null;
|
|
36
38
|
this.currentFile = null;
|
|
37
|
-
this.
|
|
38
|
-
this.lastBodyHtml = null;
|
|
39
|
+
this.lastHtml = null;
|
|
39
40
|
this.clientId = this.generateClientId();
|
|
40
41
|
this.debounceMs = 150;
|
|
41
42
|
this.debounceTimer = null;
|
|
42
43
|
this.isPaused = false;
|
|
43
44
|
this.isDestroyed = false;
|
|
45
|
+
this.debug = false;
|
|
44
46
|
|
|
45
47
|
// Store handler reference for cleanup
|
|
46
48
|
this._snapshotHandler = null;
|
|
@@ -52,22 +54,33 @@ class LiveSync {
|
|
|
52
54
|
this.onError = null;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
_log(message, data = null) {
|
|
58
|
+
if (!this.debug) return;
|
|
59
|
+
const prefix = `[LiveSync ${new Date().toISOString()}]`;
|
|
60
|
+
if (data !== null) {
|
|
61
|
+
console.log(prefix, message, data);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(prefix, message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
/**
|
|
56
|
-
* Generate or retrieve a
|
|
68
|
+
* Generate or retrieve a tab-specific client ID
|
|
69
|
+
* Uses sessionStorage (unique per tab) not localStorage (shared across tabs)
|
|
57
70
|
*/
|
|
58
71
|
generateClientId() {
|
|
59
72
|
let id = null;
|
|
60
73
|
|
|
61
74
|
try {
|
|
62
|
-
id =
|
|
75
|
+
id = sessionStorage.getItem('livesync-client-id');
|
|
63
76
|
} catch (e) {
|
|
64
|
-
//
|
|
77
|
+
// sessionStorage might not be available
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
if (!id) {
|
|
68
81
|
id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
|
|
69
82
|
try {
|
|
70
|
-
|
|
83
|
+
sessionStorage.setItem('livesync-client-id', id);
|
|
71
84
|
} catch (e) {
|
|
72
85
|
// That's okay
|
|
73
86
|
}
|
|
@@ -97,8 +110,7 @@ class LiveSync {
|
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
// Reset state for new connection
|
|
100
|
-
this.
|
|
101
|
-
this.lastBodyHtml = null;
|
|
113
|
+
this.lastHtml = null;
|
|
102
114
|
|
|
103
115
|
console.log('[LiveSync] Starting for:', this.currentFile);
|
|
104
116
|
this.connect();
|
|
@@ -185,32 +197,23 @@ class LiveSync {
|
|
|
185
197
|
return;
|
|
186
198
|
}
|
|
187
199
|
|
|
188
|
-
const {
|
|
200
|
+
const { html, sender } = data;
|
|
189
201
|
|
|
190
202
|
// Ignore own changes
|
|
191
|
-
if (sender === this.clientId)
|
|
192
|
-
|
|
193
|
-
// Guard against invalid body - never apply non-string
|
|
194
|
-
if (typeof body !== 'string') {
|
|
195
|
-
console.error('[LiveSync] Received invalid body (not a string), ignoring');
|
|
203
|
+
if (sender === this.clientId) {
|
|
204
|
+
this._log('Ignoring own message (sender matches clientId)');
|
|
196
205
|
return;
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (this.lastHeadHash && headHash !== this.lastHeadHash) {
|
|
204
|
-
console.log('[LiveSync] Head changed, reloading');
|
|
205
|
-
location.reload();
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
this.lastHeadHash = headHash;
|
|
208
|
+
// Guard against invalid html
|
|
209
|
+
if (typeof html !== 'string') {
|
|
210
|
+
console.error('[LiveSync] Received invalid html, ignoring');
|
|
211
|
+
return;
|
|
209
212
|
}
|
|
210
213
|
|
|
211
|
-
|
|
212
|
-
this.applyUpdate(
|
|
213
|
-
if (this.onUpdate) this.onUpdate({
|
|
214
|
+
this._log(`Received update from: ${sender} (my clientId: ${this.clientId})`);
|
|
215
|
+
this.applyUpdate(html);
|
|
216
|
+
if (this.onUpdate) this.onUpdate({ html, sender });
|
|
214
217
|
};
|
|
215
218
|
|
|
216
219
|
// Native EventSource auto-reconnects on transient errors
|
|
@@ -228,160 +231,90 @@ class LiveSync {
|
|
|
228
231
|
|
|
229
232
|
/**
|
|
230
233
|
* Listen for snapshot-ready events from the save system.
|
|
231
|
-
* Receives the full cloned documentElement and
|
|
234
|
+
* Receives the full cloned documentElement and sends it.
|
|
232
235
|
*/
|
|
233
236
|
listenForSnapshots() {
|
|
234
|
-
this._snapshotHandler =
|
|
235
|
-
if (this.isPaused)
|
|
237
|
+
this._snapshotHandler = (event) => {
|
|
238
|
+
if (this.isPaused) {
|
|
239
|
+
this._log('snapshot-ready received but isPaused, skipping');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
236
242
|
|
|
237
243
|
const { documentElement } = event.detail;
|
|
238
244
|
if (!documentElement) return;
|
|
239
245
|
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// Compute headHash using SHA-256 (async)
|
|
245
|
-
const headHash = await this.computeHeadHash(head);
|
|
246
|
-
|
|
247
|
-
// Send update even if body is empty (allows clearing content)
|
|
248
|
-
this.sendUpdate(body, headHash);
|
|
246
|
+
this._log('snapshot-ready received, preparing to send');
|
|
247
|
+
const html = documentElement.outerHTML;
|
|
248
|
+
this.sendUpdate(html);
|
|
249
249
|
};
|
|
250
250
|
|
|
251
251
|
document.addEventListener('hyperclay:snapshot-ready', this._snapshotHandler);
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
|
-
* Send
|
|
256
|
-
* Only updates
|
|
255
|
+
* Send full HTML to the server (debounced)
|
|
256
|
+
* Only updates lastHtml after successful save
|
|
257
257
|
*/
|
|
258
|
-
sendUpdate(
|
|
258
|
+
sendUpdate(html) {
|
|
259
259
|
clearTimeout(this.debounceTimer);
|
|
260
260
|
|
|
261
261
|
this.debounceTimer = setTimeout(() => {
|
|
262
262
|
// Skip if unchanged
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
if (html === this.lastHtml) {
|
|
264
|
+
this._log('Skipping send - HTML unchanged');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
this.lastHeadHash = headHash;
|
|
268
|
+
this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
|
|
269
269
|
|
|
270
270
|
fetch('/live-sync/save', {
|
|
271
271
|
method: 'POST',
|
|
272
272
|
headers: { 'Content-Type': 'application/json' },
|
|
273
273
|
body: JSON.stringify({
|
|
274
274
|
file: this.currentFile,
|
|
275
|
-
|
|
276
|
-
sender: this.clientId
|
|
277
|
-
headHash: headHash
|
|
275
|
+
html: html,
|
|
276
|
+
sender: this.clientId
|
|
278
277
|
})
|
|
279
278
|
}).then(response => {
|
|
280
279
|
if (response.ok) {
|
|
281
|
-
|
|
282
|
-
this.lastBodyHtml = body;
|
|
280
|
+
this.lastHtml = html;
|
|
283
281
|
} else {
|
|
284
|
-
// Log non-OK responses but don't suppress future sends
|
|
285
282
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
286
283
|
}
|
|
287
284
|
}).catch(err => {
|
|
288
|
-
// Network error - don't update lastBodyHtml so next mutation will retry
|
|
289
285
|
console.error('[LiveSync] Save failed:', err);
|
|
290
286
|
if (this.onError) this.onError(err);
|
|
291
287
|
});
|
|
292
288
|
}, this.debounceMs);
|
|
293
289
|
}
|
|
294
290
|
|
|
295
|
-
/**
|
|
296
|
-
* Compute SHA-256 hash of head content (first 16 hex chars)
|
|
297
|
-
* Uses SubtleCrypto API (async)
|
|
298
|
-
* @param {string} head - Head innerHTML
|
|
299
|
-
* @returns {Promise<string|null>} 16-char hex hash or null if unavailable
|
|
300
|
-
*/
|
|
301
|
-
async computeHeadHash(head) {
|
|
302
|
-
if (!head) return null;
|
|
303
|
-
|
|
304
|
-
// SubtleCrypto requires secure context (HTTPS or localhost)
|
|
305
|
-
if (!crypto?.subtle?.digest) {
|
|
306
|
-
console.warn('[LiveSync] SHA-256 unavailable (non-secure context), skipping headHash');
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
try {
|
|
311
|
-
const encoder = new TextEncoder();
|
|
312
|
-
const data = encoder.encode(head);
|
|
313
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
314
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
315
|
-
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
316
|
-
} catch (e) {
|
|
317
|
-
console.warn('[LiveSync] SHA-256 hash failed, skipping headHash:', e);
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
291
|
/**
|
|
323
292
|
* Apply an update received from the server
|
|
324
|
-
*
|
|
293
|
+
* Morphs the entire document (head and body)
|
|
325
294
|
*/
|
|
326
|
-
applyUpdate(
|
|
327
|
-
|
|
328
|
-
if (typeof bodyHtml !== 'string') {
|
|
329
|
-
console.error('[LiveSync] applyUpdate called with non-string value, ignoring');
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
295
|
+
applyUpdate(html) {
|
|
296
|
+
this._log('applyUpdate - pausing mutations and morphing');
|
|
333
297
|
this.isPaused = true;
|
|
334
|
-
this.
|
|
298
|
+
this.lastHtml = html;
|
|
335
299
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
temp.innerHTML = bodyHtml;
|
|
339
|
-
|
|
340
|
-
// Use Idiomorph if available
|
|
341
|
-
const morphFn = window.Idiomorph?.morph;
|
|
342
|
-
|
|
343
|
-
if (morphFn) {
|
|
344
|
-
morphFn(document.body, temp, {
|
|
345
|
-
morphStyle: 'innerHTML',
|
|
346
|
-
ignoreActiveValue: true
|
|
347
|
-
});
|
|
348
|
-
} else {
|
|
349
|
-
// Fallback to innerHTML
|
|
350
|
-
console.warn('[LiveSync] Idiomorph not available, using innerHTML fallback');
|
|
351
|
-
document.body.innerHTML = bodyHtml;
|
|
352
|
-
}
|
|
300
|
+
// Pause mutation observer so morph doesn't trigger autosave
|
|
301
|
+
Mutation.pause();
|
|
353
302
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
}
|
|
303
|
+
// Parse as full document
|
|
304
|
+
const parser = new DOMParser();
|
|
305
|
+
const newDoc = parser.parseFromString(html, 'text/html');
|
|
359
306
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// Text inputs and textareas
|
|
367
|
-
container.querySelectorAll('input[value], textarea[value]').forEach(el => {
|
|
368
|
-
if (el === focused) return;
|
|
369
|
-
el.value = el.getAttribute('value') || '';
|
|
307
|
+
// Morph entire document (html element)
|
|
308
|
+
HyperMorph.morph(document.documentElement, newDoc.documentElement, {
|
|
309
|
+
morphStyle: 'outerHTML',
|
|
310
|
+
ignoreActiveValue: true,
|
|
311
|
+
head: { style: 'merge' },
|
|
312
|
+
scripts: { handle: true, matchMode: 'smart' }
|
|
370
313
|
});
|
|
371
314
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
el.checked = el.hasAttribute('checked');
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
// Select dropdowns
|
|
379
|
-
container.querySelectorAll('select').forEach(select => {
|
|
380
|
-
if (select === focused) return;
|
|
381
|
-
select.querySelectorAll('option').forEach(opt => {
|
|
382
|
-
opt.selected = opt.hasAttribute('selected');
|
|
383
|
-
});
|
|
384
|
-
});
|
|
315
|
+
this._log('applyUpdate - morph complete, resuming mutations');
|
|
316
|
+
Mutation.resume();
|
|
317
|
+
this.isPaused = false;
|
|
385
318
|
}
|
|
386
319
|
|
|
387
320
|
/**
|
package/src/core/autosave.js
CHANGED
|
@@ -27,9 +27,23 @@ function initSavePageOnChange() {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Initialize auto-save on input events for [persist] elements
|
|
32
|
+
* Form input values don't trigger DOM mutations, so we listen for input events
|
|
33
|
+
*/
|
|
34
|
+
let inputSaveTimer = null;
|
|
35
|
+
function initSaveOnPersistInput() {
|
|
36
|
+
document.addEventListener('input', (e) => {
|
|
37
|
+
if (!e.target.closest('[persist]')) return;
|
|
38
|
+
clearTimeout(inputSaveTimer);
|
|
39
|
+
inputSaveTimer = setTimeout(savePageThrottled, 3333);
|
|
40
|
+
}, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
function init() {
|
|
31
44
|
if (!isEditMode) return;
|
|
32
45
|
initSavePageOnChange();
|
|
46
|
+
initSaveOnPersistInput();
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
// No window exports - savePageThrottled is exported from save-system
|
package/src/core/savePage.js
CHANGED
|
@@ -14,11 +14,14 @@ import throttle from "../utilities/throttle.js";
|
|
|
14
14
|
import Mutation from "../utilities/mutation.js";
|
|
15
15
|
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
saveHtml,
|
|
18
18
|
getPageContents,
|
|
19
19
|
replacePageWith as replacePageWithCore,
|
|
20
|
-
beforeSave
|
|
20
|
+
beforeSave,
|
|
21
|
+
isSaveInProgress
|
|
21
22
|
} from "./savePageCore.js";
|
|
23
|
+
import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
|
+
import { logSaveCheck, logBaseline } from "../utilities/autosaveDebug.js";
|
|
22
25
|
|
|
23
26
|
// Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
|
|
24
27
|
beforeSave(clone => {
|
|
@@ -88,6 +91,23 @@ window.addEventListener('online', () => {
|
|
|
88
91
|
}
|
|
89
92
|
});
|
|
90
93
|
|
|
94
|
+
// ============================================
|
|
95
|
+
// POST-SAVE BASELINE RECAPTURE
|
|
96
|
+
// ============================================
|
|
97
|
+
// After a successful save, onaftersave handlers may modify the live DOM
|
|
98
|
+
// (e.g., cacheBust updates ?v= query params). We recapture the baseline
|
|
99
|
+
// after these sync handlers complete to prevent false "unsaved changes" warnings.
|
|
100
|
+
|
|
101
|
+
document.addEventListener('hyperclay:save-saved', () => {
|
|
102
|
+
// Use setTimeout(0) to run after all sync onaftersave handlers complete
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
105
|
+
const contents = captureForComparison();
|
|
106
|
+
lastSavedContents = contents;
|
|
107
|
+
logBaseline('recaptured after onaftersave', `${contents.length} chars`);
|
|
108
|
+
}, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
91
111
|
// Re-export from core for backward compatibility
|
|
92
112
|
export { beforeSave, getPageContents };
|
|
93
113
|
|
|
@@ -110,6 +130,11 @@ export function savePage(callback = () => {}) {
|
|
|
110
130
|
return;
|
|
111
131
|
}
|
|
112
132
|
|
|
133
|
+
// Don't start a new save if one is already in progress
|
|
134
|
+
if (isSaveInProgress()) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
// Check if offline - set DOM state immediately for UI feedback
|
|
114
139
|
// but still try the fetch (navigator.onLine can be wrong)
|
|
115
140
|
const wasOffline = !navigator.onLine;
|
|
@@ -117,10 +142,24 @@ export function savePage(callback = () => {}) {
|
|
|
117
142
|
setOfflineStateQuiet();
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
|
|
145
|
+
// Single capture: clone once, get both versions
|
|
146
|
+
// forComparison has [save-remove] and [save-ignore] stripped
|
|
147
|
+
// forSave has only [save-remove] stripped
|
|
148
|
+
let forSave, forComparison;
|
|
149
|
+
try {
|
|
150
|
+
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
153
|
+
setSaveState('error', err.message);
|
|
154
|
+
if (typeof callback === 'function') {
|
|
155
|
+
callback({ msg: err.message, msgType: 'error' });
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
121
159
|
|
|
122
|
-
//
|
|
123
|
-
unsavedChanges = (
|
|
160
|
+
// Compare directly - lastSavedContents is already stripped
|
|
161
|
+
unsavedChanges = (forComparison !== lastSavedContents);
|
|
162
|
+
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
124
163
|
|
|
125
164
|
// Skip if content hasn't changed
|
|
126
165
|
if (!unsavedChanges) {
|
|
@@ -130,24 +169,29 @@ export function savePage(callback = () => {}) {
|
|
|
130
169
|
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
131
170
|
setSavingState();
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
173
|
+
saveHtml(forSave, (err, data) => {
|
|
174
|
+
if (!err) {
|
|
175
|
+
// SUCCESS - store stripped version for future comparisons
|
|
176
|
+
lastSavedContents = forComparison;
|
|
137
177
|
unsavedChanges = false;
|
|
138
|
-
setSaveState('saved', msg);
|
|
178
|
+
setSaveState('saved', data?.msg || 'Saved');
|
|
179
|
+
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
139
180
|
} else {
|
|
140
181
|
// FAILED - determine if it's offline or server error
|
|
141
182
|
if (!navigator.onLine) {
|
|
142
|
-
setSaveState('offline',
|
|
183
|
+
setSaveState('offline', err.message);
|
|
143
184
|
} else {
|
|
144
|
-
setSaveState('error',
|
|
185
|
+
setSaveState('error', err.message);
|
|
145
186
|
}
|
|
146
187
|
}
|
|
147
188
|
|
|
148
|
-
// Call user callback if provided
|
|
149
|
-
if (typeof callback === 'function'
|
|
150
|
-
callback({
|
|
189
|
+
// Call user callback if provided (preserve server's msgType)
|
|
190
|
+
if (typeof callback === 'function') {
|
|
191
|
+
callback({
|
|
192
|
+
msg: err?.message || data?.msg,
|
|
193
|
+
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
194
|
+
});
|
|
151
195
|
}
|
|
152
196
|
});
|
|
153
197
|
}
|
|
@@ -207,9 +251,11 @@ function initBaselineCapture() {
|
|
|
207
251
|
|
|
208
252
|
// Take immediate snapshot and set as baseline right away
|
|
209
253
|
// This ensures saves during settle window work correctly
|
|
210
|
-
|
|
254
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
255
|
+
const immediateContents = captureForComparison();
|
|
211
256
|
lastSavedContents = immediateContents;
|
|
212
257
|
baselineContents = immediateContents;
|
|
258
|
+
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
213
259
|
|
|
214
260
|
// Track user edits to avoid overwriting real changes
|
|
215
261
|
const userEditEvents = ['input', 'change', 'paste'];
|
|
@@ -235,9 +281,13 @@ function initBaselineCapture() {
|
|
|
235
281
|
// Only update if no user edits AND no saves occurred during settle
|
|
236
282
|
// (if a save happened, lastSavedContents would differ from immediateContents)
|
|
237
283
|
if (!userEdited && lastSavedContents === immediateContents) {
|
|
238
|
-
|
|
284
|
+
// Store stripped version so comparisons are direct (no parsing needed)
|
|
285
|
+
const contents = captureForComparison();
|
|
239
286
|
lastSavedContents = contents;
|
|
240
287
|
baselineContents = contents;
|
|
288
|
+
logBaseline('settled capture', `${contents.length} chars`);
|
|
289
|
+
} else {
|
|
290
|
+
logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
|
|
241
291
|
}
|
|
242
292
|
|
|
243
293
|
document.documentElement.setAttribute('savestatus', 'saved');
|
|
@@ -271,10 +321,17 @@ if (document.readyState === 'loading') {
|
|
|
271
321
|
export function savePageThrottled(callback = () => {}) {
|
|
272
322
|
if (!isEditMode) return;
|
|
273
323
|
|
|
274
|
-
const currentContents = getPageContents();
|
|
275
324
|
// For autosave: check both that content changed from baseline AND from last save
|
|
276
325
|
// This prevents saves from initial setup mutations
|
|
277
|
-
|
|
326
|
+
// Compare directly - stored versions are already stripped
|
|
327
|
+
const currentForCompare = captureForComparison();
|
|
328
|
+
const differsFromBaseline = currentForCompare !== baselineContents;
|
|
329
|
+
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
330
|
+
|
|
331
|
+
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
332
|
+
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
333
|
+
|
|
334
|
+
if (differsFromBaseline && differsFromLastSave) {
|
|
278
335
|
unsavedChanges = true;
|
|
279
336
|
throttledSave(callback);
|
|
280
337
|
}
|