@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.
- package/README.md +147 -0
- package/morph.js +1133 -0
- 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
|
+
}
|