hyperclayjs 1.8.0 → 1.9.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/README.md +16 -12
- package/package.json +1 -1
- package/src/communication/live-sync.js +396 -0
- package/src/communication/sendMessage.js +2 -8
- package/src/core/autosave.js +6 -17
- package/src/core/enablePersistentFormInputValues.js +40 -45
- package/src/core/optionVisibility.js +7 -35
- package/src/core/savePageCore.js +100 -99
- package/src/core/snapshot.js +203 -0
- package/src/core/unsavedWarning.js +26 -0
- package/src/custom-attributes/ajaxElements.js +3 -10
- package/src/custom-attributes/onaftersave.js +5 -8
- package/src/dom-utilities/insertStyleTag.js +41 -18
- package/src/hyperclay.js +18 -1
- package/src/module-dependency-graph.json +110 -13
- package/src/ui/theModal.js +2 -0
- package/src/utilities/cacheBust.js +19 -0
- package/src/vendor/idiomorph.min.js +1 -0
package/README.md
CHANGED
|
@@ -57,24 +57,26 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
57
57
|
|
|
58
58
|
| Module | Size | Description |
|
|
59
59
|
|--------|------|-------------|
|
|
60
|
-
| autosave |
|
|
60
|
+
| autosave | 0.9KB | 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
|
-
| option-visibility | 5.
|
|
64
|
-
| persist | 2.
|
|
65
|
-
| save-core | 6.
|
|
63
|
+
| option-visibility | 5.3KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
|
|
64
|
+
| persist | 2.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
|
|
65
|
+
| save-core | 6.8KB | Basic save function only - hyperclay.savePage() |
|
|
66
66
|
| save-system | 7.1KB | Manual save: keyboard shortcut (CMD+S), save button, change tracking |
|
|
67
67
|
| save-toast | 0.9KB | Toast notifications for save events |
|
|
68
|
+
| snapshot | 7.5KB | Source of truth for page state - captures DOM snapshots for save and sync |
|
|
69
|
+
| unsaved-warning | 0.8KB | Warn before leaving page with unsaved changes |
|
|
68
70
|
|
|
69
71
|
### Custom Attributes (HTML enhancements)
|
|
70
72
|
|
|
71
73
|
| Module | Size | Description |
|
|
72
74
|
|--------|------|-------------|
|
|
73
|
-
| ajax-elements | 2.
|
|
75
|
+
| ajax-elements | 2.6KB | [ajax-form], [ajax-button] for async form submissions |
|
|
74
76
|
| dom-helpers | 6.2KB | el.nearest, el.val, el.text, el.exec, el.cycle |
|
|
75
77
|
| event-attrs | 4.1KB | [onclickaway], [onclone], [onpagemutation], [onrender] |
|
|
76
78
|
| input-helpers | 1.2KB | [prevent-enter], [autosize] for textareas |
|
|
77
|
-
| onaftersave |
|
|
79
|
+
| onaftersave | 1KB | [onaftersave] attribute - run JS when save status changes |
|
|
78
80
|
| sortable | 3.4KB | Drag-drop sorting with [sortable], lazy-loads ~118KB Sortable.js in edit mode |
|
|
79
81
|
|
|
80
82
|
### UI Components (User interface elements)
|
|
@@ -89,6 +91,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
89
91
|
|
|
90
92
|
| Module | Size | Description |
|
|
91
93
|
|--------|------|-------------|
|
|
94
|
+
| cache-bust | 0.6KB | Cache-bust href/src attributes |
|
|
92
95
|
| cookie | 1.4KB | Cookie management (often auto-included) |
|
|
93
96
|
| debounce | 0.4KB | Function debouncing |
|
|
94
97
|
| mutation | 13KB | DOM mutation observation (often auto-included) |
|
|
@@ -102,7 +105,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
102
105
|
| all-js | 14.4KB | Full DOM manipulation library |
|
|
103
106
|
| dom-ready | 0.4KB | DOM ready callback |
|
|
104
107
|
| form-data | 2KB | Extract form data as an object |
|
|
105
|
-
| style-injection | 1.
|
|
108
|
+
| style-injection | 1.9KB | Dynamic stylesheet injection |
|
|
106
109
|
|
|
107
110
|
### String Utilities (String manipulation helpers)
|
|
108
111
|
|
|
@@ -117,27 +120,28 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
117
120
|
| Module | Size | Description |
|
|
118
121
|
|--------|------|-------------|
|
|
119
122
|
| file-upload | 10.7KB | File upload with progress |
|
|
120
|
-
|
|
|
123
|
+
| live-sync | 12KB | Real-time DOM sync across browsers and with file system |
|
|
124
|
+
| send-message | 1.3KB | Message sending utility |
|
|
121
125
|
|
|
122
126
|
### Vendor Libraries (Third-party libraries)
|
|
123
127
|
|
|
124
128
|
| Module | Size | Description |
|
|
125
129
|
|--------|------|-------------|
|
|
126
|
-
| idiomorph | 8.
|
|
130
|
+
| idiomorph | 8.3KB | Efficient DOM morphing library |
|
|
127
131
|
|
|
128
132
|
## Presets
|
|
129
133
|
|
|
130
|
-
### Minimal (~30.
|
|
134
|
+
### Minimal (~30.4KB)
|
|
131
135
|
Essential features for basic editing
|
|
132
136
|
|
|
133
137
|
**Modules:** `save-core`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`
|
|
134
138
|
|
|
135
|
-
### Standard (~48.
|
|
139
|
+
### Standard (~48.4KB)
|
|
136
140
|
Standard feature set for most use cases
|
|
137
141
|
|
|
138
142
|
**Modules:** `save-core`, `save-system`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`
|
|
139
143
|
|
|
140
|
-
### Everything (~
|
|
144
|
+
### Everything (~176.5KB)
|
|
141
145
|
All available features
|
|
142
146
|
|
|
143
147
|
Includes all available modules across all categories.
|
package/package.json
CHANGED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live-sync.js — Real-time sync between admin users
|
|
3
|
+
*
|
|
4
|
+
* HOW IT WORKS:
|
|
5
|
+
*
|
|
6
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ 1. LISTEN snapshot-ready event from save │
|
|
8
|
+
* │ (body with form values, no strip) │
|
|
9
|
+
* └─────────────────────────────────────────────────────────┘
|
|
10
|
+
* │
|
|
11
|
+
* ▼
|
|
12
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
13
|
+
* │ 2. SEND POST body to /live-sync/save │
|
|
14
|
+
* │ (debounced, skip if unchanged) │
|
|
15
|
+
* └─────────────────────────────────────────────────────────┘
|
|
16
|
+
* │
|
|
17
|
+
* ▼
|
|
18
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
19
|
+
* │ 3. RECEIVE SSE stream from server │
|
|
20
|
+
* │ (other clients' changes) │
|
|
21
|
+
* └─────────────────────────────────────────────────────────┘
|
|
22
|
+
* │
|
|
23
|
+
* ▼
|
|
24
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
25
|
+
* │ 4. MORPH Idiomorph to update DOM │
|
|
26
|
+
* │ (preserves focus, input values) │
|
|
27
|
+
* └─────────────────────────────────────────────────────────┘
|
|
28
|
+
*
|
|
29
|
+
* DEPENDS ON: Idiomorph (for intelligent DOM morphing)
|
|
30
|
+
* INTEGRATES WITH: snapshot.js (receives snapshot-ready events)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
class LiveSync {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.sse = null;
|
|
36
|
+
this.currentFile = null;
|
|
37
|
+
this.lastHeadHash = null;
|
|
38
|
+
this.lastBodyHtml = null;
|
|
39
|
+
this.clientId = this.generateClientId();
|
|
40
|
+
this.debounceMs = 150;
|
|
41
|
+
this.debounceTimer = null;
|
|
42
|
+
this.isPaused = false;
|
|
43
|
+
this.isDestroyed = false;
|
|
44
|
+
|
|
45
|
+
// Store handler reference for cleanup
|
|
46
|
+
this._snapshotHandler = null;
|
|
47
|
+
|
|
48
|
+
// Callbacks
|
|
49
|
+
this.onConnect = null;
|
|
50
|
+
this.onDisconnect = null;
|
|
51
|
+
this.onUpdate = null;
|
|
52
|
+
this.onError = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate or retrieve a persistent client ID
|
|
57
|
+
*/
|
|
58
|
+
generateClientId() {
|
|
59
|
+
let id = null;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
id = localStorage.getItem('livesync-client-id');
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// localStorage might not be available
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!id) {
|
|
68
|
+
id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
|
|
69
|
+
try {
|
|
70
|
+
localStorage.setItem('livesync-client-id', id);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// That's okay
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Start the LiveSync system
|
|
81
|
+
* Can be called after stop() to restart with a new file
|
|
82
|
+
*/
|
|
83
|
+
start(file = null) {
|
|
84
|
+
// Reset destroyed flag to allow restart after stop()
|
|
85
|
+
this.isDestroyed = false;
|
|
86
|
+
|
|
87
|
+
// Prevent double-connect: clean up existing connection first
|
|
88
|
+
if (this.sse || this._snapshotHandler) {
|
|
89
|
+
this.cleanup();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.currentFile = file || this.detectCurrentFile();
|
|
93
|
+
|
|
94
|
+
if (!this.currentFile) {
|
|
95
|
+
console.warn('[LiveSync] No file detected');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Reset state for new connection
|
|
100
|
+
this.lastHeadHash = null;
|
|
101
|
+
this.lastBodyHtml = null;
|
|
102
|
+
|
|
103
|
+
console.log('[LiveSync] Starting for:', this.currentFile);
|
|
104
|
+
this.connect();
|
|
105
|
+
this.listenForSnapshots();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Clean up resources without marking as destroyed
|
|
110
|
+
* Used internally by start() to prevent double-connect
|
|
111
|
+
*/
|
|
112
|
+
cleanup() {
|
|
113
|
+
if (this.sse) {
|
|
114
|
+
this.sse.close();
|
|
115
|
+
this.sse = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this._snapshotHandler) {
|
|
119
|
+
document.removeEventListener('hyperclay:snapshot-ready', this._snapshotHandler);
|
|
120
|
+
this._snapshotHandler = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
clearTimeout(this.debounceTimer);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Auto-detect the current site identifier from the URL
|
|
128
|
+
* Returns site ID without .html extension
|
|
129
|
+
*
|
|
130
|
+
* Handles:
|
|
131
|
+
* - / -> index
|
|
132
|
+
* - /about -> about
|
|
133
|
+
* - /about.html -> about
|
|
134
|
+
* - /about/ -> about/index
|
|
135
|
+
* - /pages/contact -> pages/contact
|
|
136
|
+
* - /pages/contact/ -> pages/contact/index
|
|
137
|
+
*/
|
|
138
|
+
detectCurrentFile() {
|
|
139
|
+
let pathname = window.location.pathname;
|
|
140
|
+
|
|
141
|
+
// Root path
|
|
142
|
+
if (pathname === '/') {
|
|
143
|
+
return 'index';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Remove leading slash
|
|
147
|
+
pathname = pathname.replace(/^\//, '');
|
|
148
|
+
|
|
149
|
+
// Handle trailing slash -> directory index
|
|
150
|
+
if (pathname.endsWith('/')) {
|
|
151
|
+
return pathname + 'index';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Remove .html extension if present
|
|
155
|
+
if (pathname.endsWith('.html')) {
|
|
156
|
+
return pathname.slice(0, -5);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Already a site identifier
|
|
160
|
+
return pathname;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Connect to the SSE endpoint
|
|
165
|
+
* Uses native EventSource reconnection behavior
|
|
166
|
+
*/
|
|
167
|
+
connect() {
|
|
168
|
+
if (this.isDestroyed) return;
|
|
169
|
+
|
|
170
|
+
const url = `/live-sync/stream?file=${encodeURIComponent(this.currentFile)}`;
|
|
171
|
+
this.sse = new EventSource(url);
|
|
172
|
+
|
|
173
|
+
this.sse.onopen = () => {
|
|
174
|
+
console.log('[LiveSync] Connected');
|
|
175
|
+
if (this.onConnect) this.onConnect();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.sse.onmessage = (event) => {
|
|
179
|
+
const data = JSON.parse(event.data);
|
|
180
|
+
|
|
181
|
+
// Handle error events from server
|
|
182
|
+
if (data.error) {
|
|
183
|
+
console.error('[LiveSync] Server error:', data.error);
|
|
184
|
+
if (this.onError) this.onError(new Error(data.error));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { body, headHash, sender } = data;
|
|
189
|
+
|
|
190
|
+
// Ignore own changes
|
|
191
|
+
if (sender === this.clientId) return;
|
|
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');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check for head changes -> full reload
|
|
200
|
+
// Only compare when BOTH hashes exist (server must send headHash)
|
|
201
|
+
// Only set lastHeadHash when incoming hash is valid
|
|
202
|
+
if (headHash) {
|
|
203
|
+
if (this.lastHeadHash && headHash !== this.lastHeadHash) {
|
|
204
|
+
console.log('[LiveSync] Head changed, reloading');
|
|
205
|
+
location.reload();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.lastHeadHash = headHash;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log('[LiveSync] Received update from:', sender);
|
|
212
|
+
this.applyUpdate(body);
|
|
213
|
+
if (this.onUpdate) this.onUpdate({ body, sender });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Native EventSource auto-reconnects on transient errors
|
|
217
|
+
// We just surface the status via callbacks
|
|
218
|
+
this.sse.onerror = () => {
|
|
219
|
+
if (this.sse.readyState === EventSource.CONNECTING) {
|
|
220
|
+
console.log('[LiveSync] Reconnecting...');
|
|
221
|
+
} else if (this.sse.readyState === EventSource.CLOSED) {
|
|
222
|
+
console.log('[LiveSync] Connection closed');
|
|
223
|
+
if (this.onError) this.onError(new Error('Connection closed'));
|
|
224
|
+
}
|
|
225
|
+
if (this.onDisconnect) this.onDisconnect();
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Listen for snapshot-ready events from the save system.
|
|
231
|
+
* This replaces DOM observation — we sync when save happens.
|
|
232
|
+
*/
|
|
233
|
+
listenForSnapshots() {
|
|
234
|
+
this._snapshotHandler = (event) => {
|
|
235
|
+
if (this.isPaused) return;
|
|
236
|
+
|
|
237
|
+
const { body } = event.detail;
|
|
238
|
+
if (!body) return;
|
|
239
|
+
|
|
240
|
+
this.sendBody(body);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
document.addEventListener('hyperclay:snapshot-ready', this._snapshotHandler);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send body HTML to the server (debounced)
|
|
248
|
+
* Only updates lastBodyHtml after successful save
|
|
249
|
+
*/
|
|
250
|
+
sendBody(body) {
|
|
251
|
+
clearTimeout(this.debounceTimer);
|
|
252
|
+
|
|
253
|
+
this.debounceTimer = setTimeout(() => {
|
|
254
|
+
// Skip if unchanged
|
|
255
|
+
if (body === this.lastBodyHtml) return;
|
|
256
|
+
|
|
257
|
+
console.log('[LiveSync] Sending update');
|
|
258
|
+
|
|
259
|
+
// Compute head hash to send to server (for hosted mode)
|
|
260
|
+
const headHash = this.computeHeadHash();
|
|
261
|
+
this.lastHeadHash = headHash; // Track local head changes
|
|
262
|
+
|
|
263
|
+
fetch('/live-sync/save', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
file: this.currentFile,
|
|
268
|
+
body: body,
|
|
269
|
+
sender: this.clientId,
|
|
270
|
+
headHash: headHash
|
|
271
|
+
})
|
|
272
|
+
}).then(response => {
|
|
273
|
+
if (response.ok) {
|
|
274
|
+
// Only update lastBodyHtml after successful save
|
|
275
|
+
this.lastBodyHtml = body;
|
|
276
|
+
} else {
|
|
277
|
+
// Log non-OK responses but don't suppress future sends
|
|
278
|
+
console.warn('[LiveSync] Save returned status:', response.status);
|
|
279
|
+
}
|
|
280
|
+
}).catch(err => {
|
|
281
|
+
// Network error - don't update lastBodyHtml so next mutation will retry
|
|
282
|
+
console.error('[LiveSync] Save failed:', err);
|
|
283
|
+
if (this.onError) this.onError(err);
|
|
284
|
+
});
|
|
285
|
+
}, this.debounceMs);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Compute MD5-like hash of head content (first 8 hex chars)
|
|
290
|
+
* Uses a simple string hash since we don't have crypto in browser
|
|
291
|
+
*/
|
|
292
|
+
computeHeadHash() {
|
|
293
|
+
const head = document.head?.innerHTML;
|
|
294
|
+
if (!head) return null;
|
|
295
|
+
|
|
296
|
+
// Simple hash function (djb2)
|
|
297
|
+
let hash = 5381;
|
|
298
|
+
for (let i = 0; i < head.length; i++) {
|
|
299
|
+
hash = ((hash << 5) + hash) + head.charCodeAt(i);
|
|
300
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
301
|
+
}
|
|
302
|
+
// Convert to hex and take first 8 chars
|
|
303
|
+
return Math.abs(hash).toString(16).padStart(8, '0').slice(0, 8);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Apply an update received from the server
|
|
308
|
+
* Guards against non-string values
|
|
309
|
+
*/
|
|
310
|
+
applyUpdate(bodyHtml) {
|
|
311
|
+
// Guard against non-string values
|
|
312
|
+
if (typeof bodyHtml !== 'string') {
|
|
313
|
+
console.error('[LiveSync] applyUpdate called with non-string value, ignoring');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.isPaused = true;
|
|
318
|
+
this.lastBodyHtml = bodyHtml;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const temp = document.createElement('div');
|
|
322
|
+
temp.innerHTML = bodyHtml;
|
|
323
|
+
|
|
324
|
+
// Use Idiomorph if available
|
|
325
|
+
const morphFn = window.Idiomorph?.morph;
|
|
326
|
+
|
|
327
|
+
if (morphFn) {
|
|
328
|
+
morphFn(document.body, temp, {
|
|
329
|
+
morphStyle: 'innerHTML',
|
|
330
|
+
ignoreActiveValue: true
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
// Fallback to innerHTML
|
|
334
|
+
console.warn('[LiveSync] Idiomorph not available, using innerHTML fallback');
|
|
335
|
+
document.body.innerHTML = bodyHtml;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.rehydrateFormState(document.body);
|
|
339
|
+
} finally {
|
|
340
|
+
this.isPaused = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Sync form control attributes to properties after DOM morph
|
|
346
|
+
*/
|
|
347
|
+
rehydrateFormState(container) {
|
|
348
|
+
const focused = document.activeElement;
|
|
349
|
+
|
|
350
|
+
// Text inputs and textareas
|
|
351
|
+
container.querySelectorAll('input[value], textarea[value]').forEach(el => {
|
|
352
|
+
if (el === focused) return;
|
|
353
|
+
el.value = el.getAttribute('value') || '';
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Checkboxes and radios
|
|
357
|
+
container.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => {
|
|
358
|
+
if (el === focused) return;
|
|
359
|
+
el.checked = el.hasAttribute('checked');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Select dropdowns
|
|
363
|
+
container.querySelectorAll('select').forEach(select => {
|
|
364
|
+
if (select === focused) return;
|
|
365
|
+
select.querySelectorAll('option').forEach(opt => {
|
|
366
|
+
opt.selected = opt.hasAttribute('selected');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Stop LiveSync and clean up resources
|
|
373
|
+
* Can call start() again to restart
|
|
374
|
+
*/
|
|
375
|
+
stop() {
|
|
376
|
+
this.cleanup();
|
|
377
|
+
this.isDestroyed = true;
|
|
378
|
+
console.log('[LiveSync] Stopped');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Singleton instance
|
|
383
|
+
const liveSync = new LiveSync();
|
|
384
|
+
|
|
385
|
+
// Auto-initialize when DOM is ready
|
|
386
|
+
if (typeof window !== 'undefined') {
|
|
387
|
+
if (document.readyState === 'loading') {
|
|
388
|
+
document.addEventListener('DOMContentLoaded', () => liveSync.start());
|
|
389
|
+
} else {
|
|
390
|
+
liveSync.start();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Export for hyperclayjs module system
|
|
395
|
+
export { liveSync };
|
|
396
|
+
export default liveSync;
|
|
@@ -5,17 +5,11 @@ import toast from "../ui/toast.js";
|
|
|
5
5
|
function sendMessage(eventOrObj, successMessage = "Successfully sent", callback) {
|
|
6
6
|
let form;
|
|
7
7
|
let data;
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
if (eventOrObj instanceof Event) {
|
|
10
10
|
eventOrObj.preventDefault();
|
|
11
11
|
form = eventOrObj.target.closest('form');
|
|
12
|
-
|
|
13
|
-
if (!form) {
|
|
14
|
-
toast('No form found for this element', 'error');
|
|
15
|
-
return Promise.reject('No form found');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
data = getDataFromForm(form);
|
|
12
|
+
data = form ? getDataFromForm(form) : {};
|
|
19
13
|
} else {
|
|
20
14
|
data = eventOrObj;
|
|
21
15
|
if (this?.closest) {
|
package/src/core/autosave.js
CHANGED
|
@@ -2,18 +2,17 @@
|
|
|
2
2
|
* Auto-save system for Hyperclay
|
|
3
3
|
*
|
|
4
4
|
* Automatically saves page on DOM changes with throttling.
|
|
5
|
-
* Warns before leaving page with unsaved changes.
|
|
6
5
|
*
|
|
7
6
|
* Requires the 'save-system' module to be loaded first.
|
|
8
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* Recommended companion modules:
|
|
9
|
+
* - 'unsaved-warning' - Warn before leaving with unsaved changes (required for beforeunload)
|
|
10
|
+
* - 'save-toast' - Show toast notifications on save events
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import Mutation from "../utilities/mutation.js";
|
|
12
|
-
import { isEditMode
|
|
13
|
-
import {
|
|
14
|
-
savePageThrottled,
|
|
15
|
-
getUnsavedChanges
|
|
16
|
-
} from "./savePage.js";
|
|
14
|
+
import { isEditMode } from "./isAdminOfCurrentResource.js";
|
|
15
|
+
import { savePageThrottled } from "./savePage.js";
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Initialize auto-save on DOM changes
|
|
@@ -28,16 +27,6 @@ function initSavePageOnChange() {
|
|
|
28
27
|
});
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
/**
|
|
32
|
-
* Warn before leaving page with unsaved changes
|
|
33
|
-
*/
|
|
34
|
-
window.addEventListener('beforeunload', (event) => {
|
|
35
|
-
if (getUnsavedChanges() && isOwner) {
|
|
36
|
-
event.preventDefault();
|
|
37
|
-
event.returnValue = '';
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
|
|
41
30
|
function init() {
|
|
42
31
|
if (!isEditMode) return;
|
|
43
32
|
initSavePageOnChange();
|
|
@@ -1,57 +1,52 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { onSnapshot } from './snapshot.js';
|
|
2
2
|
|
|
3
3
|
// <input type="checkbox" persist>
|
|
4
4
|
export default function enablePersistentFormInputValues(filterBySelector = "[persist]") {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const elem = event.target;
|
|
9
|
-
if (elem.matches(selector) && !(elem.type === 'checkbox' || elem.type === 'radio')) {
|
|
10
|
-
if (elem.tagName.toLowerCase() === 'textarea') {
|
|
11
|
-
// Store in value attribute instead of textContent to preserve cursor position
|
|
12
|
-
elem.setAttribute('value', elem.value);
|
|
13
|
-
} else {
|
|
14
|
-
elem.setAttribute('value', elem.value);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
});
|
|
5
|
+
const inputSelector = `input${filterBySelector}:not([type="password"]):not([type="hidden"]):not([type="file"])`;
|
|
6
|
+
const textareaSelector = `textarea${filterBySelector}`;
|
|
7
|
+
const selectSelector = `select${filterBySelector}`;
|
|
18
8
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
9
|
+
// Use onSnapshot so form values are synced for both save AND live-sync
|
|
10
|
+
onSnapshot((doc) => {
|
|
11
|
+
// Sync text inputs
|
|
12
|
+
const liveInputs = document.querySelectorAll(inputSelector);
|
|
13
|
+
const clonedInputs = doc.querySelectorAll(inputSelector);
|
|
14
|
+
clonedInputs.forEach((cloned, i) => {
|
|
15
|
+
const live = liveInputs[i];
|
|
16
|
+
if (live.type === 'checkbox' || live.type === 'radio') {
|
|
17
|
+
if (live.checked) {
|
|
18
|
+
cloned.setAttribute('checked', '');
|
|
26
19
|
} else {
|
|
27
|
-
|
|
20
|
+
cloned.removeAttribute('checked');
|
|
28
21
|
}
|
|
22
|
+
} else {
|
|
23
|
+
cloned.setAttribute('value', live.value);
|
|
29
24
|
}
|
|
30
|
-
|
|
31
|
-
else if (elem.tagName.toLowerCase() === 'select') {
|
|
32
|
-
// Remove selected from all options
|
|
33
|
-
const options = elem.querySelectorAll('option');
|
|
34
|
-
options.forEach(option => option.removeAttribute('selected'));
|
|
25
|
+
});
|
|
35
26
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} else if (elem.selectedIndex >= 0) {
|
|
43
|
-
options[elem.selectedIndex].setAttribute('selected', '');
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
});
|
|
27
|
+
// Sync textareas
|
|
28
|
+
const liveTextareas = document.querySelectorAll(textareaSelector);
|
|
29
|
+
const clonedTextareas = doc.querySelectorAll(textareaSelector);
|
|
30
|
+
clonedTextareas.forEach((cloned, i) => {
|
|
31
|
+
cloned.textContent = liveTextareas[i].value;
|
|
32
|
+
});
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
34
|
+
// Sync selects
|
|
35
|
+
const liveSelects = document.querySelectorAll(selectSelector);
|
|
36
|
+
const clonedSelects = doc.querySelectorAll(selectSelector);
|
|
37
|
+
clonedSelects.forEach((cloned, i) => {
|
|
38
|
+
const live = liveSelects[i];
|
|
39
|
+
const clonedOptions = cloned.querySelectorAll('option');
|
|
40
|
+
clonedOptions.forEach(opt => opt.removeAttribute('selected'));
|
|
41
|
+
|
|
42
|
+
if (live.multiple) {
|
|
43
|
+
Array.from(live.selectedOptions).forEach(opt => {
|
|
44
|
+
const idx = Array.from(live.options).indexOf(opt);
|
|
45
|
+
if (clonedOptions[idx]) clonedOptions[idx].setAttribute('selected', '');
|
|
46
|
+
});
|
|
47
|
+
} else if (live.selectedIndex >= 0 && clonedOptions[live.selectedIndex]) {
|
|
48
|
+
clonedOptions[live.selectedIndex].setAttribute('selected', '');
|
|
49
|
+
}
|
|
55
50
|
});
|
|
56
51
|
});
|
|
57
52
|
}
|