hyperclayjs 1.26.0 → 1.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/core/savePage.js +141 -98
- package/src/core/savePageCore.js +244 -190
- package/src/core/snapshot.js +9 -2
- package/src/custom-attributes/onclone.js +4 -2
- package/src/hyperclay.js +1 -1
- package/src/utilities/debounce.js +12 -3
- package/src/utilities/throttle.js +28 -10
package/README.md
CHANGED
|
@@ -62,10 +62,10 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
62
62
|
| edit-mode-helpers | 6.8KB | Admin-only functionality: [viewmode:disabled], [editmode:resource], [editmode:onclick] |
|
|
63
63
|
| option-visibility | 7.1KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
|
|
64
64
|
| persist | 6.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
|
|
65
|
-
| save-core |
|
|
66
|
-
| save-system |
|
|
65
|
+
| save-core | 11.5KB | Basic save function only - hyperclay.savePage() |
|
|
66
|
+
| save-system | 15.4KB | CMD+S, [trigger-save] button, savestatus attribute |
|
|
67
67
|
| save-toast | 0.9KB | Toast notifications for save events |
|
|
68
|
-
| snapshot |
|
|
68
|
+
| snapshot | 11KB | Source of truth for page state - captures DOM snapshots for save and sync |
|
|
69
69
|
| unsaved-warning | 1.3KB | Warn before leaving page with unsaved changes |
|
|
70
70
|
|
|
71
71
|
### Custom Attributes (HTML enhancements)
|
|
@@ -96,10 +96,10 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
96
96
|
|--------|------|-------------|
|
|
97
97
|
| cache-bust | 0.6KB | Cache-bust href/src attributes |
|
|
98
98
|
| cookie | 1.4KB | Cookie management (often auto-included) |
|
|
99
|
-
| debounce | 0.
|
|
99
|
+
| debounce | 0.7KB | Function debouncing |
|
|
100
100
|
| mutation | 13.8KB | DOM mutation observation (often auto-included) |
|
|
101
101
|
| nearest | 3.4KB | Find nearest elements (often auto-included) |
|
|
102
|
-
| throttle |
|
|
102
|
+
| throttle | 1.3KB | Function throttling |
|
|
103
103
|
|
|
104
104
|
### DOM Utilities (DOM manipulation helpers)
|
|
105
105
|
|
|
@@ -134,17 +134,17 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
134
134
|
|
|
135
135
|
## Presets
|
|
136
136
|
|
|
137
|
-
### Minimal (~
|
|
137
|
+
### Minimal (~61.8KB)
|
|
138
138
|
Essential features for basic editing
|
|
139
139
|
|
|
140
140
|
**Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
|
|
141
141
|
|
|
142
|
-
### Standard (~
|
|
142
|
+
### Standard (~88.7KB)
|
|
143
143
|
Standard feature set for most use cases
|
|
144
144
|
|
|
145
145
|
**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`
|
|
146
146
|
|
|
147
|
-
### Everything (~
|
|
147
|
+
### Everything (~231.4KB)
|
|
148
148
|
All available features
|
|
149
149
|
|
|
150
150
|
Includes all available modules across all categories.
|
package/package.json
CHANGED
package/src/core/savePage.js
CHANGED
|
@@ -121,129 +121,160 @@ export function getLastSavedContents() { return lastSavedContents; }
|
|
|
121
121
|
export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* Save the current page with change detection and state management
|
|
124
|
+
* Save the current page with change detection and state management.
|
|
125
|
+
*
|
|
126
|
+
* Returns a Promise that resolves with {msg, msgType} — the same object
|
|
127
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
128
|
+
* msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
|
|
125
129
|
*
|
|
126
130
|
* @param {Function} callback - Optional callback for custom handling
|
|
131
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
127
132
|
*/
|
|
128
133
|
export function savePage(callback = () => {}) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
if (!isEditMode && !window.hyperclay?.testMode) {
|
|
136
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
137
|
+
callback(skipped);
|
|
138
|
+
return resolve(skipped);
|
|
139
|
+
}
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
// Don't start a new save if one is already in progress
|
|
142
|
+
if (isSaveInProgress()) {
|
|
143
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
144
|
+
callback(skipped);
|
|
145
|
+
return resolve(skipped);
|
|
146
|
+
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// Check if offline - set DOM state immediately for UI feedback
|
|
149
|
+
// but still try the fetch (navigator.onLine can be wrong)
|
|
150
|
+
const wasOffline = !navigator.onLine;
|
|
151
|
+
if (wasOffline) {
|
|
152
|
+
setOfflineStateQuiet();
|
|
153
|
+
}
|
|
144
154
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
// Single capture: clone once, get both versions
|
|
156
|
+
// forComparison has [save-remove] and [save-ignore] stripped
|
|
157
|
+
// forSave has only [save-remove] stripped
|
|
158
|
+
let forSave, forComparison;
|
|
159
|
+
try {
|
|
160
|
+
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
163
|
+
setSaveState('error', err.message);
|
|
164
|
+
const result = { msg: err.message, msgType: 'error' };
|
|
165
|
+
if (typeof callback === 'function') {
|
|
166
|
+
callback(result);
|
|
167
|
+
}
|
|
168
|
+
return resolve(result);
|
|
156
169
|
}
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
// Compare directly - lastSavedContents is already stripped
|
|
172
|
+
unsavedChanges = (forComparison !== lastSavedContents);
|
|
173
|
+
logSaveCheck('savePage dirty check', !unsavedChanges);
|
|
163
174
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
// Skip if content hasn't changed
|
|
176
|
+
if (!unsavedChanges) {
|
|
177
|
+
const skipped = { msg: 'No changes to save', msgType: 'skipped' };
|
|
178
|
+
callback(skipped);
|
|
179
|
+
return resolve(skipped);
|
|
180
|
+
}
|
|
168
181
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
} else {
|
|
181
|
-
// FAILED - determine if it's offline or server error
|
|
182
|
-
if (!navigator.onLine) {
|
|
183
|
-
setSaveState('offline', err.message);
|
|
182
|
+
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
183
|
+
setSavingState();
|
|
184
|
+
|
|
185
|
+
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
186
|
+
saveHtml(forSave, (err, data) => {
|
|
187
|
+
if (!err) {
|
|
188
|
+
// SUCCESS - store stripped version for future comparisons
|
|
189
|
+
lastSavedContents = forComparison;
|
|
190
|
+
unsavedChanges = false;
|
|
191
|
+
setSaveState('saved', data?.msg || 'Saved');
|
|
192
|
+
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
184
193
|
} else {
|
|
185
|
-
|
|
194
|
+
// FAILED - determine if it's offline or server error
|
|
195
|
+
if (!navigator.onLine) {
|
|
196
|
+
setSaveState('offline', err.message);
|
|
197
|
+
} else {
|
|
198
|
+
setSaveState('error', err.message);
|
|
199
|
+
}
|
|
186
200
|
}
|
|
187
|
-
}
|
|
188
201
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
callback({
|
|
202
|
+
// Call user callback if provided (preserve server's msgType)
|
|
203
|
+
const result = {
|
|
192
204
|
msg: err?.message || data?.msg,
|
|
193
205
|
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
194
|
-
}
|
|
195
|
-
|
|
206
|
+
};
|
|
207
|
+
if (typeof callback === 'function') {
|
|
208
|
+
callback(result);
|
|
209
|
+
}
|
|
210
|
+
resolve(result);
|
|
211
|
+
});
|
|
196
212
|
});
|
|
197
213
|
}
|
|
198
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Force-save the current page (skips dirty check).
|
|
217
|
+
*
|
|
218
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
219
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
220
|
+
*/
|
|
199
221
|
export function savePageForce(callback = () => {}) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
if (!isEditMode && !window.hyperclay?.testMode) {
|
|
224
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
225
|
+
callback(skipped);
|
|
226
|
+
return resolve(skipped);
|
|
227
|
+
}
|
|
203
228
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
229
|
+
if (isSaveInProgress()) {
|
|
230
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
231
|
+
callback(skipped);
|
|
232
|
+
return resolve(skipped);
|
|
233
|
+
}
|
|
207
234
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
const wasOffline = !navigator.onLine;
|
|
236
|
+
if (wasOffline) {
|
|
237
|
+
setOfflineStateQuiet();
|
|
238
|
+
}
|
|
212
239
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
240
|
+
let forSave, forComparison;
|
|
241
|
+
try {
|
|
242
|
+
({ forSave, forComparison } = captureForSaveAndComparison());
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
245
|
+
setSaveState('error', err.message);
|
|
246
|
+
const result = { msg: err.message, msgType: 'error' };
|
|
247
|
+
if (typeof callback === 'function') {
|
|
248
|
+
callback(result);
|
|
249
|
+
}
|
|
250
|
+
return resolve(result);
|
|
221
251
|
}
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
252
|
|
|
225
|
-
|
|
253
|
+
setSavingState();
|
|
226
254
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} else {
|
|
234
|
-
if (!navigator.onLine) {
|
|
235
|
-
setSaveState('offline', err.message);
|
|
255
|
+
saveHtml(forSave, (err, data) => {
|
|
256
|
+
if (!err) {
|
|
257
|
+
lastSavedContents = forComparison;
|
|
258
|
+
unsavedChanges = false;
|
|
259
|
+
setSaveState('saved', data?.msg || 'Saved');
|
|
260
|
+
logBaseline('updated after force save', `${lastSavedContents.length} chars`);
|
|
236
261
|
} else {
|
|
237
|
-
|
|
262
|
+
if (!navigator.onLine) {
|
|
263
|
+
setSaveState('offline', err.message);
|
|
264
|
+
} else {
|
|
265
|
+
setSaveState('error', err.message);
|
|
266
|
+
}
|
|
238
267
|
}
|
|
239
|
-
}
|
|
240
268
|
|
|
241
|
-
|
|
242
|
-
callback({
|
|
269
|
+
const result = {
|
|
243
270
|
msg: err?.message || data?.msg,
|
|
244
271
|
msgType: err ? 'error' : (data?.msgType || 'success')
|
|
245
|
-
}
|
|
246
|
-
|
|
272
|
+
};
|
|
273
|
+
if (typeof callback === 'function') {
|
|
274
|
+
callback(result);
|
|
275
|
+
}
|
|
276
|
+
resolve(result);
|
|
277
|
+
});
|
|
247
278
|
});
|
|
248
279
|
}
|
|
249
280
|
|
|
@@ -364,13 +395,21 @@ if (document.readyState === 'loading') {
|
|
|
364
395
|
}
|
|
365
396
|
|
|
366
397
|
/**
|
|
367
|
-
* Save the page with throttling, for use with auto-save
|
|
368
|
-
* Checks both baseline and last saved content to prevent saves from initial setup
|
|
398
|
+
* Save the page with throttling, for use with auto-save.
|
|
399
|
+
* Checks both baseline and last saved content to prevent saves from initial setup.
|
|
400
|
+
*
|
|
401
|
+
* Returns a Promise resolving with {msg, msgType}. Within-throttle-window calls
|
|
402
|
+
* piggyback on the trailing-edge save and resolve with its result.
|
|
369
403
|
*
|
|
370
404
|
* @param {Function} callback - Optional callback
|
|
405
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
371
406
|
*/
|
|
372
407
|
export function savePageThrottled(callback = () => {}) {
|
|
373
|
-
if (!isEditMode)
|
|
408
|
+
if (!isEditMode) {
|
|
409
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
410
|
+
callback(skipped);
|
|
411
|
+
return Promise.resolve(skipped);
|
|
412
|
+
}
|
|
374
413
|
|
|
375
414
|
// For autosave: check both that content changed from baseline AND from last save
|
|
376
415
|
// This prevents saves from initial setup mutations
|
|
@@ -382,10 +421,14 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
382
421
|
logSaveCheck('throttled vs baseline', !differsFromBaseline);
|
|
383
422
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
384
423
|
|
|
385
|
-
if (differsFromBaseline && differsFromLastSave) {
|
|
386
|
-
|
|
387
|
-
|
|
424
|
+
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
425
|
+
const skipped = { msg: 'No changes to save', msgType: 'skipped' };
|
|
426
|
+
callback(skipped);
|
|
427
|
+
return Promise.resolve(skipped);
|
|
388
428
|
}
|
|
429
|
+
|
|
430
|
+
unsavedChanges = true;
|
|
431
|
+
return throttledSave(callback);
|
|
389
432
|
}
|
|
390
433
|
|
|
391
434
|
/**
|
package/src/core/savePageCore.js
CHANGED
|
@@ -66,240 +66,294 @@ function getContentsForSave() {
|
|
|
66
66
|
/**
|
|
67
67
|
* Save the current page contents to the server.
|
|
68
68
|
*
|
|
69
|
+
* Returns a Promise that resolves with {msg, msgType} — the same object
|
|
70
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
71
|
+
* msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
|
|
72
|
+
*
|
|
69
73
|
* @param {Function} callback - Called with {msg, msgType} on completion
|
|
70
|
-
* msgType will be 'success' or '
|
|
74
|
+
* msgType will be 'success', 'error', or 'skipped'
|
|
75
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
71
76
|
*
|
|
72
77
|
* @example
|
|
78
|
+
* // Callback form (unchanged)
|
|
73
79
|
* savePage(({msg, msgType}) => {
|
|
74
|
-
* if (msgType === 'error')
|
|
75
|
-
* console.error('Save failed:', msg);
|
|
76
|
-
* } else {
|
|
77
|
-
* console.log('Saved:', msg);
|
|
78
|
-
* }
|
|
80
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
79
81
|
* });
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Promise form
|
|
85
|
+
* const {msg, msgType} = await savePage();
|
|
86
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
80
87
|
*/
|
|
81
88
|
export function savePage(callback = () => {}) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
if (saveInProgress) {
|
|
91
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
92
|
+
callback(skipped);
|
|
93
|
+
return resolve(skipped);
|
|
94
|
+
}
|
|
95
|
+
if (!isEditMode && !window.hyperclay?.testMode) {
|
|
96
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
97
|
+
callback(skipped);
|
|
98
|
+
return resolve(skipped);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let currentContents;
|
|
102
|
+
try {
|
|
103
|
+
currentContents = getContentsForSave();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error('savePage: getContentsForSave failed', err);
|
|
106
|
+
const result = { msg: err.message, msgType: "error" };
|
|
107
|
+
callback(result);
|
|
108
|
+
return resolve(result);
|
|
109
|
+
}
|
|
110
|
+
saveInProgress = true;
|
|
111
|
+
|
|
112
|
+
// Test mode: skip network request, return mock success
|
|
113
|
+
if (window.hyperclay?.testMode) {
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
saveInProgress = false;
|
|
116
|
+
const result = { msg: "Test mode: save skipped", msgType: "success" };
|
|
117
|
+
if (typeof callback === 'function') {
|
|
118
|
+
callback(result);
|
|
119
|
+
}
|
|
120
|
+
resolve(result);
|
|
121
|
+
}, 0);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add timeout - abort if server doesn't respond within 12 seconds
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
128
|
+
|
|
129
|
+
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
130
|
+
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
131
|
+
window.location.hostname === '127.0.0.1';
|
|
132
|
+
|
|
133
|
+
const fetchOptions = {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
credentials: 'include',
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
headers: { 'Page-URL': window.location.href }
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
141
|
+
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
142
|
+
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
143
|
+
fetchOptions.body = JSON.stringify({
|
|
144
|
+
content: currentContents,
|
|
145
|
+
snapshotHtml: window.__hyperclaySnapshotHtml
|
|
146
|
+
});
|
|
147
|
+
// Clear after use to avoid stale data
|
|
148
|
+
window.__hyperclaySnapshotHtml = null;
|
|
149
|
+
} else {
|
|
150
|
+
// Platform: send plain text as before
|
|
151
|
+
fetchOptions.body = currentContents;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fetch(saveEndpoint, fetchOptions)
|
|
155
|
+
.then(res => {
|
|
156
|
+
clearTimeout(timeoutId);
|
|
157
|
+
return res.json().then(data => {
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
162
|
+
});
|
|
163
|
+
})
|
|
164
|
+
.then(data => {
|
|
165
|
+
const result = { msg: data.msg, msgType: data.msgType || 'success' };
|
|
166
|
+
if (typeof callback === 'function') {
|
|
167
|
+
callback(result);
|
|
168
|
+
}
|
|
169
|
+
resolve(result);
|
|
170
|
+
})
|
|
171
|
+
.catch(err => {
|
|
172
|
+
clearTimeout(timeoutId);
|
|
173
|
+
console.error('Failed to save page:', err);
|
|
109
174
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
115
|
-
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
116
|
-
window.location.hostname === '127.0.0.1';
|
|
117
|
-
|
|
118
|
-
const fetchOptions = {
|
|
119
|
-
method: 'POST',
|
|
120
|
-
credentials: 'include',
|
|
121
|
-
signal: controller.signal,
|
|
122
|
-
headers: { 'Page-URL': window.location.href }
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
126
|
-
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
127
|
-
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
128
|
-
fetchOptions.body = JSON.stringify({
|
|
129
|
-
content: currentContents,
|
|
130
|
-
snapshotHtml: window.__hyperclaySnapshotHtml
|
|
131
|
-
});
|
|
132
|
-
// Clear after use to avoid stale data
|
|
133
|
-
window.__hyperclaySnapshotHtml = null;
|
|
134
|
-
} else {
|
|
135
|
-
// Platform: send plain text as before
|
|
136
|
-
fetchOptions.body = currentContents;
|
|
137
|
-
}
|
|
175
|
+
const msg = err.name === 'AbortError'
|
|
176
|
+
? 'Server not responding'
|
|
177
|
+
: 'Save failed';
|
|
138
178
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return res.json().then(data => {
|
|
143
|
-
if (!res.ok) {
|
|
144
|
-
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
179
|
+
const result = { msg, msgType: "error" };
|
|
180
|
+
if (typeof callback === 'function') {
|
|
181
|
+
callback(result);
|
|
145
182
|
}
|
|
146
|
-
|
|
183
|
+
resolve(result);
|
|
184
|
+
})
|
|
185
|
+
.finally(() => {
|
|
186
|
+
clearTimeout(timeoutId);
|
|
187
|
+
saveInProgress = false;
|
|
147
188
|
});
|
|
148
|
-
|
|
149
|
-
.then(data => {
|
|
150
|
-
if (typeof callback === 'function') {
|
|
151
|
-
callback({ msg: data.msg, msgType: data.msgType || 'success' });
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
.catch(err => {
|
|
155
|
-
clearTimeout(timeoutId);
|
|
156
|
-
console.error('Failed to save page:', err);
|
|
157
|
-
|
|
158
|
-
const msg = err.name === 'AbortError'
|
|
159
|
-
? 'Server not responding'
|
|
160
|
-
: 'Save failed';
|
|
161
|
-
|
|
162
|
-
if (typeof callback === 'function') {
|
|
163
|
-
callback({ msg, msgType: "error" });
|
|
164
|
-
}
|
|
165
|
-
})
|
|
166
|
-
.finally(() => {
|
|
167
|
-
clearTimeout(timeoutId);
|
|
168
|
-
saveInProgress = false;
|
|
169
|
-
});
|
|
189
|
+
});
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
/**
|
|
173
193
|
* Save specific HTML content to the server.
|
|
174
194
|
*
|
|
195
|
+
* Returns a Promise that resolves with {err, data} — same arguments
|
|
196
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
197
|
+
* truthy err. Skipped early-returns resolve with data.msgType: 'skipped'.
|
|
198
|
+
*
|
|
175
199
|
* @param {string} html - HTML string to save
|
|
176
200
|
* @param {Function} callback - Called with (err, data) on completion
|
|
201
|
+
* @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
|
|
177
202
|
*
|
|
178
203
|
* @example
|
|
204
|
+
* // Callback form (unchanged)
|
|
179
205
|
* saveHtml(myHtml, (err, data) => {
|
|
180
|
-
* if (err)
|
|
181
|
-
* console.error('Save failed:', err);
|
|
182
|
-
* } else {
|
|
183
|
-
* console.log('Saved:', data);
|
|
184
|
-
* }
|
|
206
|
+
* if (err) console.error('Save failed:', err);
|
|
185
207
|
* });
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* // Promise form
|
|
211
|
+
* const {err, data} = await saveHtml(myHtml);
|
|
212
|
+
* if (err) console.error('Save failed:', err);
|
|
186
213
|
*/
|
|
187
214
|
export function saveHtml(html, callback = () => {}) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
if (!isEditMode || saveInProgress) {
|
|
217
|
+
const data = {
|
|
218
|
+
msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
|
|
219
|
+
msgType: 'skipped'
|
|
220
|
+
};
|
|
221
|
+
callback(null, data);
|
|
222
|
+
return resolve({ err: null, data });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
saveInProgress = true;
|
|
226
|
+
|
|
227
|
+
// Test mode: skip network request, return mock success
|
|
228
|
+
if (window.hyperclay?.testMode) {
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
saveInProgress = false;
|
|
231
|
+
const data = { msg: "Test mode: save skipped", msgType: "success" };
|
|
232
|
+
if (typeof callback === 'function') {
|
|
233
|
+
callback(null, data);
|
|
234
|
+
}
|
|
235
|
+
resolve({ err: null, data });
|
|
236
|
+
}, 0);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Add timeout - abort if server doesn't respond within 12 seconds
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
243
|
+
|
|
244
|
+
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
245
|
+
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
246
|
+
window.location.hostname === '127.0.0.1';
|
|
247
|
+
|
|
248
|
+
const fetchOptions = {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
credentials: 'include',
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
headers: { 'Page-URL': window.location.href }
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
256
|
+
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
257
|
+
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
258
|
+
fetchOptions.body = JSON.stringify({
|
|
259
|
+
content: html,
|
|
260
|
+
snapshotHtml: window.__hyperclaySnapshotHtml
|
|
261
|
+
});
|
|
262
|
+
// Clear after use to avoid stale data
|
|
263
|
+
window.__hyperclaySnapshotHtml = null;
|
|
264
|
+
} else {
|
|
265
|
+
// Platform: send plain text as before
|
|
266
|
+
fetchOptions.body = html;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
fetch(saveEndpoint, fetchOptions)
|
|
270
|
+
.then(res => {
|
|
271
|
+
clearTimeout(timeoutId);
|
|
272
|
+
return res.json().then(data => {
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
275
|
+
}
|
|
276
|
+
return data;
|
|
277
|
+
});
|
|
278
|
+
})
|
|
279
|
+
.then(data => {
|
|
280
|
+
if (typeof callback === 'function') {
|
|
281
|
+
callback(null, data);
|
|
282
|
+
}
|
|
283
|
+
resolve({ err: null, data });
|
|
284
|
+
})
|
|
285
|
+
.catch(err => {
|
|
286
|
+
clearTimeout(timeoutId);
|
|
287
|
+
console.error('Failed to save page:', err);
|
|
204
288
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
210
|
-
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
211
|
-
window.location.hostname === '127.0.0.1';
|
|
212
|
-
|
|
213
|
-
const fetchOptions = {
|
|
214
|
-
method: 'POST',
|
|
215
|
-
credentials: 'include',
|
|
216
|
-
signal: controller.signal,
|
|
217
|
-
headers: { 'Page-URL': window.location.href }
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
221
|
-
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
222
|
-
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
223
|
-
fetchOptions.body = JSON.stringify({
|
|
224
|
-
content: html,
|
|
225
|
-
snapshotHtml: window.__hyperclaySnapshotHtml
|
|
226
|
-
});
|
|
227
|
-
// Clear after use to avoid stale data
|
|
228
|
-
window.__hyperclaySnapshotHtml = null;
|
|
229
|
-
} else {
|
|
230
|
-
// Platform: send plain text as before
|
|
231
|
-
fetchOptions.body = html;
|
|
232
|
-
}
|
|
289
|
+
// Normalize timeout errors
|
|
290
|
+
const error = err.name === 'AbortError'
|
|
291
|
+
? new Error('Server not responding')
|
|
292
|
+
: err;
|
|
233
293
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
clearTimeout(timeoutId);
|
|
237
|
-
return res.json().then(data => {
|
|
238
|
-
if (!res.ok) {
|
|
239
|
-
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
294
|
+
if (typeof callback === 'function') {
|
|
295
|
+
callback(error);
|
|
240
296
|
}
|
|
241
|
-
|
|
297
|
+
resolve({ err: error, data: null });
|
|
298
|
+
})
|
|
299
|
+
.finally(() => {
|
|
300
|
+
clearTimeout(timeoutId);
|
|
301
|
+
saveInProgress = false;
|
|
242
302
|
});
|
|
243
|
-
|
|
244
|
-
.then(data => {
|
|
245
|
-
if (typeof callback === 'function') {
|
|
246
|
-
callback(null, data);
|
|
247
|
-
}
|
|
248
|
-
})
|
|
249
|
-
.catch(err => {
|
|
250
|
-
clearTimeout(timeoutId);
|
|
251
|
-
console.error('Failed to save page:', err);
|
|
252
|
-
|
|
253
|
-
// Normalize timeout errors
|
|
254
|
-
const error = err.name === 'AbortError'
|
|
255
|
-
? new Error('Server not responding')
|
|
256
|
-
: err;
|
|
257
|
-
|
|
258
|
-
if (typeof callback === 'function') {
|
|
259
|
-
callback(error);
|
|
260
|
-
}
|
|
261
|
-
})
|
|
262
|
-
.finally(() => {
|
|
263
|
-
clearTimeout(timeoutId);
|
|
264
|
-
saveInProgress = false;
|
|
265
|
-
});
|
|
303
|
+
});
|
|
266
304
|
}
|
|
267
305
|
|
|
268
306
|
/**
|
|
269
307
|
* Fetch HTML from a URL and save it to replace the current page.
|
|
270
308
|
*
|
|
309
|
+
* Returns a Promise that resolves with {err, data} — same arguments
|
|
310
|
+
* passed to the callback. Promise never rejects.
|
|
311
|
+
*
|
|
271
312
|
* @param {string} url - URL to fetch HTML from
|
|
272
313
|
* @param {Function} callback - Called with (err, data) on completion
|
|
314
|
+
* @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
|
|
273
315
|
*
|
|
274
316
|
* @example
|
|
317
|
+
* // Callback form (unchanged)
|
|
275
318
|
* replacePageWith('/templates/blog.html', (err, data) => {
|
|
276
|
-
* if (err)
|
|
277
|
-
*
|
|
278
|
-
* } else {
|
|
279
|
-
* window.location.reload();
|
|
280
|
-
* }
|
|
319
|
+
* if (err) console.error('Failed:', err);
|
|
320
|
+
* else window.location.reload();
|
|
281
321
|
* });
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* // Promise form
|
|
325
|
+
* const {err, data} = await replacePageWith('/templates/blog.html');
|
|
326
|
+
* if (!err) window.location.reload();
|
|
282
327
|
*/
|
|
283
328
|
export function replacePageWith(url, callback = () => {}) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
if (!isEditMode || saveInProgress) {
|
|
331
|
+
const data = {
|
|
332
|
+
msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
|
|
333
|
+
msgType: 'skipped'
|
|
334
|
+
};
|
|
335
|
+
callback(null, data);
|
|
336
|
+
return resolve({ err: null, data });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fetch(url)
|
|
340
|
+
.then(res => res.text())
|
|
341
|
+
.then(html => {
|
|
342
|
+
saveHtml(html, (err, data) => {
|
|
343
|
+
if (typeof callback === 'function') {
|
|
344
|
+
callback(err, data);
|
|
345
|
+
}
|
|
346
|
+
resolve({ err: err || null, data: data || null });
|
|
347
|
+
});
|
|
348
|
+
})
|
|
349
|
+
.catch(err => {
|
|
350
|
+
console.error('Failed to fetch template:', err);
|
|
292
351
|
if (typeof callback === 'function') {
|
|
293
|
-
callback(err
|
|
352
|
+
callback(err);
|
|
294
353
|
}
|
|
354
|
+
resolve({ err, data: null });
|
|
295
355
|
});
|
|
296
|
-
|
|
297
|
-
.catch(err => {
|
|
298
|
-
console.error('Failed to fetch template:', err);
|
|
299
|
-
if (typeof callback === 'function') {
|
|
300
|
-
callback(err);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
356
|
+
});
|
|
303
357
|
}
|
|
304
358
|
|
|
305
359
|
// =============================================================================
|
package/src/core/snapshot.js
CHANGED
|
@@ -78,8 +78,15 @@ export const beforeSave = onPrepareForSave;
|
|
|
78
78
|
*
|
|
79
79
|
* @returns {HTMLElement} Cloned document element with snapshot hooks applied
|
|
80
80
|
*/
|
|
81
|
+
function clonePreventingOnclone(node) {
|
|
82
|
+
const prev = window.__preventOnclone;
|
|
83
|
+
window.__preventOnclone = true;
|
|
84
|
+
try { return node.cloneNode(true); }
|
|
85
|
+
finally { window.__preventOnclone = prev; }
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
export function captureSnapshot() {
|
|
82
|
-
const clone = document.documentElement
|
|
89
|
+
const clone = clonePreventingOnclone(document.documentElement);
|
|
83
90
|
|
|
84
91
|
for (const hook of snapshotHooks) {
|
|
85
92
|
hook(clone);
|
|
@@ -196,7 +203,7 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
199
|
-
const compareClone = clone
|
|
206
|
+
const compareClone = clonePreventingOnclone(clone);
|
|
200
207
|
|
|
201
208
|
// Save clone: strip [save-remove], then run hooks
|
|
202
209
|
for (const el of clone.querySelectorAll('[save-remove]')) {
|
|
@@ -13,8 +13,10 @@ function init() {
|
|
|
13
13
|
const clonedNode = originalCloneNode.call(this, deep);
|
|
14
14
|
|
|
15
15
|
if (clonedNode.nodeType === Node.ELEMENT_NODE) {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
if (!window.__preventOnclone) {
|
|
17
|
+
processOnclone(clonedNode);
|
|
18
|
+
clonedNode.querySelectorAll('[onclone]').forEach(processOnclone);
|
|
19
|
+
}
|
|
18
20
|
|
|
19
21
|
// Patch textareas: the persist module writes live values to data-value
|
|
20
22
|
// on every keystroke (because writing textContent on a focused textarea
|
package/src/hyperclay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DO NOT EDIT THIS FILE DIRECTLY — it is generated from build/hyperclay.template.js
|
|
3
3
|
*
|
|
4
|
-
* HyperclayJS v1.26.
|
|
4
|
+
* HyperclayJS v1.26.2 - Minimal Browser-Native Loader
|
|
5
5
|
*
|
|
6
6
|
* Modules auto-init when imported (no separate init call needed).
|
|
7
7
|
* Include `export-to-window` feature to export to window.hyperclay.
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// debounce.js
|
|
2
2
|
function debounce(callback, delay) {
|
|
3
3
|
let timeoutId;
|
|
4
|
+
let pendingResolvers = [];
|
|
4
5
|
|
|
5
6
|
return function (...args) {
|
|
7
|
+
const ctx = this;
|
|
6
8
|
clearTimeout(timeoutId);
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
pendingResolvers.push(resolve);
|
|
12
|
+
|
|
13
|
+
timeoutId = setTimeout(() => {
|
|
14
|
+
const resolvers = pendingResolvers;
|
|
15
|
+
pendingResolvers = [];
|
|
16
|
+
Promise.resolve(callback.apply(ctx, args))
|
|
17
|
+
.then(value => { for (const r of resolvers) r(value); });
|
|
18
|
+
}, delay);
|
|
19
|
+
});
|
|
11
20
|
};
|
|
12
21
|
}
|
|
13
22
|
|
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
function throttle(callback, delay, executeFirst = true) {
|
|
2
2
|
let lastCall = executeFirst ? 0 : Date.now();
|
|
3
3
|
let timeoutId = null;
|
|
4
|
+
let pendingResolvers = [];
|
|
4
5
|
|
|
5
6
|
return function (...args) {
|
|
7
|
+
const ctx = this;
|
|
6
8
|
const now = Date.now();
|
|
7
9
|
const remaining = delay - (now - lastCall);
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return callback.apply(this, args);
|
|
13
|
-
} else if (!timeoutId) {
|
|
14
|
-
timeoutId = setTimeout(() => {
|
|
15
|
-
lastCall = Date.now();
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
if (remaining <= 0) {
|
|
13
|
+
clearTimeout(timeoutId);
|
|
16
14
|
timeoutId = null;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
lastCall = now;
|
|
16
|
+
|
|
17
|
+
const resolvers = pendingResolvers.concat(resolve);
|
|
18
|
+
pendingResolvers = [];
|
|
19
|
+
|
|
20
|
+
Promise.resolve(callback.apply(ctx, args))
|
|
21
|
+
.then(value => { for (const r of resolvers) r(value); });
|
|
22
|
+
} else {
|
|
23
|
+
pendingResolvers.push(resolve);
|
|
24
|
+
|
|
25
|
+
if (!timeoutId) {
|
|
26
|
+
timeoutId = setTimeout(() => {
|
|
27
|
+
lastCall = Date.now();
|
|
28
|
+
timeoutId = null;
|
|
29
|
+
const resolvers = pendingResolvers;
|
|
30
|
+
pendingResolvers = [];
|
|
31
|
+
|
|
32
|
+
Promise.resolve(callback.apply(ctx, args))
|
|
33
|
+
.then(value => { for (const r of resolvers) r(value); });
|
|
34
|
+
}, remaining);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
20
38
|
};
|
|
21
39
|
}
|
|
22
40
|
|