hyperclayjs 1.27.1 → 1.28.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 +6 -5
- package/package.json +1 -1
- package/src/communication/live-sync.js +300 -40
- package/src/hyperclay.js +5 -1
- package/src/ui/prompts.js +4 -6
- package/src/ui/theModal.js +17 -0
- package/src/utilities/mutation.js +26 -2
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hypercms.vendor.js +374 -0
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
87
87
|
| Module | Size | Description |
|
|
88
88
|
|--------|------|-------------|
|
|
89
89
|
| dialogs | 8.5KB | ask(), consent(), tell(), snippet() dialog functions |
|
|
90
|
-
| the-modal | 22.
|
|
90
|
+
| the-modal | 22.8KB | Full modal window creation system - window.theModal |
|
|
91
91
|
| toast | 15.8KB | Success/error message notifications, toast(msg, msgType) |
|
|
92
92
|
|
|
93
93
|
### Utilities (Core utilities (often auto-included))
|
|
@@ -97,7 +97,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
97
97
|
| cache-bust | 0.6KB | Cache-bust href/src attributes |
|
|
98
98
|
| cookie | 1.4KB | Cookie management (often auto-included) |
|
|
99
99
|
| debounce | 0.7KB | Function debouncing |
|
|
100
|
-
| mutation |
|
|
100
|
+
| mutation | 14.7KB | DOM mutation observation (often auto-included) |
|
|
101
101
|
| nearest | 3.4KB | Find nearest elements (often auto-included) |
|
|
102
102
|
| throttle | 1.3KB | Function throttling |
|
|
103
103
|
|
|
@@ -123,14 +123,15 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
123
123
|
| Module | Size | Description |
|
|
124
124
|
|--------|------|-------------|
|
|
125
125
|
| file-upload | 11.3KB | File upload with progress |
|
|
126
|
-
| live-sync |
|
|
126
|
+
| live-sync | 25.3KB | Real-time DOM sync across browsers |
|
|
127
127
|
| send-message | 1.3KB | Message sending utility |
|
|
128
128
|
|
|
129
129
|
### Vendor Libraries (Third-party libraries)
|
|
130
130
|
|
|
131
131
|
| Module | Size | Description |
|
|
132
132
|
|--------|------|-------------|
|
|
133
|
-
| hyper-morph |
|
|
133
|
+
| hyper-morph | 18.7KB | DOM morphing with content-based element matching |
|
|
134
|
+
| hypercms | 88.7KB | Live edit-in-place CMS sidebar driven by a hyper-html-api rules tag. Pairs with [sortable] and [hyper-morph]. |
|
|
134
135
|
|
|
135
136
|
## Presets
|
|
136
137
|
|
|
@@ -144,7 +145,7 @@ Standard feature set for most use cases
|
|
|
144
145
|
|
|
145
146
|
**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
147
|
|
|
147
|
-
### Everything (~
|
|
148
|
+
### Everything (~337.3KB)
|
|
148
149
|
All available features
|
|
149
150
|
|
|
150
151
|
Includes all available modules across all categories.
|
package/package.json
CHANGED
|
@@ -50,16 +50,31 @@ class LiveSync {
|
|
|
50
50
|
// High-water mark of server seqs we've seen on this channel (own echoes
|
|
51
51
|
// count too). Server-broadcast payloads carry a monotonic seq
|
|
52
52
|
// (Date.now()-based). We drop anything <= this to guard against rare
|
|
53
|
-
// cases where a stale message lands after a newer one
|
|
53
|
+
// cases where a stale message lands after a newer one, e.g. buffered
|
|
54
54
|
// replay, alt backend after reconnect, or an own-save echo arriving
|
|
55
55
|
// after a peer's newer broadcast.
|
|
56
56
|
this.lastSeenSeq = 0;
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
58
|
+
// rAF-paced single-flight queue. Incoming updates overwrite a pending
|
|
59
|
+
// slot; on each animation frame, if a slot is set and no morph is in
|
|
60
|
+
// flight, run one morph against the latest pending payload. Burst
|
|
61
|
+
// arrivals collapse to one morph per frame, keeping the receiver from
|
|
62
|
+
// falling behind under load. A morph with external scripts returns a
|
|
63
|
+
// Promise, so the in-flight flag prevents overlap.
|
|
64
|
+
this._pendingHtml = null;
|
|
65
|
+
this._pendingSeq = null;
|
|
66
|
+
this._pendingIdentityMap = null;
|
|
67
|
+
this._morphInFlight = false;
|
|
68
|
+
this._rafHandle = null;
|
|
69
|
+
|
|
70
|
+
// Identity tracking for content-based morphing across live-sync updates.
|
|
71
|
+
// Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
|
|
72
|
+
// the DOM, never serialized into saved HTML. The WeakMap holds them
|
|
73
|
+
// against the live elements so that the next save can produce the same
|
|
74
|
+
// identityMap, and afterNodeMorphed transfers IDs from incoming parsed
|
|
75
|
+
// elements onto the live elements they morphed into.
|
|
76
|
+
this.idCounter = this._loadIdCounter();
|
|
77
|
+
this.liveWeakMap = new WeakMap();
|
|
63
78
|
|
|
64
79
|
// Callbacks
|
|
65
80
|
this.onConnect = null;
|
|
@@ -127,7 +142,6 @@ class LiveSync {
|
|
|
127
142
|
// Reset state for new connection
|
|
128
143
|
this.lastHtml = null;
|
|
129
144
|
this.lastSeenSeq = 0;
|
|
130
|
-
this._applyChain = Promise.resolve();
|
|
131
145
|
|
|
132
146
|
console.log('[LiveSync] Starting for:', this.currentFile);
|
|
133
147
|
this.connect();
|
|
@@ -150,6 +164,149 @@ class LiveSync {
|
|
|
150
164
|
}
|
|
151
165
|
|
|
152
166
|
clearTimeout(this.debounceTimer);
|
|
167
|
+
|
|
168
|
+
// Cancel any pending frame and clear the queue. A morph already in
|
|
169
|
+
// flight cannot be aborted; the isDestroyed check in _runPending guards
|
|
170
|
+
// its post-morph rescheduling so the queue stops cleanly.
|
|
171
|
+
if (this._rafHandle != null) {
|
|
172
|
+
this._cancelFrame(this._rafHandle);
|
|
173
|
+
this._rafHandle = null;
|
|
174
|
+
}
|
|
175
|
+
this._pendingHtml = null;
|
|
176
|
+
this._pendingSeq = null;
|
|
177
|
+
this._pendingIdentityMap = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_loadIdCounter() {
|
|
181
|
+
try {
|
|
182
|
+
const raw = sessionStorage.getItem('livesync-id-counter');
|
|
183
|
+
const n = parseInt(raw || '0', 10);
|
|
184
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_persistIdCounter() {
|
|
191
|
+
try {
|
|
192
|
+
sessionStorage.setItem('livesync-id-counter', String(this.idCounter));
|
|
193
|
+
} catch (e) {
|
|
194
|
+
// Storage full / sandboxed — fall back to in-memory only.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_mintId() {
|
|
199
|
+
this.idCounter++;
|
|
200
|
+
this._persistIdCounter();
|
|
201
|
+
return `${this.clientId}:${this.idCounter}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Walk the live DOM and the snapshot clone in lockstep. Path keys come
|
|
206
|
+
* from the clone (= what the receiver will see, after [snapshot-remove]
|
|
207
|
+
* and snapshotHooks). WeakMap lookup happens against the live element so
|
|
208
|
+
* synthetic IDs persist across saves.
|
|
209
|
+
*
|
|
210
|
+
* The live walk filters [snapshot-remove] to mirror the clone's earlier
|
|
211
|
+
* strip in captureSnapshot. If child counts diverge anywhere (an
|
|
212
|
+
* onbeforesnapshot handler added/removed siblings on the clone), the
|
|
213
|
+
* subtree is skipped — better to fall back to content scoring there than
|
|
214
|
+
* emit misaligned IDs.
|
|
215
|
+
*
|
|
216
|
+
* @param {Element} liveRoot
|
|
217
|
+
* @param {Element} cloneRoot
|
|
218
|
+
* @returns {Object} identityMap keyed by dot-path
|
|
219
|
+
*/
|
|
220
|
+
_buildIdentityMap(liveRoot, cloneRoot) {
|
|
221
|
+
const map = {};
|
|
222
|
+
if (!liveRoot || !cloneRoot) return map;
|
|
223
|
+
|
|
224
|
+
const visit = (live, clone, path) => {
|
|
225
|
+
let id = this.liveWeakMap.get(live);
|
|
226
|
+
if (!id) {
|
|
227
|
+
id = this._mintId();
|
|
228
|
+
this.liveWeakMap.set(live, id);
|
|
229
|
+
}
|
|
230
|
+
map[path] = id;
|
|
231
|
+
|
|
232
|
+
const liveKids = [];
|
|
233
|
+
for (const c of live.children) {
|
|
234
|
+
if (!c.hasAttribute('snapshot-remove')) liveKids.push(c);
|
|
235
|
+
}
|
|
236
|
+
const cloneKids = clone.children;
|
|
237
|
+
|
|
238
|
+
if (liveKids.length !== cloneKids.length) {
|
|
239
|
+
this._log(
|
|
240
|
+
`identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < liveKids.length; i++) {
|
|
246
|
+
visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
visit(liveRoot, cloneRoot, '');
|
|
251
|
+
return map;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Walk a single parsed tree, invoking cb(element, path) at each Element.
|
|
256
|
+
* Paths use the same dot-segment scheme as _buildIdentityMap so the
|
|
257
|
+
* receiver can look up IDs by the path the sender emitted.
|
|
258
|
+
*/
|
|
259
|
+
_walkParsedTree(root, cb) {
|
|
260
|
+
if (!root) return;
|
|
261
|
+
const visit = (el, path) => {
|
|
262
|
+
cb(el, path);
|
|
263
|
+
const kids = el.children;
|
|
264
|
+
for (let i = 0; i < kids.length; i++) {
|
|
265
|
+
visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
visit(root, '');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Fill liveWeakMap entries for live elements that the matcher's
|
|
273
|
+
* afterNodeMorphed didn't reach. createNode's no-id-children
|
|
274
|
+
* optimization (hyper-morph importNode path) inserts a clone of the
|
|
275
|
+
* parsed element without invoking morphNode, so afterNodeMorphed never
|
|
276
|
+
* fires for those subtrees and their synthetic IDs would be lost. On
|
|
277
|
+
* the receiver's next save, _buildIdentityMap would mint fresh IDs for
|
|
278
|
+
* the same logical elements, breaking convergence for newly-added
|
|
279
|
+
* ambiguous siblings — exactly the case identity-map exists to fix.
|
|
280
|
+
*
|
|
281
|
+
* Walks live and parsed in lockstep using the same path scheme as
|
|
282
|
+
* _buildIdentityMap. Filters [snapshot-remove] from the live side to
|
|
283
|
+
* stay aligned with the sender's clone view. Aborts a subtree on
|
|
284
|
+
* child-count divergence (e.g. local save-ignore additions) — those
|
|
285
|
+
* elements fall through to content scoring on the next round, which
|
|
286
|
+
* is the same fallback as a sender-side lockstep skip.
|
|
287
|
+
*
|
|
288
|
+
* @param {Element} liveRoot - post-morph live tree root
|
|
289
|
+
* @param {Element} parsedRoot - parsed-tree root (still has identityMap WeakMap entries)
|
|
290
|
+
* @param {Object} identityMap - path → id map from the SSE payload
|
|
291
|
+
*/
|
|
292
|
+
_fillInIdsAfterMorph(liveRoot, parsedRoot, identityMap) {
|
|
293
|
+
if (!liveRoot || !parsedRoot || !identityMap) return;
|
|
294
|
+
const visit = (live, parsed, path) => {
|
|
295
|
+
const id = identityMap[path];
|
|
296
|
+
if (id && !this.liveWeakMap.has(live)) {
|
|
297
|
+
this.liveWeakMap.set(live, id);
|
|
298
|
+
}
|
|
299
|
+
const liveKids = [];
|
|
300
|
+
for (const c of live.children) {
|
|
301
|
+
if (!c.hasAttribute('snapshot-remove')) liveKids.push(c);
|
|
302
|
+
}
|
|
303
|
+
const parsedKids = parsed.children;
|
|
304
|
+
if (liveKids.length !== parsedKids.length) return;
|
|
305
|
+
for (let i = 0; i < liveKids.length; i++) {
|
|
306
|
+
visit(liveKids[i], parsedKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
visit(liveRoot, parsedRoot, '');
|
|
153
310
|
}
|
|
154
311
|
|
|
155
312
|
/**
|
|
@@ -210,7 +367,7 @@ class LiveSync {
|
|
|
210
367
|
return;
|
|
211
368
|
}
|
|
212
369
|
|
|
213
|
-
const { html, sender, seq } = data;
|
|
370
|
+
const { html, sender, seq, identityMap } = data;
|
|
214
371
|
|
|
215
372
|
// Staleness check runs FIRST — compared against the high-water mark of
|
|
216
373
|
// seqs we've seen (own echoes count too, see below). `seq` is optional
|
|
@@ -240,8 +397,8 @@ class LiveSync {
|
|
|
240
397
|
}
|
|
241
398
|
|
|
242
399
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
243
|
-
this.applyUpdate(html, seq);
|
|
244
|
-
if (this.onUpdate) this.onUpdate({ html, sender, seq });
|
|
400
|
+
this.applyUpdate(html, seq, identityMap);
|
|
401
|
+
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
|
|
245
402
|
};
|
|
246
403
|
|
|
247
404
|
// Native EventSource auto-reconnects on transient errors
|
|
@@ -268,12 +425,17 @@ class LiveSync {
|
|
|
268
425
|
return;
|
|
269
426
|
}
|
|
270
427
|
|
|
271
|
-
const { documentElement } = event.detail;
|
|
272
|
-
if (!
|
|
428
|
+
const { documentElement: clone } = event.detail;
|
|
429
|
+
if (!clone) return;
|
|
273
430
|
|
|
431
|
+
// Capture both synchronously inside the event handler — captureSnapshot's
|
|
432
|
+
// caller continues to mutate the clone (strip [save-remove], run hooks)
|
|
433
|
+
// after dispatchEvent returns, so reading outerHTML and walking children
|
|
434
|
+
// must happen now.
|
|
274
435
|
this._log('snapshot-ready received, preparing to send');
|
|
275
|
-
const html =
|
|
276
|
-
this.
|
|
436
|
+
const html = clone.outerHTML;
|
|
437
|
+
const identityMap = this._buildIdentityMap(document.documentElement, clone);
|
|
438
|
+
this.sendUpdate(html, identityMap);
|
|
277
439
|
};
|
|
278
440
|
|
|
279
441
|
document.addEventListener('hyperclay:snapshot-ready', this._snapshotHandler);
|
|
@@ -283,7 +445,7 @@ class LiveSync {
|
|
|
283
445
|
* Send full HTML to the server (debounced)
|
|
284
446
|
* Only updates lastHtml after successful save
|
|
285
447
|
*/
|
|
286
|
-
sendUpdate(html) {
|
|
448
|
+
sendUpdate(html, identityMap) {
|
|
287
449
|
clearTimeout(this.debounceTimer);
|
|
288
450
|
|
|
289
451
|
this.debounceTimer = setTimeout(() => {
|
|
@@ -300,7 +462,8 @@ class LiveSync {
|
|
|
300
462
|
headers: { 'Content-Type': 'application/json', 'Page-URL': window.location.href },
|
|
301
463
|
body: JSON.stringify({
|
|
302
464
|
html: html,
|
|
303
|
-
sender: this.clientId
|
|
465
|
+
sender: this.clientId,
|
|
466
|
+
identityMap: identityMap
|
|
304
467
|
})
|
|
305
468
|
}).then(response => {
|
|
306
469
|
if (response.ok) {
|
|
@@ -316,39 +479,96 @@ class LiveSync {
|
|
|
316
479
|
}
|
|
317
480
|
|
|
318
481
|
/**
|
|
319
|
-
* Apply an update received from the server.
|
|
320
|
-
* Morphs the entire document (head and body).
|
|
482
|
+
* Apply an update received from the server. Morphs the entire document.
|
|
321
483
|
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
484
|
+
* Updates land in a single pending slot. On each animation frame, the
|
|
485
|
+
* latest pending payload is morphed once; intermediate updates that
|
|
486
|
+
* arrived between frames are skipped because they would be replaced
|
|
487
|
+
* milliseconds later anyway. Burst arrivals collapse to roughly one morph
|
|
488
|
+
* per frame, so the receiver always shows current state instead of
|
|
489
|
+
* playing back a backlog of stale snapshots.
|
|
324
490
|
*
|
|
325
491
|
* @param {string} html - Full document HTML
|
|
326
492
|
* @param {number} [seq] - Optional monotonic seq from the server
|
|
327
|
-
* @
|
|
493
|
+
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
328
494
|
*/
|
|
329
|
-
applyUpdate(html, seq) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
495
|
+
applyUpdate(html, seq, identityMap) {
|
|
496
|
+
if (this.isDestroyed) return;
|
|
497
|
+
this._pendingHtml = html;
|
|
498
|
+
this._pendingSeq = seq;
|
|
499
|
+
this._pendingIdentityMap = identityMap;
|
|
500
|
+
this._scheduleNextFrame();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Schedule the next-frame morph if one isn't already pending. Skipped when
|
|
505
|
+
* a morph is in flight; the morph's post-completion check will reschedule
|
|
506
|
+
* if a newer payload arrived during it.
|
|
507
|
+
*/
|
|
508
|
+
_scheduleNextFrame() {
|
|
509
|
+
if (this.isDestroyed) return;
|
|
510
|
+
if (this._rafHandle != null) return;
|
|
511
|
+
if (this._morphInFlight) return;
|
|
512
|
+
this._rafHandle = this._requestFrame(() => this._runPending());
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Drain the pending slot once. Errors are caught and logged so a single
|
|
517
|
+
* failed morph does not stop the queue.
|
|
518
|
+
*/
|
|
519
|
+
async _runPending() {
|
|
520
|
+
this._rafHandle = null;
|
|
521
|
+
if (this.isDestroyed) return;
|
|
522
|
+
|
|
523
|
+
const html = this._pendingHtml;
|
|
524
|
+
const seq = this._pendingSeq;
|
|
525
|
+
const identityMap = this._pendingIdentityMap;
|
|
526
|
+
this._pendingHtml = null;
|
|
527
|
+
this._pendingSeq = null;
|
|
528
|
+
this._pendingIdentityMap = null;
|
|
529
|
+
if (html == null) return;
|
|
530
|
+
|
|
531
|
+
this._morphInFlight = true;
|
|
532
|
+
try {
|
|
533
|
+
await this._doApplyUpdate(html, seq, identityMap);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
console.error('[LiveSync] applyUpdate failed:', err);
|
|
536
|
+
} finally {
|
|
537
|
+
this._morphInFlight = false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// A newer payload may have arrived during the morph. Schedule another
|
|
541
|
+
// frame to drain it. Without this, late-arriving updates would sit
|
|
542
|
+
// forever until the next applyUpdate call.
|
|
543
|
+
if (!this.isDestroyed && this._pendingHtml != null) {
|
|
544
|
+
this._scheduleNextFrame();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
_requestFrame(cb) {
|
|
549
|
+
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
550
|
+
return window.requestAnimationFrame(cb);
|
|
551
|
+
}
|
|
552
|
+
return setTimeout(cb, 16);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
_cancelFrame(handle) {
|
|
556
|
+
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
|
557
|
+
window.cancelAnimationFrame(handle);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
clearTimeout(handle);
|
|
342
561
|
}
|
|
343
562
|
|
|
344
563
|
/**
|
|
345
|
-
* Actual morph work. Do not call directly
|
|
346
|
-
*
|
|
564
|
+
* Actual morph work. Do not call directly. Use applyUpdate() so calls
|
|
565
|
+
* pass through the rAF queue and don't overlap.
|
|
347
566
|
* @param {string} html
|
|
348
567
|
* @param {number} [seq]
|
|
568
|
+
* @param {Object} [identityMap]
|
|
349
569
|
* @returns {Promise<void>}
|
|
350
570
|
*/
|
|
351
|
-
async _doApplyUpdate(html, seq) {
|
|
571
|
+
async _doApplyUpdate(html, seq, identityMap) {
|
|
352
572
|
this._log('applyUpdate - pausing mutations and morphing');
|
|
353
573
|
this.isPaused = true;
|
|
354
574
|
|
|
@@ -366,6 +586,32 @@ class LiveSync {
|
|
|
366
586
|
const parser = new DOMParser();
|
|
367
587
|
const newDoc = parser.parseFromString(html, 'text/html');
|
|
368
588
|
|
|
589
|
+
// Build the parsed-tree WeakMap from the incoming identityMap. The
|
|
590
|
+
// sender emitted paths off its clone, which is exactly what we just
|
|
591
|
+
// parsed, so the same path scheme indexes into both trees.
|
|
592
|
+
const parsedWeakMap = new WeakMap();
|
|
593
|
+
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
594
|
+
this._walkParsedTree(newDoc.documentElement, (el, path) => {
|
|
595
|
+
const id = identityMap[path];
|
|
596
|
+
if (id) parsedWeakMap.set(el, id);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const liveWeakMap = this.liveWeakMap;
|
|
601
|
+
// Priority: synthetic IDs win when present (they're updated after every
|
|
602
|
+
// morph via afterNodeMorphed). data-id / id is the durable fallback that
|
|
603
|
+
// covers the bootstrap window and any element that hasn't been paired yet.
|
|
604
|
+
const key = (el) =>
|
|
605
|
+
liveWeakMap.get(el) ||
|
|
606
|
+
parsedWeakMap.get(el) ||
|
|
607
|
+
(el.getAttribute && el.getAttribute('data-id')) ||
|
|
608
|
+
el.id ||
|
|
609
|
+
null;
|
|
610
|
+
const afterNodeMorphed = (oldEl, newEl) => {
|
|
611
|
+
const id = parsedWeakMap.get(newEl);
|
|
612
|
+
if (id) liveWeakMap.set(oldEl, id);
|
|
613
|
+
};
|
|
614
|
+
|
|
369
615
|
try {
|
|
370
616
|
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
371
617
|
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
@@ -376,12 +622,24 @@ class LiveSync {
|
|
|
376
622
|
morphStyle: 'outerHTML',
|
|
377
623
|
ignoreActiveValue: true,
|
|
378
624
|
head: { style: 'merge' },
|
|
379
|
-
scripts: { handle: true, matchMode: 'smart' }
|
|
625
|
+
scripts: { handle: true, matchMode: 'smart' },
|
|
626
|
+
key,
|
|
627
|
+
callbacks: { afterNodeMorphed }
|
|
380
628
|
});
|
|
381
629
|
|
|
382
630
|
// Restore viewport. Done after morph so layout has settled.
|
|
383
631
|
window.scrollTo(scrollX, scrollY);
|
|
384
632
|
|
|
633
|
+
// Fill in any IDs the matcher's afterNodeMorphed missed. Brand-new
|
|
634
|
+
// elements come in via hyper-morph's importNode optimization, which
|
|
635
|
+
// skips morphNode and thus afterNodeMorphed; their parsedWeakMap IDs
|
|
636
|
+
// never make it onto liveWeakMap. Without this pass, the receiver
|
|
637
|
+
// would mint fresh IDs on its next save for those elements,
|
|
638
|
+
// breaking convergence exactly for newly-added ambiguous siblings.
|
|
639
|
+
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
640
|
+
this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, identityMap);
|
|
641
|
+
}
|
|
642
|
+
|
|
385
643
|
// Only mark lastHtml after a successful morph so that a failed apply
|
|
386
644
|
// doesn't desync our state and cause the next outbound save to be
|
|
387
645
|
// mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
|
|
@@ -449,6 +707,8 @@ if (typeof window !== 'undefined') {
|
|
|
449
707
|
}
|
|
450
708
|
}
|
|
451
709
|
|
|
452
|
-
// Export for hyperclayjs module system
|
|
453
|
-
|
|
710
|
+
// Export for hyperclayjs module system. The class itself is exported so
|
|
711
|
+
// tests can create fresh instances without driving the singleton's
|
|
712
|
+
// EventSource/snapshot wiring.
|
|
713
|
+
export { liveSync, LiveSync };
|
|
454
714
|
export default liveSync;
|
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.
|
|
4
|
+
* HyperclayJS v1.28.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.
|
|
@@ -61,6 +61,7 @@ const MODULE_PATHS = {
|
|
|
61
61
|
"style-injection": "./dom-utilities/insertStyleTag.js",
|
|
62
62
|
"form-data": "./dom-utilities/getDataFromForm.js",
|
|
63
63
|
"hyper-morph": "./vendor/hyper-morph.vendor.js",
|
|
64
|
+
"hypercms": "./vendor/hypercms.vendor.js",
|
|
64
65
|
"slugify": "./string-utilities/slugify.js",
|
|
65
66
|
"copy-to-clipboard": "./string-utilities/copy-to-clipboard.js",
|
|
66
67
|
"query-params": "./string-utilities/query.js",
|
|
@@ -139,6 +140,7 @@ const PRESETS = {
|
|
|
139
140
|
"all-js",
|
|
140
141
|
"style-injection",
|
|
141
142
|
"form-data",
|
|
143
|
+
"hypercms",
|
|
142
144
|
"slugify",
|
|
143
145
|
"copy-to-clipboard",
|
|
144
146
|
"query-params",
|
|
@@ -187,6 +189,7 @@ const PRESETS = {
|
|
|
187
189
|
"style-injection",
|
|
188
190
|
"form-data",
|
|
189
191
|
"hyper-morph",
|
|
192
|
+
"hypercms",
|
|
190
193
|
"slugify",
|
|
191
194
|
"copy-to-clipboard",
|
|
192
195
|
"query-params",
|
|
@@ -337,6 +340,7 @@ export const insertStyleTag = window.hyperclayModules['style-injection']?.insert
|
|
|
337
340
|
export const getDataFromForm = window.hyperclayModules['form-data']?.getDataFromForm ?? window.hyperclayModules['form-data']?.default;
|
|
338
341
|
export const HyperMorph = window.hyperclayModules['hyper-morph']?.HyperMorph ?? window.hyperclayModules['hyper-morph']?.default;
|
|
339
342
|
export const morph = window.hyperclayModules['hyper-morph']?.morph ?? window.hyperclayModules['hyper-morph']?.default;
|
|
343
|
+
export const cms = window.hyperclayModules['hypercms']?.cms ?? window.hyperclayModules['hypercms']?.default;
|
|
340
344
|
export const slugify = window.hyperclayModules['slugify']?.slugify ?? window.hyperclayModules['slugify']?.default;
|
|
341
345
|
export const copyToClipboard = window.hyperclayModules['copy-to-clipboard']?.copyToClipboard ?? window.hyperclayModules['copy-to-clipboard']?.default;
|
|
342
346
|
export const query = window.hyperclayModules['query-params']?.query ?? window.hyperclayModules['query-params']?.default;
|
package/src/ui/prompts.js
CHANGED
|
@@ -114,7 +114,9 @@ export function tell(promptText, ...content) {
|
|
|
114
114
|
* Display a modal with a code snippet and copy functionality
|
|
115
115
|
* @param {string} title - The modal heading
|
|
116
116
|
* @param {string} content - The code to display
|
|
117
|
-
* @param {string} extraContent - Optional
|
|
117
|
+
* @param {string} extraContent - Optional raw HTML rendered below the copy button.
|
|
118
|
+
* Callers style their own container; use `<div class="snippet-warning">…</div>`
|
|
119
|
+
* for the standard yellow-bordered warning box.
|
|
118
120
|
*/
|
|
119
121
|
export function snippet(title, content, extraContent = '') {
|
|
120
122
|
|
|
@@ -126,11 +128,7 @@ export function snippet(title, content, extraContent = '') {
|
|
|
126
128
|
|
|
127
129
|
<button type="button" class="micromodal__secondary-btn copy-snippet-btn" style="margin-bottom: 14px;">copy</button>
|
|
128
130
|
|
|
129
|
-
${extraContent
|
|
130
|
-
<div class="snippet-warning">
|
|
131
|
-
${extraContent}
|
|
132
|
-
</div>
|
|
133
|
-
` : ''}
|
|
131
|
+
${extraContent || ''}
|
|
134
132
|
`;
|
|
135
133
|
|
|
136
134
|
// Use the existing modal system
|
package/src/ui/theModal.js
CHANGED
|
@@ -530,6 +530,23 @@ const modalCss = `<style class="micromodal-css">
|
|
|
530
530
|
max-width: 420px;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
+
.micromodal .snippet-link-box {
|
|
534
|
+
padding: 0.75rem;
|
|
535
|
+
border: 2px solid #F6F7F9;
|
|
536
|
+
background-color: transparent;
|
|
537
|
+
font-size: 0.875rem;
|
|
538
|
+
margin-top: 0.75rem;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.micromodal .snippet-link-box a {
|
|
542
|
+
color: #F6F7F9;
|
|
543
|
+
text-decoration: underline;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.micromodal .snippet-link-box a:hover {
|
|
547
|
+
color: #E5E7EB;
|
|
548
|
+
}
|
|
549
|
+
|
|
533
550
|
.micromodal button.micromodal__close {
|
|
534
551
|
clip-path: polygon(0% 4%, 0% 0%, 100% 0%, 100% 100%, 94% 100%);
|
|
535
552
|
position: absolute;
|
|
@@ -280,7 +280,7 @@ const Mutation = {
|
|
|
280
280
|
if (node.nodeType === 1 && !this._shouldIgnore(node)) {
|
|
281
281
|
const removedNodes = [node, ...node.querySelectorAll('*')];
|
|
282
282
|
this._log(`Processing ${removedNodes.length} removed nodes`, { removedNodes });
|
|
283
|
-
|
|
283
|
+
|
|
284
284
|
for (const element of removedNodes) {
|
|
285
285
|
const change = {
|
|
286
286
|
type: 'remove',
|
|
@@ -294,6 +294,29 @@ const Mutation = {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
// Bubble text-node child changes (e.g. `el.textContent = 'foo'`) up
|
|
299
|
+
// to the parent element so onAnyChange fires. Typed callbacks
|
|
300
|
+
// (addElement, removeElement) stay element-only by design.
|
|
301
|
+
let hasTextNodeChanges = false;
|
|
302
|
+
for (const node of mutation.addedNodes) {
|
|
303
|
+
if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
|
|
304
|
+
}
|
|
305
|
+
if (!hasTextNodeChanges) {
|
|
306
|
+
for (const node of mutation.removedNodes) {
|
|
307
|
+
if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (hasTextNodeChanges && mutation.target.nodeType === 1) {
|
|
311
|
+
const change = {
|
|
312
|
+
type: 'characterData',
|
|
313
|
+
element: mutation.target,
|
|
314
|
+
oldValue: undefined,
|
|
315
|
+
newValue: mutation.target.textContent
|
|
316
|
+
};
|
|
317
|
+
changes.push(change);
|
|
318
|
+
changesByType.characterData.push(change);
|
|
319
|
+
}
|
|
297
320
|
}
|
|
298
321
|
|
|
299
322
|
if (mutation.type === 'attributes') {
|
|
@@ -404,7 +427,8 @@ const Mutation = {
|
|
|
404
427
|
attributes: true,
|
|
405
428
|
subtree: true,
|
|
406
429
|
characterData: true,
|
|
407
|
-
attributeOldValue: true
|
|
430
|
+
attributeOldValue: true,
|
|
431
|
+
characterDataOldValue: true
|
|
408
432
|
});
|
|
409
433
|
this._observing = true;
|
|
410
434
|
this._log('Observation started');
|