@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { onSnapshot } from './snapshot.js';
|
|
2
|
+
import { serializeControlToAttributes, finalizeControlForSave } from '../vendor/control-serialize.vendor.js';
|
|
3
|
+
|
|
4
|
+
// Persistent Form Input Values
|
|
5
|
+
//
|
|
6
|
+
// Problem: Browser form values (.value, .checked, .selectedIndex) live in JS
|
|
7
|
+
// memory, not in DOM attributes. When clayjs serializes the page via
|
|
8
|
+
// cloneNode() or outerHTML, those JS-only values are lost. This module syncs
|
|
9
|
+
// them back to the DOM so they survive saves, live-sync, and cloning.
|
|
10
|
+
//
|
|
11
|
+
// The per-control rules (which attribute represents which control's state) live
|
|
12
|
+
// in control-serialize.vendor.js — the single source of truth shared verbatim
|
|
13
|
+
// with sapjs (carrier mirror) so the two implementations never drift. This
|
|
14
|
+
// module owns only the triggers (which elements, on which events) and the
|
|
15
|
+
// snapshot safety net; serializeControlToAttributes / finalizeControlForSave do
|
|
16
|
+
// the actual attribute writes.
|
|
17
|
+
//
|
|
18
|
+
// Strategy: sync to the DOM immediately on every user interaction, not just at
|
|
19
|
+
// snapshot time. This means cloneNode() always gets current values.
|
|
20
|
+
//
|
|
21
|
+
// Why this is safe for each element type:
|
|
22
|
+
//
|
|
23
|
+
// <input type="text"> — setAttribute("value", ...) updates the DOM attribute
|
|
24
|
+
// but does NOT change the displayed text or cursor position. The browser's
|
|
25
|
+
// "dirty value flag" (WHATWG spec) means that once a user has typed into an
|
|
26
|
+
// input, the browser ignores attribute changes for display purposes. The
|
|
27
|
+
// attribute and the live .value property become independent surfaces.
|
|
28
|
+
//
|
|
29
|
+
// <select> — Setting/removing the "selected" attribute on <option> elements
|
|
30
|
+
// has no side effects on display or interaction.
|
|
31
|
+
//
|
|
32
|
+
// <input type="checkbox/radio"> — Setting/removing the "checked" attribute
|
|
33
|
+
// has no side effects.
|
|
34
|
+
//
|
|
35
|
+
// <textarea> — The hard case. Unlike <input>, a textarea stores its default
|
|
36
|
+
// value as child text nodes, not an attribute. Writing textContent while
|
|
37
|
+
// focused destroys cursor position, scroll, and selection. So instead, we
|
|
38
|
+
// write to a harmless "data-value" attribute on every keystroke (completely
|
|
39
|
+
// inert — no cursor, scroll, or reflow). At snapshot time, the onSnapshot
|
|
40
|
+
// hook reads data-value from the cloned textarea, writes it into the
|
|
41
|
+
// clone's textContent, and strips the attribute. The saved HTML is clean.
|
|
42
|
+
//
|
|
43
|
+
// The onSnapshot hook also serves as a safety net for all types, catching any
|
|
44
|
+
// values that weren't synced by the live listeners (e.g., a textarea that was
|
|
45
|
+
// never typed into but had its value set programmatically).
|
|
46
|
+
//
|
|
47
|
+
// Synergy with onclone (custom-attributes/onclone.js):
|
|
48
|
+
// When event-attrs is loaded, its cloneNode() intercept (onclone.js) patches
|
|
49
|
+
// data-value into textContent on cloned textareas automatically.
|
|
50
|
+
|
|
51
|
+
export default function enablePersistentFormInputValues(filterBySelector = "[persist]") {
|
|
52
|
+
const inputSelector = `input${filterBySelector}:not([type="password"]):not([type="hidden"]):not([type="file"])`;
|
|
53
|
+
const textareaSelector = `textarea${filterBySelector}`;
|
|
54
|
+
const selectSelector = `select${filterBySelector}`;
|
|
55
|
+
|
|
56
|
+
// --- Live DOM sync: keep attributes in sync on every user interaction ---
|
|
57
|
+
|
|
58
|
+
// Text-like inputs and textareas serialize on every keystroke; the shared
|
|
59
|
+
// serializer keeps it cursor-safe (value via the dirty flag, textarea via
|
|
60
|
+
// an inert data-value attribute).
|
|
61
|
+
document.addEventListener('input', (e) => {
|
|
62
|
+
const el = e.target;
|
|
63
|
+
if ((el.matches(inputSelector) && el.type !== 'checkbox' && el.type !== 'radio') ||
|
|
64
|
+
el.matches(textareaSelector)) {
|
|
65
|
+
serializeControlToAttributes(el);
|
|
66
|
+
}
|
|
67
|
+
}, true);
|
|
68
|
+
|
|
69
|
+
// Checkboxes, radios, and selects settle on change.
|
|
70
|
+
document.addEventListener('change', (e) => {
|
|
71
|
+
const el = e.target;
|
|
72
|
+
if ((el.matches(inputSelector) && (el.type === 'checkbox' || el.type === 'radio')) ||
|
|
73
|
+
el.matches(selectSelector)) {
|
|
74
|
+
serializeControlToAttributes(el);
|
|
75
|
+
}
|
|
76
|
+
}, true);
|
|
77
|
+
|
|
78
|
+
// --- Snapshot hook: final safety net at serialize time ---
|
|
79
|
+
|
|
80
|
+
// Safety net at serialize time: write each cloned control's attributes from
|
|
81
|
+
// the live element (the true source of truth — catches values set
|
|
82
|
+
// programmatically without firing an input event, and resolves textarea
|
|
83
|
+
// data-value into real textContent). Matched by index per selector; another
|
|
84
|
+
// hook mutating the clone could diverge the lists, hence the per-type loops.
|
|
85
|
+
onSnapshot((doc) => {
|
|
86
|
+
const finalize = (selector) => {
|
|
87
|
+
const live = document.querySelectorAll(selector);
|
|
88
|
+
const cloned = doc.querySelectorAll(selector);
|
|
89
|
+
cloned.forEach((c, i) => { if (live[i]) finalizeControlForSave(c, live[i]); });
|
|
90
|
+
};
|
|
91
|
+
finalize(inputSelector);
|
|
92
|
+
finalize(textareaSelector);
|
|
93
|
+
finalize(selectSelector);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Auto-initialize with default selector
|
|
98
|
+
export function init() {
|
|
99
|
+
enablePersistentFormInputValues("[persist]");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Auto-init when module is imported
|
|
103
|
+
init();
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* save-core.js — Network save functionality
|
|
3
|
+
*
|
|
4
|
+
* This module handles sending page contents to the server.
|
|
5
|
+
* It uses snapshot.js for capturing the DOM state.
|
|
6
|
+
*
|
|
7
|
+
* For full save system with state management, use save.js instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isEditMode } from "./is-edit-mode.js";
|
|
11
|
+
import { consumeUserDriven, markUserDriven } from "../lib/user-gesture.js";
|
|
12
|
+
import {
|
|
13
|
+
captureForSave,
|
|
14
|
+
beforeSave,
|
|
15
|
+
getPageContents,
|
|
16
|
+
onSnapshot,
|
|
17
|
+
onPrepareForSave
|
|
18
|
+
} from "./snapshot.js";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// STATE
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
let saveInProgress = false;
|
|
25
|
+
const saveEndpoint = '/_/save';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a save is currently in progress.
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isSaveInProgress() {
|
|
32
|
+
return saveInProgress;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the save endpoint for the current host.
|
|
37
|
+
*
|
|
38
|
+
* htmlclay (the local Go app for .htmlclay files) authenticates each save with
|
|
39
|
+
* a per-file token injected as the `htmlclaytoken` attribute on <html>, and
|
|
40
|
+
* carries it in the URL path so the same token works for fetch and EventSource.
|
|
41
|
+
* When that attribute is present, save to `/_/save/{token}`; otherwise use the
|
|
42
|
+
* bare `/_/save` (platform and Hyperclay Local, which authenticate by cookie).
|
|
43
|
+
*
|
|
44
|
+
* @returns {string} The endpoint URL for the current save.
|
|
45
|
+
*/
|
|
46
|
+
function getSaveEndpoint() {
|
|
47
|
+
const htmlclayToken = document.documentElement.getAttribute('htmlclaytoken');
|
|
48
|
+
return htmlclayToken ? `${saveEndpoint}/${htmlclayToken}` : saveEndpoint;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// RE-EXPORTS FROM SNAPSHOT (for backwards compat)
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
export { beforeSave, getPageContents, onSnapshot, onPrepareForSave };
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// INTERNAL: GET PAGE CONTENTS
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the current page contents as HTML string for saving.
|
|
63
|
+
* Emits snapshot-ready event for live-sync.
|
|
64
|
+
*
|
|
65
|
+
* @returns {string} HTML string of current page
|
|
66
|
+
*/
|
|
67
|
+
function getContentsForSave() {
|
|
68
|
+
// Emit for live-sync when actually saving
|
|
69
|
+
return captureForSave({ emitForSync: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// SAVE FUNCTIONS
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save the current page contents to the server.
|
|
78
|
+
*
|
|
79
|
+
* Returns a Promise that resolves with {msg, msgType} — the same object
|
|
80
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
81
|
+
* msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
|
|
82
|
+
*
|
|
83
|
+
* @param {Function} callback - Called with {msg, msgType} on completion
|
|
84
|
+
* msgType will be 'success', 'error', or 'skipped'
|
|
85
|
+
* @returns {Promise<{msg: string, msgType: string}>}
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* // Callback form (unchanged)
|
|
89
|
+
* savePage(({msg, msgType}) => {
|
|
90
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // Promise form
|
|
95
|
+
* const {msg, msgType} = await savePage();
|
|
96
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
97
|
+
*/
|
|
98
|
+
export function savePage(callback = () => {}) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
if (saveInProgress) {
|
|
101
|
+
const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
|
|
102
|
+
callback(skipped);
|
|
103
|
+
return resolve(skipped);
|
|
104
|
+
}
|
|
105
|
+
if (!isEditMode && !window.clay?.testMode) {
|
|
106
|
+
const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
|
|
107
|
+
callback(skipped);
|
|
108
|
+
return resolve(skipped);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let currentContents;
|
|
112
|
+
try {
|
|
113
|
+
currentContents = getContentsForSave();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('savePage: getContentsForSave failed', err);
|
|
116
|
+
const result = { msg: err.message, msgType: "error" };
|
|
117
|
+
callback(result);
|
|
118
|
+
return resolve(result);
|
|
119
|
+
}
|
|
120
|
+
saveInProgress = true;
|
|
121
|
+
|
|
122
|
+
// Test mode: skip network request, return mock success
|
|
123
|
+
if (window.clay?.testMode) {
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
saveInProgress = false;
|
|
126
|
+
const result = { msg: "Test mode: save skipped", msgType: "success" };
|
|
127
|
+
if (typeof callback === 'function') {
|
|
128
|
+
callback(result);
|
|
129
|
+
}
|
|
130
|
+
resolve(result);
|
|
131
|
+
}, 0);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Add timeout - abort if server doesn't respond within 12 seconds
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
138
|
+
|
|
139
|
+
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
140
|
+
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
141
|
+
window.location.hostname === '127.0.0.1';
|
|
142
|
+
|
|
143
|
+
// Read-and-reset the data-guard provenance bit at the ACTUAL send (past the
|
|
144
|
+
// early returns above), so it's never consumed on a save that never ships.
|
|
145
|
+
const userDriven = consumeUserDriven();
|
|
146
|
+
|
|
147
|
+
const fetchOptions = {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
credentials: 'include',
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
155
|
+
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
156
|
+
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
157
|
+
fetchOptions.body = JSON.stringify({
|
|
158
|
+
content: currentContents,
|
|
159
|
+
snapshotHtml: window.__hyperclaySnapshotHtml,
|
|
160
|
+
userDriven
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
// Platform: send plain text as before
|
|
164
|
+
fetchOptions.body = currentContents;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fetch(getSaveEndpoint(), fetchOptions)
|
|
168
|
+
.then(res => {
|
|
169
|
+
clearTimeout(timeoutId);
|
|
170
|
+
return res.json().then(data => {
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
173
|
+
}
|
|
174
|
+
return data;
|
|
175
|
+
});
|
|
176
|
+
})
|
|
177
|
+
.then(data => {
|
|
178
|
+
// Clear the snapshot only once the save actually landed (a failed save
|
|
179
|
+
// keeps it so a retry still carries the provenance snapshot).
|
|
180
|
+
window.__hyperclaySnapshotHtml = null;
|
|
181
|
+
const result = { msg: data.msg, msgType: data.msgType || 'success' };
|
|
182
|
+
if (typeof callback === 'function') {
|
|
183
|
+
callback(result);
|
|
184
|
+
}
|
|
185
|
+
resolve(result);
|
|
186
|
+
})
|
|
187
|
+
.catch(err => {
|
|
188
|
+
clearTimeout(timeoutId);
|
|
189
|
+
console.error('Failed to save page:', err);
|
|
190
|
+
|
|
191
|
+
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
192
|
+
// save still reports the human gesture instead of reading as background.
|
|
193
|
+
if (userDriven) markUserDriven();
|
|
194
|
+
|
|
195
|
+
const msg = err.name === 'AbortError'
|
|
196
|
+
? 'Server not responding'
|
|
197
|
+
: 'Save failed';
|
|
198
|
+
|
|
199
|
+
const result = { msg, msgType: "error" };
|
|
200
|
+
if (typeof callback === 'function') {
|
|
201
|
+
callback(result);
|
|
202
|
+
}
|
|
203
|
+
resolve(result);
|
|
204
|
+
})
|
|
205
|
+
.finally(() => {
|
|
206
|
+
clearTimeout(timeoutId);
|
|
207
|
+
saveInProgress = false;
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Save specific HTML content to the server.
|
|
214
|
+
*
|
|
215
|
+
* Returns a Promise that resolves with {err, data} — same arguments
|
|
216
|
+
* passed to the callback. Promise never rejects; errors resolve with
|
|
217
|
+
* truthy err. Skipped early-returns resolve with data.msgType: 'skipped'.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} html - HTML string to save
|
|
220
|
+
* @param {Function} callback - Called with (err, data) on completion
|
|
221
|
+
* @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* // Callback form (unchanged)
|
|
225
|
+
* saveHtml(myHtml, (err, data) => {
|
|
226
|
+
* if (err) console.error('Save failed:', err);
|
|
227
|
+
* });
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* // Promise form
|
|
231
|
+
* const {err, data} = await saveHtml(myHtml);
|
|
232
|
+
* if (err) console.error('Save failed:', err);
|
|
233
|
+
*/
|
|
234
|
+
export function saveHtml(html, callback = () => {}) {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
if (!isEditMode || saveInProgress) {
|
|
237
|
+
const data = {
|
|
238
|
+
msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
|
|
239
|
+
msgType: 'skipped'
|
|
240
|
+
};
|
|
241
|
+
callback(null, data);
|
|
242
|
+
return resolve({ err: null, data });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
saveInProgress = true;
|
|
246
|
+
|
|
247
|
+
// Test mode: skip network request, return mock success
|
|
248
|
+
if (window.clay?.testMode) {
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
saveInProgress = false;
|
|
251
|
+
const data = { msg: "Test mode: save skipped", msgType: "success" };
|
|
252
|
+
if (typeof callback === 'function') {
|
|
253
|
+
callback(null, data);
|
|
254
|
+
}
|
|
255
|
+
resolve({ err: null, data });
|
|
256
|
+
}, 0);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add timeout - abort if server doesn't respond within 12 seconds
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
263
|
+
|
|
264
|
+
// Check if running on Hyperclay Local - send JSON with both versions for platform sync
|
|
265
|
+
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
266
|
+
window.location.hostname === '127.0.0.1';
|
|
267
|
+
|
|
268
|
+
const userDriven = consumeUserDriven();
|
|
269
|
+
|
|
270
|
+
const fetchOptions = {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
credentials: 'include',
|
|
273
|
+
signal: controller.signal,
|
|
274
|
+
headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
278
|
+
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
279
|
+
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
280
|
+
fetchOptions.body = JSON.stringify({
|
|
281
|
+
content: html,
|
|
282
|
+
snapshotHtml: window.__hyperclaySnapshotHtml,
|
|
283
|
+
userDriven
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
// Platform: send plain text as before
|
|
287
|
+
fetchOptions.body = html;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fetch(getSaveEndpoint(), fetchOptions)
|
|
291
|
+
.then(res => {
|
|
292
|
+
clearTimeout(timeoutId);
|
|
293
|
+
return res.json().then(data => {
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
296
|
+
}
|
|
297
|
+
return data;
|
|
298
|
+
});
|
|
299
|
+
})
|
|
300
|
+
.then(data => {
|
|
301
|
+
// Clear the snapshot only once the save actually landed (a failed save
|
|
302
|
+
// keeps it so a retry still carries the provenance snapshot).
|
|
303
|
+
window.__hyperclaySnapshotHtml = null;
|
|
304
|
+
if (typeof callback === 'function') {
|
|
305
|
+
callback(null, data);
|
|
306
|
+
}
|
|
307
|
+
resolve({ err: null, data });
|
|
308
|
+
})
|
|
309
|
+
.catch(err => {
|
|
310
|
+
clearTimeout(timeoutId);
|
|
311
|
+
console.error('Failed to save page:', err);
|
|
312
|
+
|
|
313
|
+
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
314
|
+
// save still reports the human gesture instead of reading as background.
|
|
315
|
+
if (userDriven) markUserDriven();
|
|
316
|
+
|
|
317
|
+
// Normalize timeout errors
|
|
318
|
+
const error = err.name === 'AbortError'
|
|
319
|
+
? new Error('Server not responding')
|
|
320
|
+
: err;
|
|
321
|
+
|
|
322
|
+
if (typeof callback === 'function') {
|
|
323
|
+
callback(error);
|
|
324
|
+
}
|
|
325
|
+
resolve({ err: error, data: null });
|
|
326
|
+
})
|
|
327
|
+
.finally(() => {
|
|
328
|
+
clearTimeout(timeoutId);
|
|
329
|
+
saveInProgress = false;
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Fetch HTML from a URL and save it to replace the current page.
|
|
336
|
+
*
|
|
337
|
+
* Returns a Promise that resolves with {err, data} — same arguments
|
|
338
|
+
* passed to the callback. Promise never rejects.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} url - URL to fetch HTML from
|
|
341
|
+
* @param {Function} callback - Called with (err, data) on completion
|
|
342
|
+
* @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* // Callback form (unchanged)
|
|
346
|
+
* replacePageWith('/templates/blog.html', (err, data) => {
|
|
347
|
+
* if (err) console.error('Failed:', err);
|
|
348
|
+
* else window.location.reload();
|
|
349
|
+
* });
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* // Promise form
|
|
353
|
+
* const {err, data} = await replacePageWith('/templates/blog.html');
|
|
354
|
+
* if (!err) window.location.reload();
|
|
355
|
+
*/
|
|
356
|
+
export function replacePageWith(url, callback = () => {}) {
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
if (!isEditMode || saveInProgress) {
|
|
359
|
+
const data = {
|
|
360
|
+
msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
|
|
361
|
+
msgType: 'skipped'
|
|
362
|
+
};
|
|
363
|
+
callback(null, data);
|
|
364
|
+
return resolve({ err: null, data });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fetch(url)
|
|
368
|
+
.then(res => res.text())
|
|
369
|
+
.then(html => {
|
|
370
|
+
saveHtml(html, (err, data) => {
|
|
371
|
+
if (typeof callback === 'function') {
|
|
372
|
+
callback(err, data);
|
|
373
|
+
}
|
|
374
|
+
resolve({ err: err || null, data: data || null });
|
|
375
|
+
});
|
|
376
|
+
})
|
|
377
|
+
.catch(err => {
|
|
378
|
+
console.error('Failed to fetch template:', err);
|
|
379
|
+
if (typeof callback === 'function') {
|
|
380
|
+
callback(err);
|
|
381
|
+
}
|
|
382
|
+
resolve({ err, data: null });
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|