@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,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Lightweight wrapper around MutationObserver that provides methods to watch DOM changes.
|
|
4
|
+
* Uses a single observer instance internally to improve performance.
|
|
5
|
+
*
|
|
6
|
+
* // Watch for any DOM changes (additions, removals, attribute changes)
|
|
7
|
+
* Mutation.onAnyChange({ debounce: 200 }, changes => {
|
|
8
|
+
* changes.forEach(change => {
|
|
9
|
+
* if (change.type === 'add') {
|
|
10
|
+
* console.log('Added:', change.element);
|
|
11
|
+
* console.log('To parent:', change.parent);
|
|
12
|
+
* console.log('Between:', change.previousSibling, change.nextSibling);
|
|
13
|
+
* }
|
|
14
|
+
* if (change.type === 'remove') {
|
|
15
|
+
* console.log('Removed:', change.element);
|
|
16
|
+
* console.log('From parent:', change.parent);
|
|
17
|
+
* }
|
|
18
|
+
* if (change.type === 'attribute') {
|
|
19
|
+
* console.log('Changed:', change.attribute);
|
|
20
|
+
* console.log('From:', change.oldValue, 'to:', change.newValue);
|
|
21
|
+
* }
|
|
22
|
+
* });
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Watch for element additions or removals
|
|
26
|
+
* Mutation.onAddOrRemove({ debounce: 200 }, changes => {
|
|
27
|
+
* changes.forEach(change => {
|
|
28
|
+
* const action = change.type === 'add' ? 'added to' : 'removed from';
|
|
29
|
+
* console.log(`${change.element.tagName} ${action} ${change.parent.tagName}`);
|
|
30
|
+
* });
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* // Watch for element additions
|
|
34
|
+
* Mutation.onAddElement({ debounce: 200 }, changes => {
|
|
35
|
+
* changes.forEach(({ element, parent }) => {
|
|
36
|
+
* console.log(`${element.tagName} added to ${parent.tagName}`);
|
|
37
|
+
* });
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // Watch for element removals with location info
|
|
41
|
+
* Mutation.onRemoveElement({ debounce: 200 }, changes => {
|
|
42
|
+
* changes.forEach(({ element, parent, previousSibling, nextSibling }) => {
|
|
43
|
+
* console.log(`${element.tagName} removed from ${parent.tagName}`);
|
|
44
|
+
* console.log('Was between:', previousSibling?.tagName, nextSibling?.tagName);
|
|
45
|
+
* });
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* // Watch for attribute changes
|
|
49
|
+
* Mutation.onAttribute({ debounce: 200 }, changes => {
|
|
50
|
+
* // Debounce collects multiple changes into an array
|
|
51
|
+
* changes.forEach(({ element, attribute, oldValue, newValue: newValue }) => {
|
|
52
|
+
* console.log(`${element.tagName} ${attribute} changed from ${oldValue} to ${newValue}`);
|
|
53
|
+
* });
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // External reactive consumers (e.g. sapjs, which re-derives state on ANY DOM change)
|
|
57
|
+
* // subscribe through the region-aware, pause-gated lane:
|
|
58
|
+
* //
|
|
59
|
+
* // Mutation.onAnyChange({ require: 'observed' }, changes => { ... })
|
|
60
|
+
* //
|
|
61
|
+
* // Leaving `pausable` at its default (true) is load-bearing: by wrapping its own write
|
|
62
|
+
* // phase in Mutation.pause()/resume(), such a consumer's derived writes are vacuumed on
|
|
63
|
+
* // resume and routed only to non-pausable consumers, so they never loop back to it.
|
|
64
|
+
* // The raw lane (subscribeRaw/createObserver) is internal — reserved for undo/region
|
|
65
|
+
* // plumbing — and is NOT part of the public contract for external consumers.
|
|
66
|
+
*
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
|
|
70
|
+
import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
|
|
71
|
+
import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
|
|
72
|
+
|
|
73
|
+
const dummyElem = document.createElement("div");
|
|
74
|
+
|
|
75
|
+
const Mutation = {
|
|
76
|
+
_callbacks: {
|
|
77
|
+
anyChange: [],
|
|
78
|
+
addOrRemove: [],
|
|
79
|
+
addElement: [],
|
|
80
|
+
removeElement: [],
|
|
81
|
+
attribute: []
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
_observing: false,
|
|
85
|
+
_pauseDepth: 0,
|
|
86
|
+
_hasNonPausable: false,
|
|
87
|
+
debug: false,
|
|
88
|
+
|
|
89
|
+
// Raw lane: subscribers that need the untransformed MutationRecords (the undo
|
|
90
|
+
// recorder). Each gets a private buffer so a global takeRecords() drain by one
|
|
91
|
+
// subscriber can't steal records owed to another. See _drainBrowserQueue.
|
|
92
|
+
_rawSubscribers: [],
|
|
93
|
+
// Change-lane records pulled by a raw subscriber's drain, deferred to a
|
|
94
|
+
// microtask so a debounce:0 mutating subscriber (autosize) never fires
|
|
95
|
+
// synchronously inside undo's commit boundary.
|
|
96
|
+
_deferredChangeRecords: null,
|
|
97
|
+
_deferredChangeScheduled: false,
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pause mutation observation.
|
|
101
|
+
* Use this when making programmatic DOM changes that shouldn't trigger callbacks.
|
|
102
|
+
* Always pair with resume() in a try/finally block.
|
|
103
|
+
*/
|
|
104
|
+
pause() {
|
|
105
|
+
// Reference-counted so nested pauses (e.g. a programmatic pause wrapping a
|
|
106
|
+
// live-sync morph that also pauses) only resume on the outermost release.
|
|
107
|
+
this._pauseDepth++;
|
|
108
|
+
// Bridge: pause undo recorder too. Live-sync calls Mutation.pause()
|
|
109
|
+
// before morphing remote HTML in, and we want those mutations excluded
|
|
110
|
+
// from the local undo stack as well. undo.pause() is itself reference-counted.
|
|
111
|
+
if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.pause) {
|
|
112
|
+
window.hyperclay.undo.pause();
|
|
113
|
+
}
|
|
114
|
+
this._log('Paused', this._pauseDepth);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resume mutation observation after a pause.
|
|
119
|
+
*/
|
|
120
|
+
resume() {
|
|
121
|
+
if (this._pauseDepth === 0) return; // underflow guard
|
|
122
|
+
this._pauseDepth--;
|
|
123
|
+
// Drain pending mutation records only on the outermost release — observer
|
|
124
|
+
// stays connected during pause, so morph mutations are recorded and would
|
|
125
|
+
// fire async once the depth returns to zero. Route through the one drain
|
|
126
|
+
// funnel: non-pausable callbacks (pure enhancers like sortable /
|
|
127
|
+
// optionVisibility) get that boundary batch, and raw subscribers (undo) get
|
|
128
|
+
// it into their buffer so the morph-boundary records aren't lost (then
|
|
129
|
+
// undo.resume()'s own drain-discard excludes the morph, matching today).
|
|
130
|
+
if (this._pauseDepth === 0 && this._observer) {
|
|
131
|
+
this._drainBrowserQueue(this);
|
|
132
|
+
}
|
|
133
|
+
if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.resume) {
|
|
134
|
+
window.hyperclay.undo.resume();
|
|
135
|
+
}
|
|
136
|
+
this._log('Resumed', this._pauseDepth);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
_log(message, data = null, type = 'log') {
|
|
140
|
+
if (!this.debug) return;
|
|
141
|
+
|
|
142
|
+
const timestamp = new Date().toISOString();
|
|
143
|
+
const prefix = `[Mutation ${timestamp}]`;
|
|
144
|
+
|
|
145
|
+
if (data) {
|
|
146
|
+
console[type](prefix, message, data);
|
|
147
|
+
} else {
|
|
148
|
+
console[type](prefix, message);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
_notify(type, changes, onlyNonPausable = false) {
|
|
153
|
+
this._log(`Notifying ${this._callbacks[type].length} callbacks of type "${type}"`, { changes });
|
|
154
|
+
|
|
155
|
+
for (const callback of this._callbacks[type]) {
|
|
156
|
+
// While paused, only non-pausable callbacks (pure enhancers) run.
|
|
157
|
+
if (onlyNonPausable && callback.pausable !== false) continue;
|
|
158
|
+
|
|
159
|
+
const { fn, debounce = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
|
|
160
|
+
|
|
161
|
+
// Per-consumer region policy: drop changes whose region this consumer
|
|
162
|
+
// doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
|
|
163
|
+
// and their legacy equivalents), resolved against the callback's `require`.
|
|
164
|
+
let filteredChanges = changes.filter(
|
|
165
|
+
change => !skipForPolicy(this._policyForChange(change), require, skip)
|
|
166
|
+
);
|
|
167
|
+
if (!filteredChanges.length) {
|
|
168
|
+
this._log('No changes passed the region policy, skipping callback');
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Apply filtering if there's a selector filter
|
|
173
|
+
if (selectorFilter) {
|
|
174
|
+
this._log('Applying selector filter', { selectorFilter });
|
|
175
|
+
filteredChanges = filteredChanges.filter(change => {
|
|
176
|
+
if (typeof selectorFilter === 'string') {
|
|
177
|
+
const matches = change.element.matches?.(selectorFilter) || false;
|
|
178
|
+
this._log(`Selector "${selectorFilter}" match:`, { element: change.element, matches });
|
|
179
|
+
return matches;
|
|
180
|
+
}
|
|
181
|
+
if (typeof selectorFilter === 'function') {
|
|
182
|
+
const matches = selectorFilter(change.element);
|
|
183
|
+
this._log('Custom filter match:', { element: change.element, matches });
|
|
184
|
+
return matches;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
});
|
|
188
|
+
this._log('Changes after filtering:', { filteredChanges });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Skip if nothing remains after filtering
|
|
192
|
+
if (!filteredChanges.length) {
|
|
193
|
+
this._log('No changes passed the filter, skipping callback');
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle debouncing and callback execution
|
|
198
|
+
if (debounce === 0) {
|
|
199
|
+
// No debounce, execute immediately
|
|
200
|
+
try {
|
|
201
|
+
if (omitChangeDetails) {
|
|
202
|
+
fn();
|
|
203
|
+
} else {
|
|
204
|
+
fn(filteredChanges);
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
this._log('Error in callback execution:', e, 'error');
|
|
208
|
+
console.error('Error in Mutation callback:', e);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
// Clear any existing timeout
|
|
212
|
+
if (callback.timeout) {
|
|
213
|
+
clearTimeout(callback.timeout);
|
|
214
|
+
callback.timeout = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (omitChangeDetails) {
|
|
218
|
+
// For omitChangeDetails, just reset the timer
|
|
219
|
+
callback.timeout = setTimeout(() => {
|
|
220
|
+
callback.timeout = null;
|
|
221
|
+
try {
|
|
222
|
+
this._log('Executing debounced callback (no details)');
|
|
223
|
+
fn();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
this._log('Error in callback execution:', e, 'error');
|
|
226
|
+
console.error('Error in Mutation callback:', e);
|
|
227
|
+
}
|
|
228
|
+
}, debounce);
|
|
229
|
+
} else {
|
|
230
|
+
// For callbacks with change details, accumulate changes
|
|
231
|
+
if (!callback.pendingChanges) {
|
|
232
|
+
callback.pendingChanges = [];
|
|
233
|
+
}
|
|
234
|
+
callback.pendingChanges.push(...filteredChanges);
|
|
235
|
+
|
|
236
|
+
// Reset the timer
|
|
237
|
+
callback.timeout = setTimeout(() => {
|
|
238
|
+
const changes = callback.pendingChanges;
|
|
239
|
+
callback.pendingChanges = null; // Reset to null, not empty array
|
|
240
|
+
callback.timeout = null; // Clear the timeout reference
|
|
241
|
+
try {
|
|
242
|
+
this._log('Executing debounced callback with changes:', { changes });
|
|
243
|
+
if (changes && changes.length > 0) {
|
|
244
|
+
fn(changes);
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
this._log('Error in callback execution:', e, 'error');
|
|
248
|
+
console.error('Error in Mutation callback:', e);
|
|
249
|
+
}
|
|
250
|
+
}, debounce);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// Resolve a change's region policy once per batch (memoized on the change).
|
|
257
|
+
// Removed/detached elements carry their region markers on the still-attached
|
|
258
|
+
// parent (the region they were removed FROM), so merge it in for those.
|
|
259
|
+
_policyForChange(change) {
|
|
260
|
+
if (change.__policy) return change.__policy;
|
|
261
|
+
const el = change.element;
|
|
262
|
+
let policy = resolveRegionPolicy(el);
|
|
263
|
+
if (change.parent && el && el.isConnected === false) {
|
|
264
|
+
policy = strictestPolicy(policy, resolveRegionPolicy(change.parent));
|
|
265
|
+
}
|
|
266
|
+
change.__policy = policy;
|
|
267
|
+
return policy;
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
// The real MutationObserver's callback target. Two lanes, in this order:
|
|
271
|
+
// 1. raw fan-out FIRST, unconditionally (even while paused / on the fast
|
|
272
|
+
// path) so the raw lane's push timing matches a real MutationObserver and
|
|
273
|
+
// undo's own paused-drop keeps working.
|
|
274
|
+
// 2. the change lane (pause-gated, as before).
|
|
275
|
+
_onRecords(records) {
|
|
276
|
+
this._fanOutRaw(records);
|
|
277
|
+
this._handleMutations(records);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
// Push the untransformed records to every raw subscriber's callback. No
|
|
281
|
+
// filtering and no pause gating: raw means raw (undo runs its own filter and
|
|
282
|
+
// its own pause). Captured BEFORE the change lane's isInert / extension-attr
|
|
283
|
+
// intake drops, which the raw subscriber must not inherit.
|
|
284
|
+
_fanOutRaw(records) {
|
|
285
|
+
if (!this._rawSubscribers.length || !records.length) return;
|
|
286
|
+
for (const sub of this._rawSubscribers) {
|
|
287
|
+
try {
|
|
288
|
+
sub.cb(records);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
this._log('Error in raw subscriber callback:', e, 'error');
|
|
291
|
+
console.error('Error in Mutation raw subscriber:', e);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
// The ONLY caller of this._observer.takeRecords(). Pulls the browser's pending
|
|
297
|
+
// (undelivered) records and routes them three ways so nobody loses a record
|
|
298
|
+
// the global takeRecords() just emptied:
|
|
299
|
+
// (a) every OTHER raw subscriber: into its private buffer + a microtask flush
|
|
300
|
+
// (b) the change lane: deferred to a microtask when the requester is a raw
|
|
301
|
+
// subscriber (so a debounce:0 mutating subscriber can't fire inside
|
|
302
|
+
// undo's synchronous commit); synchronous-to-non-pausables when the hub
|
|
303
|
+
// itself is the requester (the resume() boundary, matching today)
|
|
304
|
+
// (c) the requester: returns the pulled records merged with its own buffer,
|
|
305
|
+
// then clears the buffer.
|
|
306
|
+
_drainBrowserQueue(requester) {
|
|
307
|
+
const pulled = this._observer ? this._observer.takeRecords() : [];
|
|
308
|
+
const requesterIsRaw = !!requester && this._rawSubscribers.indexOf(requester) !== -1;
|
|
309
|
+
|
|
310
|
+
if (pulled.length) {
|
|
311
|
+
// (a) records owed to other raw subscribers (the global queue is now empty
|
|
312
|
+
// for them too, so hand them their copy via their buffer).
|
|
313
|
+
for (const sub of this._rawSubscribers) {
|
|
314
|
+
if (sub === requester) continue;
|
|
315
|
+
sub.buffer.push(...pulled);
|
|
316
|
+
this._scheduleBufferFlush(sub);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// (b) change lane.
|
|
320
|
+
if (requesterIsRaw) {
|
|
321
|
+
this._deferChangeLane(pulled);
|
|
322
|
+
} else if (this._hasNonPausable) {
|
|
323
|
+
this._processMutations(pulled, true);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// (c) hand the requester its records (own buffer first = chronological).
|
|
328
|
+
if (requesterIsRaw) {
|
|
329
|
+
const own = requester.buffer;
|
|
330
|
+
requester.buffer = [];
|
|
331
|
+
return own.length ? own.concat(pulled) : pulled;
|
|
332
|
+
}
|
|
333
|
+
return pulled;
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// Deliver a raw subscriber's buffered records on a microtask, reading the
|
|
337
|
+
// CURRENT buffer at flush time so a synchronous drain() in between makes this
|
|
338
|
+
// a no-op (the buffer is consume-and-clear; a record is delivered exactly once
|
|
339
|
+
// — via flush OR via drain, never both).
|
|
340
|
+
_scheduleBufferFlush(sub) {
|
|
341
|
+
if (sub._flushScheduled) return;
|
|
342
|
+
sub._flushScheduled = true;
|
|
343
|
+
queueMicrotask(() => {
|
|
344
|
+
sub._flushScheduled = false;
|
|
345
|
+
if (!sub.buffer.length) return;
|
|
346
|
+
const batch = sub.buffer;
|
|
347
|
+
sub.buffer = [];
|
|
348
|
+
try {
|
|
349
|
+
sub.cb(batch);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
this._log('Error in raw subscriber flush:', e, 'error');
|
|
352
|
+
console.error('Error in Mutation raw subscriber:', e);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// Queue change-lane records pulled by a raw subscriber's drain for a single
|
|
358
|
+
// microtask-deferred pass through _handleMutations (pause rules apply at
|
|
359
|
+
// processing time, not capture time).
|
|
360
|
+
_deferChangeLane(records) {
|
|
361
|
+
if (!this._deferredChangeRecords) this._deferredChangeRecords = [];
|
|
362
|
+
this._deferredChangeRecords.push(...records);
|
|
363
|
+
if (this._deferredChangeScheduled) return;
|
|
364
|
+
this._deferredChangeScheduled = true;
|
|
365
|
+
queueMicrotask(() => {
|
|
366
|
+
this._deferredChangeScheduled = false;
|
|
367
|
+
const batch = this._deferredChangeRecords;
|
|
368
|
+
this._deferredChangeRecords = null;
|
|
369
|
+
if (batch && batch.length) this._handleMutations(batch);
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
// Raw lane primitive: an explicit push+pull subscription to the untransformed
|
|
374
|
+
// MutationRecords. Internal plumbing for the vendored undo recorder (so the
|
|
375
|
+
// page runs ONE MutationObserver). Not public API yet.
|
|
376
|
+
subscribeRaw(cb) {
|
|
377
|
+
const sub = { cb, buffer: [], _flushScheduled: false };
|
|
378
|
+
this._rawSubscribers.push(sub);
|
|
379
|
+
// A raw subscriber may register before any change subscriber, and the hub
|
|
380
|
+
// only starts observing on first subscription (the lazy-start trap), so
|
|
381
|
+
// force it on or undo records nothing when it starts first.
|
|
382
|
+
this._startObserving();
|
|
383
|
+
return {
|
|
384
|
+
drain: () => this._drainBrowserQueue(sub),
|
|
385
|
+
unsubscribe: () => {
|
|
386
|
+
const i = this._rawSubscribers.indexOf(sub);
|
|
387
|
+
if (i !== -1) this._rawSubscribers.splice(i, 1);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
// Thin MutationObserver-shaped adapter over subscribeRaw, so undo's scope.js
|
|
393
|
+
// changes one construction line and keeps its observe/disconnect/takeRecords
|
|
394
|
+
// calls. Only document.body is supported (the singleton's scope); created
|
|
395
|
+
// shadow scopes keep a real MutationObserver. Options are accepted and ignored
|
|
396
|
+
// — the hub already observes with options identical to undo's.
|
|
397
|
+
createObserver(cb) {
|
|
398
|
+
let subscription = null;
|
|
399
|
+
return {
|
|
400
|
+
observe: (target /*, options */) => {
|
|
401
|
+
if (target !== document.body) {
|
|
402
|
+
throw new Error('Mutation.createObserver only supports observing document.body');
|
|
403
|
+
}
|
|
404
|
+
this._startObserving();
|
|
405
|
+
if (!subscription) subscription = this.subscribeRaw(cb);
|
|
406
|
+
},
|
|
407
|
+
disconnect: () => {
|
|
408
|
+
if (subscription) { subscription.unsubscribe(); subscription = null; }
|
|
409
|
+
},
|
|
410
|
+
takeRecords: () => (subscription ? subscription.drain() : []),
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
_handleMutations(mutations) {
|
|
415
|
+
if (this._pauseDepth > 0) {
|
|
416
|
+
// While paused (e.g. during a live-sync morph), only non-pausable
|
|
417
|
+
// callbacks should run. With none registered, take the fast path.
|
|
418
|
+
if (!this._hasNonPausable) {
|
|
419
|
+
this._log(`Skipping ${mutations.length} mutations (paused)`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this._processMutations(mutations, true);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this._processMutations(mutations, false);
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
_processMutations(mutations, onlyNonPausable = false) {
|
|
429
|
+
const changes = [];
|
|
430
|
+
const changesByType = {
|
|
431
|
+
add: [],
|
|
432
|
+
remove: [],
|
|
433
|
+
attribute: [],
|
|
434
|
+
characterData: []
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
for (const mutation of mutations) {
|
|
438
|
+
// Intake drop: a no-watch / mutations-ignore subtree (or extension noise)
|
|
439
|
+
// is invisible to every consumer, so skip it without walking. All other
|
|
440
|
+
// region attributes are resolved per-consumer in _notify.
|
|
441
|
+
if (isInert(mutation.target)) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Ignore extension marker attributes (e.g. password-manager field tags) stamped onto real elements.
|
|
446
|
+
if (mutation.type === 'attributes' && mutation.attributeName &&
|
|
447
|
+
EXTENSION_ATTR_PATTERN.test(mutation.attributeName.toLowerCase())) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (mutation.type === 'characterData') {
|
|
452
|
+
this._log('Processing characterData mutation', {
|
|
453
|
+
element: mutation.target.parentElement,
|
|
454
|
+
oldValue: mutation.oldValue,
|
|
455
|
+
newValue: mutation.target.textContent
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const change = {
|
|
459
|
+
type: 'characterData',
|
|
460
|
+
element: mutation.target.parentElement ?? dummyElem, // hacky, but ensures we always pass an element in the callback
|
|
461
|
+
oldValue: mutation.oldValue,
|
|
462
|
+
newValue: mutation.target.textContent
|
|
463
|
+
};
|
|
464
|
+
changes.push(change);
|
|
465
|
+
changesByType.characterData.push(change);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (mutation.type === 'childList') {
|
|
469
|
+
this._log('Processing childList mutation', {
|
|
470
|
+
addedNodes: mutation.addedNodes,
|
|
471
|
+
removedNodes: mutation.removedNodes
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
for (const node of mutation.addedNodes) {
|
|
475
|
+
if (node.nodeType === 1 && !isInert(node)) {
|
|
476
|
+
const addedNodes = [node, ...node.querySelectorAll('*')];
|
|
477
|
+
this._log(`Processing ${addedNodes.length} added nodes`, { addedNodes });
|
|
478
|
+
|
|
479
|
+
for (const element of addedNodes) {
|
|
480
|
+
const change = {
|
|
481
|
+
type: 'add',
|
|
482
|
+
element,
|
|
483
|
+
parent: mutation.target,
|
|
484
|
+
previousSibling: mutation.previousSibling,
|
|
485
|
+
nextSibling: mutation.nextSibling
|
|
486
|
+
};
|
|
487
|
+
changes.push(change);
|
|
488
|
+
changesByType.add.push(change);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const node of mutation.removedNodes) {
|
|
494
|
+
if (node.nodeType === 1 && !isInert(node)) {
|
|
495
|
+
const removedNodes = [node, ...node.querySelectorAll('*')];
|
|
496
|
+
this._log(`Processing ${removedNodes.length} removed nodes`, { removedNodes });
|
|
497
|
+
|
|
498
|
+
for (const element of removedNodes) {
|
|
499
|
+
const change = {
|
|
500
|
+
type: 'remove',
|
|
501
|
+
element,
|
|
502
|
+
parent: mutation.target,
|
|
503
|
+
previousSibling: mutation.previousSibling,
|
|
504
|
+
nextSibling: mutation.nextSibling
|
|
505
|
+
};
|
|
506
|
+
changes.push(change);
|
|
507
|
+
changesByType.remove.push(change);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Bubble text-node child changes (e.g. `el.textContent = 'foo'`) up
|
|
513
|
+
// to the parent element so onAnyChange fires. Typed callbacks
|
|
514
|
+
// (addElement, removeElement) stay element-only by design.
|
|
515
|
+
let hasTextNodeChanges = false;
|
|
516
|
+
for (const node of mutation.addedNodes) {
|
|
517
|
+
if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
|
|
518
|
+
}
|
|
519
|
+
if (!hasTextNodeChanges) {
|
|
520
|
+
for (const node of mutation.removedNodes) {
|
|
521
|
+
if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (hasTextNodeChanges && mutation.target.nodeType === 1) {
|
|
525
|
+
const change = {
|
|
526
|
+
type: 'characterData',
|
|
527
|
+
element: mutation.target,
|
|
528
|
+
oldValue: undefined,
|
|
529
|
+
newValue: mutation.target.textContent
|
|
530
|
+
};
|
|
531
|
+
changes.push(change);
|
|
532
|
+
changesByType.characterData.push(change);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (mutation.type === 'attributes') {
|
|
537
|
+
this._log('Processing attribute mutation', {
|
|
538
|
+
element: mutation.target,
|
|
539
|
+
attribute: mutation.attributeName,
|
|
540
|
+
oldValue: mutation.oldValue,
|
|
541
|
+
newValue: mutation.target.getAttribute(mutation.attributeName)
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const change = {
|
|
545
|
+
type: 'attribute',
|
|
546
|
+
element: mutation.target,
|
|
547
|
+
attribute: mutation.attributeName,
|
|
548
|
+
oldValue: mutation.oldValue,
|
|
549
|
+
newValue: mutation.target.getAttribute(mutation.attributeName)
|
|
550
|
+
};
|
|
551
|
+
changes.push(change);
|
|
552
|
+
changesByType.attribute.push(change);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (changes.length) {
|
|
557
|
+
this._log('Processing collected changes', {
|
|
558
|
+
total: changes.length,
|
|
559
|
+
byType: {
|
|
560
|
+
add: changesByType.add.length,
|
|
561
|
+
remove: changesByType.remove.length,
|
|
562
|
+
attribute: changesByType.attribute.length
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Data-guard provenance: if a trusted gesture drove this turn (or one
|
|
567
|
+
// happened within the recency window) and any change is autosave-relevant
|
|
568
|
+
// (matches the save's region scope), mark the pending save user-driven.
|
|
569
|
+
// Runs synchronously in the MO callback; skipped during paused-morph drains.
|
|
570
|
+
if (!onlyNonPausable && isUserDrivenNow()) {
|
|
571
|
+
for (const change of changes) {
|
|
572
|
+
if (!skipForPolicy(this._policyForChange(change), 'autosave')) {
|
|
573
|
+
markUserDriven();
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
this._notify('anyChange', changes, onlyNonPausable);
|
|
580
|
+
|
|
581
|
+
const addOrRemove = [...changesByType.add, ...changesByType.remove];
|
|
582
|
+
if (addOrRemove.length) {
|
|
583
|
+
this._notify('addOrRemove', addOrRemove, onlyNonPausable);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (changesByType.add.length) {
|
|
587
|
+
this._notify('addElement', changesByType.add, onlyNonPausable);
|
|
588
|
+
}
|
|
589
|
+
if (changesByType.remove.length) {
|
|
590
|
+
this._notify('removeElement', changesByType.remove, onlyNonPausable);
|
|
591
|
+
}
|
|
592
|
+
if (changesByType.attribute.length) {
|
|
593
|
+
this._notify('attribute', changesByType.attribute, onlyNonPausable);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
_observer: null,
|
|
599
|
+
|
|
600
|
+
_initializeObserver() {
|
|
601
|
+
if (!this._observer) {
|
|
602
|
+
this._log('Initializing MutationObserver');
|
|
603
|
+
this._observer = new MutationObserver(this._onRecords.bind(this));
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
_addCallback(type, options = {}, callback) {
|
|
608
|
+
this._log('Adding callback', { type, options });
|
|
609
|
+
|
|
610
|
+
if (options.debug) {
|
|
611
|
+
this.debug = true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const cb = {
|
|
615
|
+
fn: callback,
|
|
616
|
+
debounce: options.debounce || 0,
|
|
617
|
+
selectorFilter: options.selectorFilter,
|
|
618
|
+
omitChangeDetails: options.omitChangeDetails,
|
|
619
|
+
// Region policy: axis this consumer needs ('observed' | 'autosave' | 'undo')
|
|
620
|
+
// or a literal attribute escape-hatch. Unset => legacy four-marker skip.
|
|
621
|
+
require: options.require,
|
|
622
|
+
skip: options.skip,
|
|
623
|
+
// pausable:false keeps the callback firing during Mutation.pause() (pure
|
|
624
|
+
// enhancers that never save/record/rebroadcast). Default true.
|
|
625
|
+
pausable: options.pausable !== false,
|
|
626
|
+
timeout: null,
|
|
627
|
+
pendingChanges: null
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
this._callbacks[type].push(cb);
|
|
631
|
+
this._recomputeHasNonPausable();
|
|
632
|
+
this._log(`Added callback to ${type}. Total callbacks:`, {
|
|
633
|
+
[type]: this._callbacks[type].length
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
this._startObserving();
|
|
637
|
+
|
|
638
|
+
return () => {
|
|
639
|
+
this._log('Removing callback', { type });
|
|
640
|
+
const index = this._callbacks[type].indexOf(cb);
|
|
641
|
+
if (index !== -1) {
|
|
642
|
+
clearTimeout(cb.timeout);
|
|
643
|
+
cb.pendingChanges = null;
|
|
644
|
+
this._callbacks[type].splice(index, 1);
|
|
645
|
+
this._recomputeHasNonPausable();
|
|
646
|
+
this._log(`Removed callback from ${type}. Remaining callbacks:`, {
|
|
647
|
+
[type]: this._callbacks[type].length
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
_recomputeHasNonPausable() {
|
|
654
|
+
this._hasNonPausable = Object.values(this._callbacks).some(
|
|
655
|
+
list => list.some(cb => cb.pausable === false)
|
|
656
|
+
);
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
_startObserving() {
|
|
660
|
+
if (this._observing) {
|
|
661
|
+
this._log('Already observing, skipping initialization');
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
this._log('Starting observation');
|
|
666
|
+
this._initializeObserver();
|
|
667
|
+
this._observer.observe(document.body, {
|
|
668
|
+
childList: true,
|
|
669
|
+
attributes: true,
|
|
670
|
+
subtree: true,
|
|
671
|
+
characterData: true,
|
|
672
|
+
attributeOldValue: true,
|
|
673
|
+
characterDataOldValue: true
|
|
674
|
+
});
|
|
675
|
+
this._observing = true;
|
|
676
|
+
this._log('Observation started');
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
onAnyChange(options = {}, callback) {
|
|
680
|
+
return this._addCallback('anyChange', options, callback);
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
onAddOrRemove(options = {}, callback) {
|
|
684
|
+
return this._addCallback('addOrRemove', options, callback);
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
onAddElement(options = {}, callback) {
|
|
688
|
+
return this._addCallback('addElement', options, callback);
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
onRemoveElement(options = {}, callback) {
|
|
692
|
+
return this._addCallback('removeElement', options, callback);
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
onAttribute(options = {}, callback) {
|
|
696
|
+
return this._addCallback('attribute', options, callback);
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Start the singleton observer without registering a callback. The data-clobber
|
|
701
|
+
* chip (data-loss-panel) calls this so user-driven attribution runs wherever the
|
|
702
|
+
* chip ships, not only when another module (e.g. option-visibility) happens to
|
|
703
|
+
* subscribe. Idempotent — the _observing guard makes repeat calls a no-op.
|
|
704
|
+
*/
|
|
705
|
+
ensureObserving() {
|
|
706
|
+
this._startObserving();
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Signal consumers (e.g. hypercms ?cms=true auto-open) that Mutation is ready,
|
|
711
|
+
// so they can react instead of polling. Wrapped so a dispatch failure can never
|
|
712
|
+
// break the install.
|
|
713
|
+
try {
|
|
714
|
+
document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
|
|
715
|
+
// vendor-compat: hypercms's readiness fast path listens for the legacy name
|
|
716
|
+
document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
|
|
717
|
+
} catch {}
|
|
718
|
+
|
|
719
|
+
export default Mutation;
|