@pariharshyamu/morph 2.0.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.
Files changed (3) hide show
  1. package/README.md +147 -0
  2. package/morph.js +1133 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @pariharshyamu/morph
2
+
3
+ > Interrupt-safe DOM morphing with spring physics, FLIP animations, and auto-animate.
4
+
5
+ **morph.js v2** patches your DOM like `morphdom`, but adds silky smooth animations — spring physics, FLIP transitions, choreographed enter/exit/move, and mid-animation interruption handling.
6
+
7
+ ## Features
8
+
9
+ - ✦ **Interrupted animation handling** — morphs from current visual position, never snaps
10
+ - ✦ **Scroll-aware FLIP** — corrects `getBoundingClientRect` across scrolled containers
11
+ - ✦ **Stacking-context correction** — handles CSS transform ancestors
12
+ - ✦ **Hungarian algorithm matching** — optimal global assignment, not greedy
13
+ - ✦ **Spring physics engine** — position/velocity integrator, replaces cubic-bezier
14
+ - ✦ **Simultaneous move + content morph** — FLIP + crossfade in one animation track
15
+ - ✦ **Choreography model** — sequence exits, moves, enters independently
16
+ - ✦ **MorphObserver** — auto-animate any DOM mutations on a container
17
+ - ✦ **htmx extension** — hooks into htmx:beforeSwap / htmx:afterSwap
18
+ - ✦ **Confidence-gated matching** — low-confidence pairs become enter/exit instead
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @pariharshyamu/morph
24
+ ```
25
+
26
+ Or use a CDN:
27
+
28
+ ```html
29
+ <script type="module">
30
+ import morph from 'https://unpkg.com/@pariharshyamu/morph';
31
+ </script>
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```js
37
+ import morph from '@pariharshyamu/morph';
38
+
39
+ // Morph an element to new HTML
40
+ const result = morph(element, '<div class="card">New content</div>');
41
+
42
+ // Wait for all animations to complete
43
+ await result.finished;
44
+ ```
45
+
46
+ ## API
47
+
48
+ ### `morph(fromEl, toElOrHTML, options?)`
49
+
50
+ Patches `fromEl` to match `toElOrHTML` (a DOM element or HTML string), animating the transition.
51
+
52
+ **Returns:** `{ animations, finished, debug, cancel() }`
53
+
54
+ ### Options
55
+
56
+ | Option | Default | Description |
57
+ |--------|---------|-------------|
58
+ | `duration` | `400` | Base duration in ms |
59
+ | `easing` | `'spring'` | `'spring'` or any CSS easing string |
60
+ | `spring` | `{ stiffness: 280, damping: 24, mass: 1 }` | Spring physics parameters |
61
+ | `stagger` | `25` | Stagger delay between elements (ms) |
62
+ | `keyAttribute` | `'data-morph-key'` | Attribute for explicit element matching |
63
+ | `matchConfidenceThreshold` | `1.5` | Below this → treat as enter/exit |
64
+ | `respectReducedMotion` | `true` | Skip animation if user prefers reduced motion |
65
+ | `morphContent` | `true` | Cross-fade content changes during move |
66
+ | `debug` | `false` | Log matching info to console |
67
+
68
+ ### `morph.text(el, newText, options?)`
69
+
70
+ Cross-fade text content change with a fade-out → swap → fade-in sequence.
71
+
72
+ ```js
73
+ await morph.text(heading, 'New Title').finished;
74
+ ```
75
+
76
+ ### `morph.prepare(fromEl, toEl, options?)`
77
+
78
+ Create a controllable transition that can be triggered later.
79
+
80
+ ```js
81
+ const transition = morph.prepare(el, newEl, { duration: 500 });
82
+ transition.play(); // triggers when ready
83
+ ```
84
+
85
+ ### `MorphObserver`
86
+
87
+ Auto-animate any DOM mutations on a container:
88
+
89
+ ```js
90
+ import { MorphObserver } from '@pariharshyamu/morph';
91
+
92
+ const observer = new MorphObserver(container, { duration: 350 });
93
+ observer.observe();
94
+
95
+ // Now any mutation to container's children will auto-animate
96
+ container.innerHTML = '<div>New content</div>'; // animated!
97
+
98
+ observer.disconnect();
99
+ ```
100
+
101
+ ### htmx Extension
102
+
103
+ ```html
104
+ <script src="https://unpkg.com/htmx.org"></script>
105
+ <script type="module">
106
+ import morph from '@pariharshyamu/morph';
107
+ morph.htmx({ duration: 350 });
108
+ </script>
109
+
110
+ <div hx-ext="morph" hx-get="/api/items" hx-swap="innerHTML">
111
+ <!-- content swaps are now animated -->
112
+ </div>
113
+ ```
114
+
115
+ ### Choreography
116
+
117
+ Control the timing of exit, move, and enter phases:
118
+
119
+ ```js
120
+ morph(el, newHTML, {
121
+ choreography: {
122
+ exit: { at: 0, duration: 0.5 }, // fractions of total duration
123
+ move: { at: 0.15, duration: 0.85 },
124
+ enter: { at: 0.4, duration: 0.6 },
125
+ }
126
+ });
127
+ ```
128
+
129
+ ### Custom Hooks
130
+
131
+ ```js
132
+ morph(el, newHTML, {
133
+ onEnter: (el) => [
134
+ { opacity: 0, transform: 'translateX(-20px)' },
135
+ { opacity: 1, transform: 'translateX(0)' },
136
+ ],
137
+ onLeave: (el) => [
138
+ { opacity: 1 },
139
+ { opacity: 0, transform: 'scale(0.8)' },
140
+ ],
141
+ onMove: (el, fromRect, toRect) => null, // return keyframes or null
142
+ });
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
package/morph.js ADDED
@@ -0,0 +1,1133 @@
1
+ /**
2
+ * morph.js v2
3
+ *
4
+ * What's new vs v1:
5
+ * ✦ Interrupted animation handling — morphs from current visual position, never snaps
6
+ * ✦ Scroll-aware FLIP — corrects getBoundingClientRect across scrolled containers
7
+ * ✦ Stacking-context correction — handles CSS transform ancestors
8
+ * ✦ Hungarian algorithm matching — optimal global assignment, not greedy
9
+ * ✦ Spring physics engine — position/velocity integrator, replaces cubic-bezier
10
+ * ✦ Simultaneous move + content morph — FLIP + crossfade in one animation track
11
+ * ✦ Choreography model — sequence exits, moves, enters independently
12
+ * ✦ MorphObserver — auto-animate any DOM mutations on a container
13
+ * ✦ htmx extension — hooks into htmx:beforeSwap / htmx:afterSwap
14
+ * ✦ Confidence-gated matching — low-confidence pairs become enter/exit instead
15
+ */
16
+
17
+ // ═══════════════════════════════════════════════════════════════
18
+ // § 1 — Constants & Defaults
19
+ // ═══════════════════════════════════════════════════════════════
20
+
21
+ const VERSION = '2.0.0';
22
+
23
+ const DEFAULTS = {
24
+ // Timing
25
+ duration: 400,
26
+ easing: 'spring', // 'spring' | any CSS easing string
27
+ spring: { stiffness: 280, damping: 24, mass: 1 },
28
+ stagger: 25,
29
+
30
+ // Matching
31
+ keyAttribute: 'data-morph-key',
32
+ matchConfidenceThreshold: 1.5, // below this → treat as enter/exit
33
+
34
+ // Hooks
35
+ onEnter: null, // (el) => keyframes[]
36
+ onLeave: null, // (el) => keyframes[]
37
+ onMove: null, // (el, fromRect, toRect) => keyframes[] | null
38
+
39
+ // Choreography
40
+ choreography: {
41
+ exit: { at: 0, duration: 0.5 }, // fractions of total duration
42
+ move: { at: 0.15, duration: 0.85 },
43
+ enter: { at: 0.4, duration: 0.6 },
44
+ },
45
+
46
+ // Behaviour
47
+ respectReducedMotion: true,
48
+ morphContent: true, // cross-fade content changes during move
49
+
50
+ // Debug
51
+ debug: false,
52
+ };
53
+
54
+
55
+ // ═══════════════════════════════════════════════════════════════
56
+ // § 2 — Spring Physics Engine
57
+ // ═══════════════════════════════════════════════════════════════
58
+
59
+ /**
60
+ * Runs a spring simulation and returns an array of {t, value} samples
61
+ * that approximate the spring curve as a custom easing.
62
+ */
63
+ function solveSpring(stiffness, damping, mass, precision = 0.001) {
64
+ // Analytical solution for under-damped spring
65
+ const w0 = Math.sqrt(stiffness / mass); // natural frequency
66
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass)); // damping ratio
67
+
68
+ if (zeta >= 1) {
69
+ // Over/critically damped — simple ease-out fallback with correct {t, value} shape
70
+ return {
71
+ samples: [
72
+ { t: 0, value: 0 },
73
+ { t: 0.4, value: 0.72 },
74
+ { t: 0.7, value: 0.92 },
75
+ { t: 1, value: 1 },
76
+ ],
77
+ settleTime: 1,
78
+ };
79
+ }
80
+
81
+ const wd = w0 * Math.sqrt(1 - zeta * zeta); // damped frequency
82
+
83
+ // Returns displacement [0→1] at time t (seconds)
84
+ const displacement = (t) =>
85
+ 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + (zeta * w0 / wd) * Math.sin(wd * t));
86
+
87
+ // Find settling time (where |1 - displacement| < precision)
88
+ let settleTime = 0.1;
89
+ while (settleTime < 10) {
90
+ if (Math.abs(1 - displacement(settleTime)) < precision &&
91
+ Math.abs(1 - displacement(settleTime + 0.016)) < precision) break;
92
+ settleTime += 0.016;
93
+ }
94
+
95
+ // Sample the curve into keyframes for WAAPI
96
+ const samples = [];
97
+ const steps = 60;
98
+ for (let i = 0; i <= steps; i++) {
99
+ const t = i / steps;
100
+ const value = displacement(t * settleTime);
101
+ samples.push({ t, value: Math.max(0, Math.min(2, value)) }); // clamp overshoot
102
+ }
103
+
104
+ return { samples, settleTime };
105
+ }
106
+
107
+ /**
108
+ * Generate WAAPI keyframes for a spring-animated FLIP move.
109
+ * Instead of a 2-keyframe from/to, we emit N keyframes tracing the spring curve.
110
+ */
111
+ function springFLIPKeyframes(dx, dy, scaleX, scaleY, springOpts) {
112
+ const { stiffness, damping, mass } = springOpts;
113
+ const { samples } = solveSpring(stiffness, damping, mass);
114
+
115
+ return samples.map(({ t, value }) => {
116
+ // value goes 0→1, so at start we're at full offset, at end we're at 0
117
+ const progress = value;
118
+ const curDx = dx * (1 - progress);
119
+ const curDy = dy * (1 - progress);
120
+ const curScaleX = scaleX + (1 - scaleX) * progress;
121
+ const curScaleY = scaleY + (1 - scaleY) * progress;
122
+
123
+ return {
124
+ offset: t,
125
+ transform: `translate(${curDx}px, ${curDy}px) scale(${curScaleX}, ${curScaleY})`,
126
+ transformOrigin: 'top left',
127
+ easing: 'linear',
128
+ };
129
+ });
130
+ }
131
+
132
+
133
+ // ═══════════════════════════════════════════════════════════════
134
+ // § 3 — Coordinate System & Scroll Correction
135
+ // ═══════════════════════════════════════════════════════════════
136
+
137
+ /**
138
+ * Get the "offset root" — the nearest positioned/transformed ancestor
139
+ * that will be the coordinate origin for our FLIP transform.
140
+ */
141
+ function getOffsetRoot(el) {
142
+ let parent = el.parentElement;
143
+ while (parent && parent !== document.body) {
144
+ const style = getComputedStyle(parent);
145
+ const pos = style.position;
146
+ const transform = style.transform;
147
+ const willChange = style.willChange;
148
+
149
+ if (pos !== 'static' ||
150
+ (transform && transform !== 'none') ||
151
+ (willChange && willChange !== 'auto')) {
152
+ return parent;
153
+ }
154
+ parent = parent.parentElement;
155
+ }
156
+ return document.documentElement;
157
+ }
158
+
159
+ /**
160
+ * Get the rect of an element corrected for:
161
+ * 1. The scroll position of all ancestor scroll containers
162
+ * 2. Any CSS transforms on ancestor elements
163
+ *
164
+ * Returns coordinates relative to the viewport, but with scroll offsets
165
+ * factored out so that FLIP math is correct even inside scrolled containers.
166
+ */
167
+ function getCorrectedRect(el) {
168
+ const rect = el.getBoundingClientRect();
169
+
170
+ // Walk ancestors to accumulate scroll offsets
171
+ let scrollX = 0, scrollY = 0;
172
+ let ancestor = el.parentElement;
173
+
174
+ while (ancestor && ancestor !== document.documentElement) {
175
+ const style = getComputedStyle(ancestor);
176
+ const overflow = style.overflow + style.overflowX + style.overflowY;
177
+
178
+ if (overflow.includes('scroll') || overflow.includes('auto') || overflow.includes('hidden')) {
179
+ scrollX += ancestor.scrollLeft;
180
+ scrollY += ancestor.scrollTop;
181
+ }
182
+ ancestor = ancestor.parentElement;
183
+ }
184
+
185
+ return {
186
+ left: rect.left + scrollX,
187
+ top: rect.top + scrollY,
188
+ right: rect.right + scrollX,
189
+ bottom: rect.bottom + scrollY,
190
+ width: rect.width,
191
+ height: rect.height,
192
+ // Keep raw viewport rect for ghost positioning
193
+ viewportRect: rect,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Snapshot visual state of an element MID-ANIMATION.
199
+ * This is the key to interrupted animation handling.
200
+ *
201
+ * We temporarily pause all running animations, read the computed style
202
+ * (which reflects the current visual position), then resume.
203
+ */
204
+ function snapshotVisualState(el) {
205
+ const runningAnims = el.getAnimations ? el.getAnimations() : [];
206
+
207
+ // Pause all to freeze the visual state
208
+ runningAnims.forEach(a => a.pause());
209
+
210
+ const rect = getCorrectedRect(el);
211
+ const style = getComputedStyle(el);
212
+ const opacity = parseFloat(style.opacity);
213
+ const transform = style.transform;
214
+
215
+ // Resume animations (we've read what we need)
216
+ runningAnims.forEach(a => a.play());
217
+
218
+ return { rect, opacity, transform, animations: runningAnims };
219
+ }
220
+
221
+
222
+ // ═══════════════════════════════════════════════════════════════
223
+ // § 4 — Hungarian Algorithm (Optimal Matching)
224
+ // ═══════════════════════════════════════════════════════════════
225
+
226
+ /**
227
+ * Build a cost matrix between from-elements and to-elements.
228
+ * Lower cost = better match.
229
+ */
230
+ function buildCostMatrix(fromEls, toEls, keyAttr) {
231
+ const n = Math.max(fromEls.length, toEls.length);
232
+ // Square matrix, padded with Infinity for dummy assignments
233
+ const matrix = Array.from({ length: n }, () => Array(n).fill(Infinity));
234
+
235
+ for (let i = 0; i < fromEls.length; i++) {
236
+ for (let j = 0; j < toEls.length; j++) {
237
+ const from = fromEls[i];
238
+ const to = toEls[j];
239
+
240
+ // Explicit key match = 0 cost (perfect)
241
+ const fromKey = from.getAttribute?.(keyAttr);
242
+ const toKey = to.getAttribute?.(keyAttr);
243
+ if (fromKey && toKey && fromKey === toKey) {
244
+ matrix[i][j] = 0;
245
+ continue;
246
+ }
247
+ // Key mismatch (both have keys but different) = infinite
248
+ if (fromKey && toKey && fromKey !== toKey) {
249
+ matrix[i][j] = Infinity;
250
+ continue;
251
+ }
252
+
253
+ // Similarity-based cost
254
+ matrix[i][j] = similarityCost(from, to);
255
+ }
256
+ }
257
+
258
+ return matrix;
259
+ }
260
+
261
+ function fingerprint(el) {
262
+ if (!el || el.nodeType !== 1) return '';
263
+ const tag = el.tagName;
264
+ const id = el.id ? `#${el.id}` : '';
265
+ const cls = Array.from(el.classList).sort().join('.');
266
+ const text = (el.textContent || '').trim().slice(0, 48);
267
+ const src = el.getAttribute?.('src') || '';
268
+ const href = el.getAttribute?.('href') || '';
269
+ return `${tag}${id}|${cls}|${text}|${src}|${href}`;
270
+ }
271
+
272
+ function similarityCost(a, b) {
273
+ if (a.tagName !== b.tagName) return Infinity;
274
+
275
+ let score = 0;
276
+
277
+ // ID match
278
+ if (a.id && a.id === b.id) score += 20;
279
+
280
+ // Class overlap
281
+ const aClasses = new Set(a.classList);
282
+ const bClasses = new Set(b.classList);
283
+ const union = new Set([...aClasses, ...bClasses]).size;
284
+ const intersect = [...aClasses].filter(c => bClasses.has(c)).length;
285
+ if (union > 0) score += 10 * (intersect / union); // Jaccard
286
+
287
+ // Text similarity (rough)
288
+ const aText = (a.textContent || '').trim().slice(0, 64);
289
+ const bText = (b.textContent || '').trim().slice(0, 64);
290
+ if (aText && aText === bText) score += 15;
291
+ else if (aText && bText) {
292
+ // Rough char overlap
293
+ const shorter = aText.length < bText.length ? aText : bText;
294
+ let matches = 0;
295
+ for (const char of shorter) { if (bText.includes(char)) matches++; }
296
+ score += 5 * (matches / Math.max(aText.length, bText.length));
297
+ }
298
+
299
+ // Structural depth similarity
300
+ const aDepth = a.querySelectorAll('*').length;
301
+ const bDepth = b.querySelectorAll('*').length;
302
+ const depthDiff = Math.abs(aDepth - bDepth);
303
+ score += Math.max(0, 5 - depthDiff);
304
+
305
+ // Full fingerprint match bonus
306
+ if (fingerprint(a) === fingerprint(b)) score += 30;
307
+
308
+ // Convert score to cost (invert, since Hungarian minimizes)
309
+ return Math.max(0, 80 - score);
310
+ }
311
+
312
+ /**
313
+ * Hungarian algorithm (Munkres) for minimum cost assignment.
314
+ * O(n³) — fast enough for n < 200.
315
+ */
316
+ function hungarian(costMatrix) {
317
+ const n = costMatrix.length;
318
+ const INF = 1e9;
319
+
320
+ // Pad infinite entries to a large finite number for the algorithm
321
+ const C = costMatrix.map(row => row.map(v => (v === Infinity ? INF : v)));
322
+
323
+ const u = Array(n + 1).fill(0);
324
+ const v = Array(n + 1).fill(0);
325
+ const p = Array(n + 1).fill(0); // assignment: column j → row
326
+ const way = Array(n + 1).fill(0);
327
+
328
+ for (let i = 1; i <= n; i++) {
329
+ p[0] = i;
330
+ let j0 = 0;
331
+ const minVal = Array(n + 1).fill(INF);
332
+ const used = Array(n + 1).fill(false);
333
+
334
+ do {
335
+ used[j0] = true;
336
+ let i0 = p[j0], delta = INF, j1 = -1;
337
+
338
+ for (let j = 1; j <= n; j++) {
339
+ if (!used[j]) {
340
+ const cur = C[i0 - 1][j - 1] - u[i0] - v[j];
341
+ if (cur < minVal[j]) { minVal[j] = cur; way[j] = j0; }
342
+ if (minVal[j] < delta) { delta = minVal[j]; j1 = j; }
343
+ }
344
+ }
345
+
346
+ for (let j = 0; j <= n; j++) {
347
+ if (used[j]) { u[p[j]] += delta; v[j] -= delta; }
348
+ else minVal[j] -= delta;
349
+ }
350
+
351
+ j0 = j1;
352
+ } while (p[j0] !== 0);
353
+
354
+ do {
355
+ const j1 = way[j0];
356
+ p[j0] = p[j1];
357
+ j0 = j1;
358
+ } while (j0);
359
+ }
360
+
361
+ // Extract assignment: ans[i] = j means fromEl[i] → toEl[j]
362
+ const assignment = Array(n).fill(-1);
363
+ for (let j = 1; j <= n; j++) {
364
+ if (p[j] !== 0) assignment[p[j] - 1] = j - 1;
365
+ }
366
+ return assignment;
367
+ }
368
+
369
+ /**
370
+ * Full tree matching using Hungarian algorithm.
371
+ * Returns Map<fromEl, toEl> and a parallel Map<fromEl, cost> for confidence gating.
372
+ */
373
+ function buildMatchMap(fromChildren, toChildren, keyAttr, confidenceThreshold) {
374
+ if (fromChildren.length === 0 || toChildren.length === 0) {
375
+ return { matches: new Map(), costs: new Map() };
376
+ }
377
+
378
+ const costMatrix = buildCostMatrix(fromChildren, toChildren, keyAttr);
379
+ const assignment = hungarian(costMatrix);
380
+
381
+ const matches = new Map();
382
+ const costs = new Map();
383
+ const n = Math.min(fromChildren.length, toChildren.length);
384
+
385
+ for (let i = 0; i < fromChildren.length; i++) {
386
+ const j = assignment[i];
387
+ if (j == null || j < 0 || j >= toChildren.length) continue;
388
+
389
+ const cost = costMatrix[i]?.[j] ?? Infinity;
390
+
391
+ // Confidence gate: very high cost → no match, treat as enter/exit
392
+ if (cost === Infinity || cost > (80 - confidenceThreshold * 10)) continue;
393
+
394
+ matches.set(fromChildren[i], toChildren[j]);
395
+ costs.set(fromChildren[i], cost);
396
+ }
397
+
398
+ return { matches, costs };
399
+ }
400
+
401
+
402
+ // ═══════════════════════════════════════════════════════════════
403
+ // § 5 — DOM Patcher
404
+ // ═══════════════════════════════════════════════════════════════
405
+
406
+ function patchDOM(fromEl, toEl) {
407
+ // Sync attributes
408
+ const toAttrs = Array.from(toEl.attributes);
409
+ const fromAttrs = Array.from(fromEl.attributes);
410
+
411
+ for (const { name } of fromAttrs) {
412
+ if (!toEl.hasAttribute(name)) fromEl.removeAttribute(name);
413
+ }
414
+ for (const { name, value } of toAttrs) {
415
+ if (fromEl.getAttribute(name) !== value) fromEl.setAttribute(name, value);
416
+ }
417
+
418
+ // Snapshot both node lists as static arrays before any DOM mutation.
419
+ const fN = Array.from(fromEl.childNodes);
420
+ const tN = Array.from(toEl.childNodes);
421
+
422
+ // Walk to-nodes. For each, find the next compatible from-node to reuse.
423
+ // Skip and remove unmatched from-nodes as we go.
424
+ let fi = 0;
425
+ for (let ti = 0; ti < tN.length; ti++) {
426
+ const tn = tN[ti];
427
+
428
+ // Find the next compatible from-node at or after fi
429
+ let matchIdx = -1;
430
+ for (let k = fi; k < fN.length; k++) {
431
+ const fn = fN[k];
432
+ const compatible =
433
+ (tn.nodeType === Node.TEXT_NODE && fn.nodeType === Node.TEXT_NODE) ||
434
+ (tn.nodeType === Node.ELEMENT_NODE && fn.nodeType === Node.ELEMENT_NODE && fn.tagName === tn.tagName);
435
+ if (compatible) { matchIdx = k; break; }
436
+ }
437
+
438
+ if (matchIdx === -1) {
439
+ // No compatible from-node — insert a fresh clone before current fi position
440
+ fromEl.insertBefore(tn.cloneNode(true), fN[fi] || null);
441
+ } else {
442
+ // Remove any skipped from-nodes (they have no to-node counterpart)
443
+ for (let k = fi; k < matchIdx; k++) {
444
+ if (fN[k].parentNode === fromEl) fromEl.removeChild(fN[k]);
445
+ }
446
+ fi = matchIdx;
447
+
448
+ const fn = fN[fi];
449
+ if (tn.nodeType === Node.TEXT_NODE) {
450
+ if (fn.textContent !== tn.textContent) fn.textContent = tn.textContent;
451
+ } else {
452
+ patchDOM(fn, tn);
453
+ }
454
+ fi++;
455
+ }
456
+ }
457
+
458
+ // Remove any remaining from-nodes with no to-node
459
+ for (let k = fi; k < fN.length; k++) {
460
+ if (fN[k].parentNode === fromEl) fromEl.removeChild(fN[k]);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Check whether an element's text/src content actually changed
466
+ * so we know to run a cross-fade alongside the FLIP.
467
+ */
468
+ function hasContentChanged(fromEl, toEl) {
469
+ if (!toEl) return false;
470
+ const aText = (fromEl.textContent || '').trim();
471
+ const bText = (toEl.textContent || '').trim();
472
+ if (aText !== bText) return true;
473
+ const attrs = ['src', 'href', 'value', 'placeholder'];
474
+ return attrs.some(a => fromEl.getAttribute(a) !== toEl.getAttribute(a));
475
+ }
476
+
477
+
478
+ // ═══════════════════════════════════════════════════════════════
479
+ // § 6 — Interrupted Animation State Store
480
+ // ═══════════════════════════════════════════════════════════════
481
+
482
+ /**
483
+ * Per-element store tracking:
484
+ * - active animations
485
+ * - last visual snapshot (position when interrupted)
486
+ *
487
+ * This is what allows morph() to be called again mid-flight
488
+ * and start from exactly where things visually are.
489
+ */
490
+ const _elementState = new WeakMap();
491
+
492
+ function getElementState(el) {
493
+ if (!_elementState.has(el)) {
494
+ _elementState.set(el, { animations: [], lastSnapshot: null });
495
+ }
496
+ return _elementState.get(el);
497
+ }
498
+
499
+ /**
500
+ * Cancel any in-flight animations on el, capturing visual state first.
501
+ * Returns the snapshot so the new animation can start from there.
502
+ */
503
+ function cancelAndSnapshot(el) {
504
+ const state = getElementState(el);
505
+
506
+ // Read current visual state (mid-animation position)
507
+ const snapshot = snapshotVisualState(el);
508
+
509
+ // Now cancel all running animations
510
+ const anims = el.getAnimations ? el.getAnimations() : [];
511
+ anims.forEach(a => {
512
+ try { a.cancel(); } catch (_) { }
513
+ });
514
+
515
+ state.animations = [];
516
+ state.lastSnapshot = snapshot;
517
+
518
+ return snapshot;
519
+ }
520
+
521
+ function registerAnimation(el, anim) {
522
+ const state = getElementState(el);
523
+ state.animations.push(anim);
524
+ anim.finished
525
+ .then(() => {
526
+ state.animations = state.animations.filter(a => a !== anim);
527
+ })
528
+ .catch(() => { });
529
+ }
530
+
531
+
532
+ // ═══════════════════════════════════════════════════════════════
533
+ // § 7 — FLIP Animator (Interrupt-Safe + Spring-Aware)
534
+ // ═══════════════════════════════════════════════════════════════
535
+
536
+ /**
537
+ * Core FLIP animation for a single element.
538
+ *
539
+ * Interrupt-safe: if the element is mid-animation, we read its
540
+ * current visual position and animate FROM there (not from the
541
+ * original pre-patch position).
542
+ */
543
+ function animateFLIP(el, fromRect, toRect, opts, delay, contentChanged, toSnapshot) {
544
+ if (!fromRect || !toRect) return null;
545
+
546
+ // If element has running animations, snapshot visual position and cancel
547
+ const hasRunning = (el.getAnimations?.() ?? []).some(a => a.playState === 'running');
548
+ let effectiveFromRect = fromRect;
549
+
550
+ if (hasRunning) {
551
+ const snapshot = cancelAndSnapshot(el);
552
+ // Use the visual snapshot rect instead of the pre-patch rect
553
+ effectiveFromRect = snapshot.rect;
554
+ }
555
+
556
+ const dx = effectiveFromRect.left - toRect.left;
557
+ const dy = effectiveFromRect.top - toRect.top;
558
+ const scaleX = effectiveFromRect.width / (toRect.width || 1);
559
+ const scaleY = effectiveFromRect.height / (toRect.height || 1);
560
+
561
+ const noTranslate = Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5;
562
+ const noScale = Math.abs(scaleX - 1) < 0.01 && Math.abs(scaleY - 1) < 0.01;
563
+
564
+ if (noTranslate && noScale && !contentChanged) return null;
565
+
566
+ // Custom onMove hook
567
+ if (opts.onMove) {
568
+ const custom = opts.onMove(el, effectiveFromRect, toRect);
569
+ if (custom) {
570
+ const anim = el.animate(custom, {
571
+ duration: opts.duration,
572
+ easing: opts.easing === 'spring' ? 'ease-out' : opts.easing,
573
+ delay,
574
+ fill: 'none',
575
+ });
576
+ registerAnimation(el, anim);
577
+ return anim;
578
+ }
579
+ }
580
+
581
+ // Build keyframes
582
+ let moveKeyframes;
583
+ const isSpring = opts.easing === 'spring';
584
+
585
+ if (!noTranslate || !noScale) {
586
+ moveKeyframes = isSpring
587
+ ? springFLIPKeyframes(dx, dy, scaleX, scaleY, opts.spring)
588
+ : [
589
+ { transform: `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})`, transformOrigin: 'top left' },
590
+ { transform: 'translate(0, 0) scale(1, 1)', transformOrigin: 'top left' },
591
+ ];
592
+ }
593
+
594
+ // If content also changed, overlay a crossfade
595
+ if (contentChanged && opts.morphContent) {
596
+ // Clone the old visual into an overlay, fade it out while the element fades in
597
+ const ghost = el.cloneNode(true);
598
+ const rect = toRect.viewportRect || toRect;
599
+
600
+ ghost.style.cssText = [
601
+ `position:fixed`,
602
+ `left:${effectiveFromRect.viewportRect?.left ?? effectiveFromRect.left}px`,
603
+ `top:${effectiveFromRect.viewportRect?.top ?? effectiveFromRect.top}px`,
604
+ `width:${effectiveFromRect.width}px`,
605
+ `height:${effectiveFromRect.height}px`,
606
+ `margin:0`,
607
+ `pointer-events:none`,
608
+ `z-index:9999`,
609
+ ].join(';');
610
+
611
+ document.body.appendChild(ghost);
612
+
613
+ ghost.animate(
614
+ [{ opacity: 1 }, { opacity: 0 }],
615
+ { duration: opts.duration * 0.5, delay, easing: 'ease', fill: 'forwards' }
616
+ ).finished.then(() => ghost.remove()).catch(() => ghost.remove());
617
+
618
+ el.animate(
619
+ [{ opacity: 0 }, { opacity: 1 }],
620
+ { duration: opts.duration * 0.5, delay: delay + opts.duration * 0.3, easing: 'ease', fill: 'backwards' }
621
+ );
622
+ }
623
+
624
+ if (!moveKeyframes) return null;
625
+
626
+ let springDuration = opts.duration;
627
+ if (isSpring) {
628
+ const { settleTime } = solveSpring(opts.spring.stiffness, opts.spring.damping, opts.spring.mass);
629
+ springDuration = settleTime * 1000; // seconds → ms
630
+ }
631
+ const timing = isSpring
632
+ ? { duration: springDuration, easing: 'linear', delay, fill: 'none' }
633
+ : { duration: opts.duration, easing: opts.easing, delay, fill: 'none' };
634
+
635
+ const anim = el.animate(moveKeyframes, timing);
636
+ registerAnimation(el, anim);
637
+ return anim;
638
+ }
639
+
640
+
641
+ // ═══════════════════════════════════════════════════════════════
642
+ // § 8 — Enter / Exit Animators
643
+ // ═══════════════════════════════════════════════════════════════
644
+
645
+ function animateEnter(el, opts, delay) {
646
+ const keyframes = opts.onEnter?.(el) || [
647
+ { opacity: 0, transform: 'scale(0.88) translateY(10px)' },
648
+ { opacity: 1, transform: 'scale(1) translateY(0)' },
649
+ ];
650
+
651
+ const anim = el.animate(keyframes, {
652
+ duration: opts.duration * opts.choreography.enter.duration,
653
+ easing: opts.easing === 'spring' ? 'cubic-bezier(0.34,1.56,0.64,1)' : opts.easing,
654
+ delay,
655
+ fill: 'backwards',
656
+ });
657
+
658
+ registerAnimation(el, anim);
659
+ return anim;
660
+ }
661
+
662
+ function animateLeaveGhost(el, rect, opts, delay) {
663
+ const ghost = el.cloneNode(true);
664
+ const vr = rect.viewportRect || rect;
665
+
666
+ ghost.style.cssText = [
667
+ 'position:fixed',
668
+ `left:${vr.left}px`,
669
+ `top:${vr.top}px`,
670
+ `width:${rect.width}px`,
671
+ `height:${rect.height}px`,
672
+ 'margin:0',
673
+ 'pointer-events:none',
674
+ 'z-index:9998',
675
+ 'will-change:transform,opacity',
676
+ ].join(';');
677
+
678
+ document.body.appendChild(ghost);
679
+
680
+ const keyframes = opts.onLeave?.(ghost, rect) || [
681
+ { opacity: 1, transform: 'scale(1) translateY(0)' },
682
+ { opacity: 0, transform: 'scale(0.88) translateY(-6px)' },
683
+ ];
684
+
685
+ const anim = ghost.animate(keyframes, {
686
+ duration: opts.duration * opts.choreography.exit.duration,
687
+ easing: opts.easing === 'spring' ? 'ease-in' : opts.easing,
688
+ delay,
689
+ fill: 'forwards',
690
+ });
691
+
692
+ anim.finished
693
+ .then(() => ghost.remove())
694
+ .catch(() => ghost.remove());
695
+
696
+ return anim;
697
+ }
698
+
699
+
700
+ // ═══════════════════════════════════════════════════════════════
701
+ // § 9 — Layout Capture
702
+ // ═══════════════════════════════════════════════════════════════
703
+
704
+ function captureLayout(root) {
705
+ const map = new Map();
706
+ map.set(root, getCorrectedRect(root));
707
+ for (const el of root.querySelectorAll('*')) {
708
+ map.set(el, getCorrectedRect(el));
709
+ }
710
+ return map;
711
+ }
712
+
713
+
714
+ // ═══════════════════════════════════════════════════════════════
715
+ // § 10 — Debug Logger
716
+ // ═══════════════════════════════════════════════════════════════
717
+
718
+ function createDebugLog(opts) {
719
+ if (!opts.debug) return { log: () => { }, data: null };
720
+
721
+ const data = {
722
+ timestamp: Date.now(),
723
+ matches: [],
724
+ entering: [],
725
+ leaving: [],
726
+ spring: opts.easing === 'spring' ? opts.spring : null,
727
+ };
728
+
729
+ const log = (type, payload) => {
730
+ if (type === 'match') data.matches.push(payload);
731
+ if (type === 'enter') data.entering.push(payload);
732
+ if (type === 'leave') data.leaving.push(payload);
733
+ if (type === 'summary') {
734
+ console.group(`[morph.js v${VERSION}]`);
735
+ console.log(`Matches: ${data.matches.length} | Entering: ${data.entering.length} | Leaving: ${data.leaving.length}`);
736
+ data.matches.forEach(m =>
737
+ console.log(` MOVE "${m.key || m.from}" → confidence ${(100 - m.cost).toFixed(0)}% Δ(${m.dx?.toFixed(1)}, ${m.dy?.toFixed(1)})`)
738
+ );
739
+ data.entering.forEach(e => console.log(` ENTER "${e.key || e.tag}"`));
740
+ data.leaving.forEach(l => console.log(` LEAVE "${l.key || l.tag}"`));
741
+ console.groupEnd();
742
+ }
743
+ };
744
+
745
+ return { log, data };
746
+ }
747
+
748
+
749
+ // ═══════════════════════════════════════════════════════════════
750
+ // § 11 — morph() — Main Entry Point
751
+ // ═══════════════════════════════════════════════════════════════
752
+
753
+ function morph(fromEl, toElOrHTML, userOpts = {}) {
754
+ const opts = deepMerge(DEFAULTS, userOpts);
755
+
756
+ // Parse input
757
+ let toEl;
758
+ if (typeof toElOrHTML === 'string') {
759
+ toEl = document.createElement(fromEl.tagName);
760
+ toEl.innerHTML = toElOrHTML;
761
+ } else {
762
+ toEl = toElOrHTML;
763
+ }
764
+
765
+ // Reduced motion: instant patch, no animation
766
+ if (opts.respectReducedMotion && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
767
+ patchDOM(fromEl, toEl);
768
+ return { animations: [], finished: Promise.resolve(), debug: null };
769
+ }
770
+
771
+ const debug = createDebugLog(opts);
772
+ const animations = [];
773
+ const dur = opts.duration;
774
+ const choreo = opts.choreography;
775
+
776
+ // ── 1. FIRST: capture layout before any DOM changes ──────────
777
+
778
+ const beforeLayout = captureLayout(fromEl);
779
+
780
+ // Snapshot visual state of all current children (for interruption handling)
781
+ const fromChildren = Array.from(fromEl.children);
782
+ const toChildren = Array.from(toEl.children);
783
+
784
+ // Per-child visual snapshots (captures mid-animation state)
785
+ const snapshots = new Map();
786
+ for (const child of fromChildren) {
787
+ snapshots.set(child, snapshotVisualState(child));
788
+ }
789
+
790
+ // ── 2. Match from-children to to-children (Hungarian) ────────
791
+
792
+ const { matches, costs } = buildMatchMap(
793
+ fromChildren, toChildren,
794
+ opts.keyAttribute,
795
+ opts.matchConfidenceThreshold
796
+ );
797
+
798
+ const matchedFrom = new Set(matches.keys());
799
+ const matchedTo = new Set(matches.values());
800
+
801
+ const leaving = fromChildren.filter(el => !matchedFrom.has(el));
802
+ const entering = toChildren.filter(el => !matchedTo.has(el));
803
+
804
+ // ── 3. Animate LEAVING elements (ghost overlay strategy) ─────
805
+
806
+ const exitDelay = choreo.exit.at * dur;
807
+
808
+ leaving.forEach((el, i) => {
809
+ const rect = beforeLayout.get(el) || getCorrectedRect(el);
810
+ debug.log('leave', { key: el.getAttribute(opts.keyAttribute), tag: el.tagName });
811
+
812
+ const anim = animateLeaveGhost(el, rect, opts, exitDelay + i * opts.stagger * 0.4);
813
+ if (anim) animations.push(anim);
814
+ });
815
+
816
+ // ── 4. LAST: patch the DOM ───────────────────────────────────
817
+
818
+ patchDOM(fromEl, toEl);
819
+
820
+ // ── 5. INVERT + PLAY: animate moved elements ─────────────────
821
+
822
+ const moveDelay = choreo.move.at * dur;
823
+ const afterLayout = captureLayout(fromEl);
824
+
825
+ Array.from(matches.entries()).forEach(([fromChild, toChild], i) => {
826
+ // Use visual snapshot (handles mid-animation interruption)
827
+ const snapshot = snapshots.get(fromChild);
828
+ const beforeRect = snapshot?.rect || beforeLayout.get(fromChild);
829
+
830
+ // After patchDOM, fromChild may be stale. Find the corresponding
831
+ // element in the live DOM by key attribute or fall back to fromChild.
832
+ const key = toChild.getAttribute(opts.keyAttribute);
833
+ let liveEl = fromChild;
834
+ if (key) {
835
+ liveEl = fromEl.querySelector(`[${opts.keyAttribute}="${CSS.escape(key)}"]`) || fromChild;
836
+ }
837
+ const afterRect = afterLayout.get(liveEl) || getCorrectedRect(liveEl);
838
+
839
+ const cost = costs.get(fromChild) ?? 0;
840
+ const changed = hasContentChanged(fromChild, toChild);
841
+ const delay = moveDelay + i * opts.stagger;
842
+
843
+ debug.log('match', {
844
+ key: fromChild.getAttribute(opts.keyAttribute),
845
+ from: fromChild.tagName,
846
+ cost,
847
+ dx: (beforeRect?.left ?? 0) - (afterRect?.left ?? 0),
848
+ dy: (beforeRect?.top ?? 0) - (afterRect?.top ?? 0),
849
+ });
850
+
851
+ const anim = animateFLIP(liveEl, beforeRect, afterRect, opts, delay, changed, toChild);
852
+ if (anim) animations.push(anim);
853
+ });
854
+
855
+ // ── 6. Animate ENTERING elements ─────────────────────────────
856
+
857
+ const enterDelay = choreo.enter.at * dur;
858
+ let enterIdx = 0;
859
+
860
+ for (const child of fromEl.children) {
861
+ if (!beforeLayout.has(child)) {
862
+ debug.log('enter', { key: child.getAttribute(opts.keyAttribute), tag: child.tagName });
863
+ const anim = animateEnter(child, opts, enterDelay + enterIdx * opts.stagger);
864
+ if (anim) animations.push(anim);
865
+ enterIdx++;
866
+ }
867
+ }
868
+
869
+ // ── 7. Debug summary ─────────────────────────────────────────
870
+
871
+ if (opts.debug) debug.log('summary');
872
+
873
+ const finished = Promise.all(animations.map(a => a.finished)).catch(() => { });
874
+
875
+ return {
876
+ animations,
877
+ finished,
878
+ debug: debug.data,
879
+ cancel() { animations.forEach(a => { try { a.cancel(); } catch (_) { } }); },
880
+ };
881
+ }
882
+
883
+
884
+ // ═══════════════════════════════════════════════════════════════
885
+ // § 12 — morph.text() — Cross-Fade Text Changes
886
+ // ═══════════════════════════════════════════════════════════════
887
+
888
+ morph.text = function (el, newText, opts = {}) {
889
+ const duration = opts.duration || 250;
890
+ const easing = opts.easing || 'ease';
891
+
892
+ // Cancel any running text morph on this element
893
+ cancelAndSnapshot(el);
894
+
895
+ const anim = el.animate([{ opacity: 1 }, { opacity: 0 }], {
896
+ duration: duration * 0.45, easing, fill: 'forwards',
897
+ });
898
+
899
+ const done = anim.finished.then(() => {
900
+ el.textContent = newText;
901
+ const anim2 = el.animate([{ opacity: 0 }, { opacity: 1 }], {
902
+ duration: duration * 0.55, easing, fill: 'forwards',
903
+ });
904
+ registerAnimation(el, anim2);
905
+ return anim2.finished;
906
+ }).catch(() => { });
907
+
908
+ registerAnimation(el, anim);
909
+
910
+ return { finished: done, cancel: () => anim.cancel() };
911
+ };
912
+
913
+
914
+ // ═══════════════════════════════════════════════════════════════
915
+ // § 13 — morph.prepare() — Controllable Transition
916
+ // ═══════════════════════════════════════════════════════════════
917
+
918
+ morph.prepare = function (fromEl, toEl, opts = {}) {
919
+ let result = null;
920
+ let played = false;
921
+
922
+ return {
923
+ play() {
924
+ if (!played) { played = true; result = morph(fromEl, toEl, opts); }
925
+ return result;
926
+ },
927
+ cancel() { result?.cancel(); },
928
+ get animations() { return result?.animations || []; },
929
+ get finished() { return result?.finished || Promise.resolve(); },
930
+ get debug() { return result?.debug || null; },
931
+ };
932
+ };
933
+
934
+
935
+ // ═══════════════════════════════════════════════════════════════
936
+ // § 14 — MorphObserver — Auto-Animate DOM Mutations
937
+ // ═══════════════════════════════════════════════════════════════
938
+
939
+ /**
940
+ * Watches a container for DOM mutations (innerHTML swaps, appendChild,
941
+ * removeChild, etc.) and automatically runs morph() on each change.
942
+ *
943
+ * Works with any DOM-mutating approach: htmx, Alpine.js, Turbo,
944
+ * vanilla innerHTML, React portals, etc.
945
+ *
946
+ * Usage:
947
+ * const obs = new MorphObserver(container, { duration: 350 });
948
+ * obs.observe();
949
+ * // Now any mutation to container's children will auto-animate
950
+ * obs.disconnect();
951
+ */
952
+ class MorphObserver {
953
+ constructor(container, opts = {}) {
954
+ this._container = container;
955
+ this._opts = deepMerge(DEFAULTS, opts);
956
+ this._observer = null;
957
+ this._pending = null;
958
+ this._snapshot = null;
959
+ this._debounce = opts.debounce ?? 16; // ms — coalesce rapid mutations
960
+ }
961
+
962
+ observe() {
963
+ if (this._observer) return;
964
+
965
+ this._snapshot = this._captureSnapshot();
966
+ this._paused = false;
967
+
968
+ this._observer = new MutationObserver((mutations) => {
969
+ if (this._paused) return; // ignore mutations we triggered ourselves
970
+ this._onMutations(mutations);
971
+ });
972
+
973
+ this._observer.observe(this._container, {
974
+ childList: true,
975
+ subtree: false,
976
+ attributes: true,
977
+ characterData: true,
978
+ attributeOldValue: true,
979
+ });
980
+
981
+ return this;
982
+ }
983
+
984
+ disconnect() {
985
+ this._observer?.disconnect();
986
+ this._observer = null;
987
+ if (this._pending) { clearTimeout(this._pending); this._pending = null; }
988
+ return this;
989
+ }
990
+
991
+ /**
992
+ * Manually trigger a morph to a new HTML string or element.
993
+ * Useful when you know a change is about to happen and want
994
+ * to control timing.
995
+ */
996
+ morph(toHTML, opts = {}) {
997
+ return morph(this._container, toHTML, { ...this._opts, ...opts });
998
+ }
999
+
1000
+ _captureSnapshot() {
1001
+ return {
1002
+ html: this._container.innerHTML,
1003
+ layout: captureLayout(this._container),
1004
+ children: Array.from(this._container.children).map(el => ({
1005
+ el,
1006
+ key: el.getAttribute(this._opts.keyAttribute),
1007
+ rect: getCorrectedRect(el),
1008
+ })),
1009
+ };
1010
+ }
1011
+
1012
+ _onMutations(mutations) {
1013
+ const prevSnapshot = this._snapshot;
1014
+
1015
+ // Capture current (post-mutation) state immediately and synchronously
1016
+ const newHTML = this._container.innerHTML;
1017
+ this._snapshot = this._captureSnapshot();
1018
+
1019
+ if (this._pending) clearTimeout(this._pending);
1020
+
1021
+ this._pending = setTimeout(() => {
1022
+ this._pending = null;
1023
+
1024
+ // Pause observer so our restore write doesn't re-trigger _onMutations
1025
+ this._paused = true;
1026
+ this._container.innerHTML = prevSnapshot.html;
1027
+ this._paused = false;
1028
+
1029
+ // morph() snapshots layout from the restored state, patches to newHTML
1030
+ const result = morph(this._container, newHTML, this._opts);
1031
+
1032
+ // Sync snapshot to actual DOM state after morph patch
1033
+ this._snapshot = this._captureSnapshot();
1034
+
1035
+ this._opts.onMorph?.(result);
1036
+ }, this._debounce);
1037
+ }
1038
+ }
1039
+
1040
+ morph.Observer = MorphObserver;
1041
+
1042
+
1043
+ // ═══════════════════════════════════════════════════════════════
1044
+ // § 15 — htmx Extension
1045
+ // ═══════════════════════════════════════════════════════════════
1046
+
1047
+ /**
1048
+ * Registers a morph.js htmx extension.
1049
+ *
1050
+ * Usage:
1051
+ * <div hx-ext="morph" hx-swap="innerHTML">...</div>
1052
+ *
1053
+ * morph.htmx({ duration: 350, stagger: 30 });
1054
+ *
1055
+ * How it works:
1056
+ * - On htmx:beforeSwap: capture current layout + snapshot
1057
+ * - On htmx:afterSettle: run morph from captured state to new state
1058
+ */
1059
+ morph.htmx = function (opts = {}) {
1060
+ if (typeof htmx === 'undefined') {
1061
+ console.warn('[morph.js] htmx not found. Call morph.htmx() after htmx loads.');
1062
+ return;
1063
+ }
1064
+
1065
+ const snapshots = new WeakMap();
1066
+
1067
+ htmx.defineExtension('morph', {
1068
+ onEvent(name, evt) {
1069
+ const el = evt.detail?.elt || evt.target;
1070
+ if (!el) return;
1071
+
1072
+ if (name === 'htmx:beforeSwap') {
1073
+ // Snapshot layout before htmx swaps the DOM
1074
+ snapshots.set(el, {
1075
+ layout: captureLayout(el),
1076
+ children: Array.from(el.children),
1077
+ html: el.innerHTML,
1078
+ });
1079
+ }
1080
+
1081
+ if (name === 'htmx:afterSettle') {
1082
+ const snap = snapshots.get(el);
1083
+ if (!snap) return;
1084
+
1085
+ // Save the new (post-swap) HTML, restore old DOM, then morph forward
1086
+ const newHTML = el.innerHTML;
1087
+ el.innerHTML = snap.html;
1088
+
1089
+ morph(el, newHTML, {
1090
+ ...DEFAULTS,
1091
+ ...opts,
1092
+ });
1093
+
1094
+ snapshots.delete(el);
1095
+ }
1096
+ }
1097
+ });
1098
+ };
1099
+
1100
+
1101
+ // ═══════════════════════════════════════════════════════════════
1102
+ // § 16 — Utilities
1103
+ // ═══════════════════════════════════════════════════════════════
1104
+
1105
+ function deepMerge(defaults, overrides) {
1106
+ const result = { ...defaults };
1107
+ for (const key of Object.keys(overrides)) {
1108
+ if (
1109
+ overrides[key] !== null &&
1110
+ typeof overrides[key] === 'object' &&
1111
+ !Array.isArray(overrides[key]) &&
1112
+ defaults[key] !== null &&
1113
+ typeof defaults[key] === 'object' &&
1114
+ !Array.isArray(defaults[key])
1115
+ ) {
1116
+ result[key] = deepMerge(defaults[key], overrides[key]);
1117
+ } else {
1118
+ result[key] = overrides[key];
1119
+ }
1120
+ }
1121
+ return result;
1122
+ }
1123
+
1124
+
1125
+ // ═══════════════════════════════════════════════════════════════
1126
+ // § 17 — Exports
1127
+ // ═══════════════════════════════════════════════════════════════
1128
+
1129
+ morph.version = VERSION;
1130
+ morph.defaults = DEFAULTS;
1131
+
1132
+ export { morph, MorphObserver };
1133
+ export default morph;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@pariharshyamu/morph",
3
+ "version": "2.0.0",
4
+ "description": "Interrupt-safe DOM morphing with spring physics, FLIP animations, Hungarian algorithm matching, and auto-animate via MutationObserver. Works with htmx, Alpine.js, Turbo, or vanilla JS.",
5
+ "main": "morph.js",
6
+ "module": "morph.js",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./morph.js",
11
+ "default": "./morph.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "morph.js",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "morph",
20
+ "dom",
21
+ "flip",
22
+ "animation",
23
+ "spring",
24
+ "transition",
25
+ "htmx",
26
+ "mutation-observer",
27
+ "morphdom",
28
+ "crossfade",
29
+ "waapi"
30
+ ],
31
+ "author": "pariharshyamu",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": ""
36
+ },
37
+ "homepage": "",
38
+ "bugs": {
39
+ "url": ""
40
+ }
41
+ }