zumly 0.18.1 → 0.92.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 +256 -34
- package/dist/zumly.css +243 -2
- package/dist/zumly.js +2 -2
- package/dist/zumly.min.css +1 -1
- package/dist/zumly.mjs +2 -2
- package/docs/DRIVER_API.md +340 -0
- package/package.json +27 -2
- package/src/drivers/driver-helpers.js +315 -0
- package/src/drivers/index.js +49 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Writing a Zumly Transition Driver
|
|
2
|
+
|
|
3
|
+
**Zumly** (*Z over XY* — focus-driven navigation, zoom into what matters) delegates animation to **transition drivers**. A driver controls **how** views move during zoom-in, zoom-out, and lateral navigation. The engine computes **what** moves where (transforms, origins, snapshot); the driver applies motion — CSS keyframes, WAAPI, GSAP, Motion, Anime.js, or a custom timeline.
|
|
4
|
+
|
|
5
|
+
**See also:** [README.md](../README.md) (options, built-in drivers, plugins) · [geometry-optimization.md](geometry-optimization.md) (how zoom-out layout reads are batched before the driver runs).
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
A driver is an object with a single method:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const myDriver = {
|
|
13
|
+
runTransition(spec, onComplete) {
|
|
14
|
+
// Animate views from spec.currentStage backward → forward states
|
|
15
|
+
// Call onComplete() when done — this is mandatory.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Register it in the constructor:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
new Zumly({
|
|
24
|
+
mount: '.canvas',
|
|
25
|
+
initialView: 'home',
|
|
26
|
+
views: { home, detail },
|
|
27
|
+
transitions: {
|
|
28
|
+
driver: myDriver.runTransition, // pass the function directly
|
|
29
|
+
duration: '600ms',
|
|
30
|
+
ease: 'ease-in-out',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or as a factory function:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
transitions: {
|
|
39
|
+
driver: (spec, onComplete) => myDriver.runTransition(spec, onComplete),
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## The `spec` object
|
|
46
|
+
|
|
47
|
+
Your `runTransition(spec, onComplete)` receives a spec with everything needed:
|
|
48
|
+
|
|
49
|
+
### Common fields (all transition types)
|
|
50
|
+
|
|
51
|
+
| Field | Type | Description |
|
|
52
|
+
|-------|------|-------------|
|
|
53
|
+
| `type` | `'zoomIn' \| 'zoomOut' \| 'lateral'` | What kind of transition |
|
|
54
|
+
| `currentView` | `HTMLElement` | The incoming view (zoom-in) or outgoing view (zoom-out) |
|
|
55
|
+
| `previousView` | `HTMLElement` | The parent view |
|
|
56
|
+
| `lastView` | `HTMLElement \| null` | Grandparent view (null at depth ≤ 2) |
|
|
57
|
+
| `currentStage` | `object` | Snapshot with computed states (see below) |
|
|
58
|
+
| `duration` | `string` | e.g. `'500ms'`, `'1s'` |
|
|
59
|
+
| `ease` | `string` | CSS easing, e.g. `'ease-in-out'` |
|
|
60
|
+
| `canvas` | `HTMLElement` | The canvas container (zoom-out and lateral only) |
|
|
61
|
+
|
|
62
|
+
### `currentStage.views[]` — The animation data
|
|
63
|
+
|
|
64
|
+
An array of view entries, indexed by role:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
views[0] → current view (the one zooming in/out)
|
|
68
|
+
views[1] → previous view (the parent)
|
|
69
|
+
views[2] → last view (grandparent, when depth > 2)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each entry has:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
{
|
|
76
|
+
viewName: 'detail',
|
|
77
|
+
backwardState: {
|
|
78
|
+
origin: '125px 100px', // CSS transform-origin
|
|
79
|
+
transform: 'translate(50px, 60px) scale(0.25)', // "start" for zoom-in, "end" for zoom-out
|
|
80
|
+
duration: '500ms',
|
|
81
|
+
ease: 'ease-in-out',
|
|
82
|
+
},
|
|
83
|
+
forwardState: {
|
|
84
|
+
origin: '125px 100px',
|
|
85
|
+
transform: 'translate(100px, 50px)', // "end" for zoom-in, "start" for zoom-out
|
|
86
|
+
duration: '500ms',
|
|
87
|
+
ease: 'ease-in-out',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**For zoom-in:** animate from `backwardState.transform` → `forwardState.transform`.
|
|
93
|
+
**For zoom-out:** animate from `forwardState.transform` → `backwardState.transform`.
|
|
94
|
+
|
|
95
|
+
### Lateral-specific fields
|
|
96
|
+
|
|
97
|
+
| Field | Type | Description |
|
|
98
|
+
|-------|------|-------------|
|
|
99
|
+
| `backView` | `HTMLElement \| null` | The parent view behind current depth |
|
|
100
|
+
| `backViewState` | `{ transformStart, transformEnd }` | Slide transforms for backView |
|
|
101
|
+
| `lastViewState` | `{ transformStart, transformEnd }` | Slide transforms for lastView |
|
|
102
|
+
| `incomingTransformStart` | `string` | Incoming view start transform |
|
|
103
|
+
| `incomingTransformEnd` | `string` | Incoming view final transform |
|
|
104
|
+
| `outgoingTransform` | `string` | Outgoing view current transform |
|
|
105
|
+
| `outgoingTransformEnd` | `string` | Outgoing view exit transform |
|
|
106
|
+
| `slideDeltaX` | `number` | Horizontal slide distance |
|
|
107
|
+
| `slideDeltaY` | `number` | Vertical slide distance |
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## What your driver MUST do
|
|
112
|
+
|
|
113
|
+
### 1. Call `onComplete()` — always, exactly once
|
|
114
|
+
|
|
115
|
+
This is the #1 rule. The engine sets `blockEvents = true` before calling your driver and only resets it in `onComplete`. If you never call it, the UI freezes permanently.
|
|
116
|
+
|
|
117
|
+
Use the `createFinishGuard` helper to guarantee this:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import { createFinishGuard, SAFETY_BUFFER_MS, parseDurationMs } from 'zumly/driver-helpers'
|
|
121
|
+
|
|
122
|
+
function runTransition(spec, onComplete) {
|
|
123
|
+
const durationMs = parseDurationMs(spec.duration)
|
|
124
|
+
|
|
125
|
+
const { finish } = createFinishGuard(() => {
|
|
126
|
+
// cleanup work here...
|
|
127
|
+
onComplete()
|
|
128
|
+
}, durationMs + SAFETY_BUFFER_MS)
|
|
129
|
+
|
|
130
|
+
// Your animation...
|
|
131
|
+
myAnimation.onfinish = finish
|
|
132
|
+
// If the animation fails/gets cancelled, the safety timer calls finish() anyway.
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
*(In this repo, you can import from `../src/drivers/driver-helpers.js` from your own source files.)*
|
|
137
|
+
|
|
138
|
+
### 2. Apply final DOM state after animation
|
|
139
|
+
|
|
140
|
+
When the animation ends, the DOM must reflect the final state — the engine does NOT do this for you. Use the shared helpers:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
import {
|
|
144
|
+
applyZoomInEndState,
|
|
145
|
+
applyZoomOutPreviousState,
|
|
146
|
+
applyZoomOutLastState,
|
|
147
|
+
removeViewFromCanvas,
|
|
148
|
+
showViews
|
|
149
|
+
} from 'zumly/driver-helpers'
|
|
150
|
+
|
|
151
|
+
// Before animating — make views visible:
|
|
152
|
+
showViews(spec.currentView, spec.previousView, spec.lastView)
|
|
153
|
+
|
|
154
|
+
// After zoom-in animation completes:
|
|
155
|
+
applyZoomInEndState(currentView, currentStage)
|
|
156
|
+
applyZoomInEndState(previousView, currentStage)
|
|
157
|
+
if (lastView) applyZoomInEndState(lastView, currentStage)
|
|
158
|
+
|
|
159
|
+
// After zoom-out animation completes:
|
|
160
|
+
removeViewFromCanvas(currentView, canvas)
|
|
161
|
+
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
|
|
162
|
+
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 3. Handle all three types
|
|
166
|
+
|
|
167
|
+
Your driver receives `spec.type` which is one of:
|
|
168
|
+
- `'zoomIn'` — drill deeper
|
|
169
|
+
- `'zoomOut'` — go back
|
|
170
|
+
- `'lateral'` — same-level swap
|
|
171
|
+
|
|
172
|
+
For lateral, the built-in **`waapi`** driver runs a **slide animation**. If you author a minimal custom driver and want **no** lateral motion, call the instant helper:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
import { runLateralInstant } from 'zumly/driver-helpers'
|
|
176
|
+
|
|
177
|
+
if (spec.type === 'lateral') {
|
|
178
|
+
runLateralInstant(spec, onComplete)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Available helpers
|
|
186
|
+
|
|
187
|
+
Import from `zumly/driver-helpers` (published) or `src/drivers/driver-helpers.js` (monorepo):
|
|
188
|
+
|
|
189
|
+
| Helper | Purpose |
|
|
190
|
+
|--------|---------|
|
|
191
|
+
| `parseDurationMs(duration)` | Parse `'1s'`/`'500ms'`/number → ms |
|
|
192
|
+
| `parseDurationSec(duration)` | Same but returns seconds |
|
|
193
|
+
| `applyZoomInEndState(el, stage)` | Apply final classes + transforms after zoom-in |
|
|
194
|
+
| `applyZoomOutPreviousState(el, state)` | Final state for previous view after zoom-out |
|
|
195
|
+
| `applyZoomOutLastState(el, state)` | Final state for last view after zoom-out |
|
|
196
|
+
| `removeViewFromCanvas(el, canvas)` | Safe removal (handles wrapped elements) |
|
|
197
|
+
| `showViews(...elements)` | Remove `hide` class + set `contentVisibility: auto` |
|
|
198
|
+
| `runLateralInstant(spec, onComplete)` | Instant lateral transition (no animation) |
|
|
199
|
+
| `createFinishGuard(cleanup, timeoutMs)` | Once-only finish + safety timeout |
|
|
200
|
+
| `SAFETY_BUFFER_MS` | Default safety buffer (150ms) |
|
|
201
|
+
|
|
202
|
+
### Matrix interpolation toolkit
|
|
203
|
+
|
|
204
|
+
For drivers that need to interpolate through computed CSS matrices (e.g. when transform-origin varies between states):
|
|
205
|
+
|
|
206
|
+
| Helper | Purpose |
|
|
207
|
+
|--------|---------|
|
|
208
|
+
| `readComputedMatrix(el, origin, transform)` | Read browser-computed matrix (⚠️ forces reflow) |
|
|
209
|
+
| `interpolateMatrix(from, to, t)` | Lerp between two matrix objects |
|
|
210
|
+
| `matrixToString(m)` | `{ a,b,c,d,e,f }` → `"matrix(...)"` |
|
|
211
|
+
| `parseMatrixString(str)` | `"matrix(...)"` → `{ a,b,c,d,e,f }` |
|
|
212
|
+
| `identityMatrix()` | Returns `{ a:1, b:0, c:0, d:1, e:0, f:0 }` |
|
|
213
|
+
| `lerp(a, b, t)` | Linear interpolation |
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Example: minimal custom driver
|
|
218
|
+
|
|
219
|
+
A complete, minimal driver that does a simple opacity crossfade instead of zoom:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
import {
|
|
223
|
+
parseDurationMs,
|
|
224
|
+
showViews,
|
|
225
|
+
applyZoomInEndState,
|
|
226
|
+
applyZoomOutPreviousState,
|
|
227
|
+
applyZoomOutLastState,
|
|
228
|
+
removeViewFromCanvas,
|
|
229
|
+
runLateralInstant,
|
|
230
|
+
createFinishGuard,
|
|
231
|
+
SAFETY_BUFFER_MS,
|
|
232
|
+
} from 'zumly/driver-helpers'
|
|
233
|
+
|
|
234
|
+
export function runTransition(spec, onComplete) {
|
|
235
|
+
const { type, currentView, previousView, lastView, currentStage, duration, canvas } = spec
|
|
236
|
+
|
|
237
|
+
if (type === 'lateral') {
|
|
238
|
+
runLateralInstant(spec, onComplete)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ms = parseDurationMs(duration)
|
|
243
|
+
showViews(currentView, previousView, lastView)
|
|
244
|
+
|
|
245
|
+
if (type === 'zoomIn') {
|
|
246
|
+
// Simple crossfade: incoming fades in, outgoing fades out
|
|
247
|
+
currentView.style.opacity = '0'
|
|
248
|
+
currentView.style.transition = `opacity ${ms}ms ease`
|
|
249
|
+
previousView.style.transition = `opacity ${ms}ms ease`
|
|
250
|
+
|
|
251
|
+
requestAnimationFrame(() => {
|
|
252
|
+
currentView.style.opacity = '1'
|
|
253
|
+
previousView.style.opacity = '0.3'
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const { finish } = createFinishGuard(() => {
|
|
257
|
+
currentView.style.transition = ''
|
|
258
|
+
previousView.style.transition = ''
|
|
259
|
+
previousView.style.opacity = ''
|
|
260
|
+
applyZoomInEndState(currentView, currentStage)
|
|
261
|
+
applyZoomInEndState(previousView, currentStage)
|
|
262
|
+
if (lastView) applyZoomInEndState(lastView, currentStage)
|
|
263
|
+
onComplete()
|
|
264
|
+
}, ms + SAFETY_BUFFER_MS)
|
|
265
|
+
|
|
266
|
+
currentView.addEventListener('transitionend', finish, { once: true })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (type === 'zoomOut') {
|
|
271
|
+
currentView.style.transition = `opacity ${ms}ms ease`
|
|
272
|
+
requestAnimationFrame(() => {
|
|
273
|
+
currentView.style.opacity = '0'
|
|
274
|
+
previousView.style.opacity = '1'
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const { finish } = createFinishGuard(() => {
|
|
278
|
+
currentView.style.transition = ''
|
|
279
|
+
removeViewFromCanvas(currentView, canvas)
|
|
280
|
+
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
|
|
281
|
+
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
|
|
282
|
+
onComplete()
|
|
283
|
+
}, ms + SAFETY_BUFFER_MS)
|
|
284
|
+
|
|
285
|
+
currentView.addEventListener('transitionend', finish, { once: true })
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
onComplete()
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Testing your driver
|
|
296
|
+
|
|
297
|
+
Use `driver: 'none'` as a reference — it applies final state instantly and calls `onComplete()` synchronously. Your driver should produce the same final DOM state, just animated.
|
|
298
|
+
|
|
299
|
+
Key things to test:
|
|
300
|
+
1. After zoom-in: `.is-current-view` exists with correct `dataset.viewName`
|
|
301
|
+
2. After zoom-out: previous view is now `.is-current-view`, old current is removed from DOM
|
|
302
|
+
3. `onComplete()` is always called, even if elements are removed mid-animation
|
|
303
|
+
4. `blockEvents` is reset (the engine handles this in `onComplete`, but verify your driver calls it)
|
|
304
|
+
|
|
305
|
+
```js
|
|
306
|
+
const app = new Zumly({
|
|
307
|
+
mount: '.canvas',
|
|
308
|
+
initialView: 'home',
|
|
309
|
+
views: { home, detail },
|
|
310
|
+
transitions: { driver: myDriver, duration: '100ms' },
|
|
311
|
+
})
|
|
312
|
+
await app.init()
|
|
313
|
+
await app.zoomTo('detail')
|
|
314
|
+
expect(app.getCurrentViewName()).toBe('detail')
|
|
315
|
+
app.back()
|
|
316
|
+
expect(app.getCurrentViewName()).toBe('home')
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Registering with `getDriver()`
|
|
322
|
+
|
|
323
|
+
Built-in drivers are resolved by name (`'css'`, `'waapi'`, `'none'`, etc.) in [`src/drivers/index.js`](../src/drivers/index.js). Community drivers are passed as functions — no registration needed:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
// Direct function — works out of the box:
|
|
327
|
+
transitions: { driver: myDriver.runTransition }
|
|
328
|
+
|
|
329
|
+
// If you want to publish as a package:
|
|
330
|
+
// npm: zumly-driver-lottie
|
|
331
|
+
import { runTransition } from 'zumly-driver-lottie'
|
|
332
|
+
new Zumly({ transitions: { driver: runTransition } })
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## See also
|
|
338
|
+
|
|
339
|
+
- [README.md](../README.md) — installation and transition driver table
|
|
340
|
+
- [roadMap.md](roadMap.md) — architecture notes and animation driver history
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zumly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.0",
|
|
4
4
|
"description": "Javascript library for building zooming user interfaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Juan Martin Muda - Zumerlab",
|
|
@@ -22,10 +22,32 @@
|
|
|
22
22
|
"ZUI"
|
|
23
23
|
],
|
|
24
24
|
"main": "dist/zumly.js",
|
|
25
|
+
"module": "dist/zumly.mjs",
|
|
26
|
+
"types": "types/zumly.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./types/zumly.d.ts",
|
|
30
|
+
"import": "./dist/zumly.mjs",
|
|
31
|
+
"require": "./dist/zumly.js",
|
|
32
|
+
"default": "./dist/zumly.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./style.css": "./dist/zumly.css",
|
|
35
|
+
"./css": "./dist/zumly.css",
|
|
36
|
+
"./drivers": {
|
|
37
|
+
"import": "./src/drivers/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./driver-helpers": {
|
|
40
|
+
"import": "./src/drivers/driver-helpers.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": [
|
|
44
|
+
"dist/zumly.css",
|
|
45
|
+
"dist/zumly.min.css"
|
|
46
|
+
],
|
|
25
47
|
"scripts": {
|
|
26
48
|
"compile": "node esbuild.config.mjs",
|
|
27
49
|
"build": "npm run compile && npm pack",
|
|
28
|
-
"dev": "npm run compile && npx serve
|
|
50
|
+
"dev": "npm run compile && npx serve . -p 9090 -c ./serve.json",
|
|
29
51
|
"test": "npx vitest run --browser.headless --reporter=verbose",
|
|
30
52
|
"test:unit": "npx vitest run __tests__/utils.test.js --reporter=verbose",
|
|
31
53
|
"test:coverage": "npx vitest run --browser.headless --coverage",
|
|
@@ -35,6 +57,9 @@
|
|
|
35
57
|
},
|
|
36
58
|
"files": [
|
|
37
59
|
"dist",
|
|
60
|
+
"src/drivers/driver-helpers.js",
|
|
61
|
+
"src/drivers/index.js",
|
|
62
|
+
"docs/DRIVER_API.md",
|
|
38
63
|
"README.md"
|
|
39
64
|
],
|
|
40
65
|
"devDependencies": {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { showViewContent } from '../view-visibility.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for Zumly transition drivers.
|
|
5
|
+
*
|
|
6
|
+
* These utilities handle the repetitive DOM work that every driver needs:
|
|
7
|
+
* applying final states, removing views, parsing durations, and running
|
|
8
|
+
* lateral (same-level) transitions. Import what you need; skip what you don't.
|
|
9
|
+
*
|
|
10
|
+
* @module driver-helpers
|
|
11
|
+
* @see ../../docs/DRIVER_API.md for the full driver authoring guide
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── Duration parsing ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a CSS-style duration string to milliseconds.
|
|
18
|
+
* Accepts "1s", "500ms", "0.3s", or a raw number (treated as ms).
|
|
19
|
+
* Returns a safe fallback (500ms) for garbage input.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|number} duration
|
|
22
|
+
* @returns {number} Duration in milliseconds (≥ 0)
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* parseDurationMs('1s') // → 1000
|
|
26
|
+
* parseDurationMs('200ms') // → 200
|
|
27
|
+
* parseDurationMs(300) // → 300
|
|
28
|
+
* parseDurationMs('nope') // → 500
|
|
29
|
+
*/
|
|
30
|
+
export function parseDurationMs (duration) {
|
|
31
|
+
if (typeof duration === 'number' && !Number.isNaN(duration)) return Math.max(0, duration)
|
|
32
|
+
const str = String(duration)
|
|
33
|
+
const m = str.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i)
|
|
34
|
+
if (!m) return 500
|
|
35
|
+
const val = parseFloat(m[1])
|
|
36
|
+
const unit = (m[2] || 's').toLowerCase()
|
|
37
|
+
return unit === 'ms' ? Math.max(0, val) : Math.max(0, val * 1000)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse duration to seconds (convenience for libs like GSAP that use seconds).
|
|
42
|
+
*
|
|
43
|
+
* @param {string|number} duration
|
|
44
|
+
* @returns {number} Duration in seconds (≥ 0)
|
|
45
|
+
*/
|
|
46
|
+
export function parseDurationSec (duration) {
|
|
47
|
+
return parseDurationMs(duration) / 1000
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── End-state application ───────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Apply the final zoom-in state to an element based on its current class.
|
|
54
|
+
* This is the DOM cleanup that runs after the animation finishes.
|
|
55
|
+
*
|
|
56
|
+
* What it does for each role:
|
|
57
|
+
* - `is-new-current-view` → becomes `is-current-view`, no-events removed, final transform applied
|
|
58
|
+
* - `is-previous-view` → no-events removed, final transform applied
|
|
59
|
+
* - `is-last-view` → no-events removed, final transform applied
|
|
60
|
+
*
|
|
61
|
+
* @param {HTMLElement} element - The view element
|
|
62
|
+
* @param {object} currentStage - The snapshot with `views[]` array
|
|
63
|
+
*/
|
|
64
|
+
export function applyZoomInEndState (element, currentStage) {
|
|
65
|
+
if (element.classList.contains('is-new-current-view')) {
|
|
66
|
+
const v = currentStage.views[0].forwardState
|
|
67
|
+
element.classList.replace('is-new-current-view', 'is-current-view')
|
|
68
|
+
element.classList.remove('zoom-current-view', 'has-no-events')
|
|
69
|
+
element.style.transformOrigin = v.origin
|
|
70
|
+
element.style.transform = v.transform
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (element.classList.contains('is-previous-view')) {
|
|
74
|
+
const v = currentStage.views[1].forwardState
|
|
75
|
+
element.classList.remove('zoom-previous-view', 'has-no-events')
|
|
76
|
+
element.style.transformOrigin = v.origin
|
|
77
|
+
element.style.transform = v.transform
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (element.classList.contains('is-last-view')) {
|
|
81
|
+
const v = currentStage.views[2].forwardState
|
|
82
|
+
element.classList.remove('zoom-last-view', 'has-no-events')
|
|
83
|
+
element.style.transformOrigin = v.origin
|
|
84
|
+
element.style.transform = v.transform
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply the final zoom-out state to the previous view (it becomes current).
|
|
90
|
+
*
|
|
91
|
+
* @param {HTMLElement} element
|
|
92
|
+
* @param {{ origin?: string, transform: string }} backwardState
|
|
93
|
+
*/
|
|
94
|
+
export function applyZoomOutPreviousState (element, backwardState) {
|
|
95
|
+
element.classList.remove('zoom-previous-view-reverse', 'has-no-events', 'has-effect', 'has-effect-reverse')
|
|
96
|
+
element.style.removeProperty('--z-effect-filter')
|
|
97
|
+
element.style.transformOrigin = '0 0'
|
|
98
|
+
element.style.transform = backwardState.transform
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply the final zoom-out state to the last view (it becomes previous).
|
|
103
|
+
*
|
|
104
|
+
* @param {HTMLElement} element
|
|
105
|
+
* @param {{ origin: string, transform: string }} backwardState
|
|
106
|
+
*/
|
|
107
|
+
export function applyZoomOutLastState (element, backwardState) {
|
|
108
|
+
element.classList.remove('zoom-last-view-reverse', 'has-no-events', 'has-effect', 'has-effect-reverse')
|
|
109
|
+
element.style.removeProperty('--z-effect-filter')
|
|
110
|
+
element.style.transformOrigin = backwardState.origin
|
|
111
|
+
element.style.transform = backwardState.transform
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── DOM removal ─────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Safely remove a view from the canvas. Handles wrapped elements (e.g. Svelte
|
|
118
|
+
* components that get an extra parent div).
|
|
119
|
+
*
|
|
120
|
+
* @param {HTMLElement} element - The view to remove
|
|
121
|
+
* @param {HTMLElement} canvas - The canvas container
|
|
122
|
+
*/
|
|
123
|
+
export function removeViewFromCanvas (element, canvas) {
|
|
124
|
+
try {
|
|
125
|
+
if (canvas) canvas.removeChild(element)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
try {
|
|
128
|
+
if (element.parentElement) canvas.removeChild(element.parentElement)
|
|
129
|
+
} catch (e2) {
|
|
130
|
+
// Element already removed or re-parented — safe to ignore.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Visibility helpers ──────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Make a view visible and interactive before animating it.
|
|
139
|
+
* Views start hidden (class `hide` for opacity, `contentVisibility: hidden` via view-visibility)
|
|
140
|
+
* when inserted by prepareAndInsertView. Drivers must call this before running any animation.
|
|
141
|
+
*
|
|
142
|
+
* @param {...HTMLElement} elements - One or more view elements (nulls are skipped)
|
|
143
|
+
*/
|
|
144
|
+
export function showViews (...elements) {
|
|
145
|
+
for (const el of elements) {
|
|
146
|
+
if (!el) continue
|
|
147
|
+
el.classList.remove('hide')
|
|
148
|
+
showViewContent(el)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Lateral (same-level) transition ─────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Instant lateral navigation: swap current view without animation.
|
|
156
|
+
* This is the shared implementation that drivers use as a baseline.
|
|
157
|
+
* Drivers that want animated lateral transitions can override this.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} spec - Full lateral spec from the engine
|
|
160
|
+
* @param {function} onComplete - Must be called when done
|
|
161
|
+
*/
|
|
162
|
+
export function runLateralInstant (spec, onComplete) {
|
|
163
|
+
const {
|
|
164
|
+
currentView: incomingView,
|
|
165
|
+
previousView: outgoingView,
|
|
166
|
+
backView,
|
|
167
|
+
backViewState,
|
|
168
|
+
lastView,
|
|
169
|
+
lastViewState,
|
|
170
|
+
incomingTransformEnd,
|
|
171
|
+
currentStage,
|
|
172
|
+
canvas
|
|
173
|
+
} = spec
|
|
174
|
+
const v0 = currentStage.views[0]
|
|
175
|
+
|
|
176
|
+
showViews(incomingView)
|
|
177
|
+
incomingView.classList.replace('is-new-current-view', 'is-current-view')
|
|
178
|
+
incomingView.classList.remove('zoom-current-view', 'has-no-events')
|
|
179
|
+
incomingView.style.transformOrigin = v0.forwardState.origin
|
|
180
|
+
incomingView.style.transform = incomingTransformEnd || v0.forwardState.transform
|
|
181
|
+
|
|
182
|
+
if (backView && backViewState) backView.style.transform = backViewState.transformEnd
|
|
183
|
+
if (lastView && lastViewState) lastView.style.transform = lastViewState.transformEnd
|
|
184
|
+
|
|
185
|
+
if (!spec.keepAlive) removeViewFromCanvas(outgoingView, canvas)
|
|
186
|
+
onComplete()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Safety timeout ──────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Default safety buffer (ms) beyond the parsed duration. Ensures onComplete
|
|
193
|
+
* fires even if animationend / Promise.all never settles (element removed,
|
|
194
|
+
* duration 0, browser quirk, etc.).
|
|
195
|
+
*/
|
|
196
|
+
export const SAFETY_BUFFER_MS = 150
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create a finish-once guard: returns a `finish()` function that only runs
|
|
200
|
+
* the first time it's called, and clears the safety timer.
|
|
201
|
+
*
|
|
202
|
+
* Use this to avoid the "completed" flag boilerplate in every driver.
|
|
203
|
+
*
|
|
204
|
+
* @param {function} cleanup - The actual cleanup + onComplete work
|
|
205
|
+
* @param {number} timeoutMs - Safety timeout duration
|
|
206
|
+
* @returns {{ finish: function, safetyTimer: number }}
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const { finish, safetyTimer } = createFinishGuard(() => {
|
|
210
|
+
* cancelAnimations()
|
|
211
|
+
* applyFinalState()
|
|
212
|
+
* onComplete()
|
|
213
|
+
* }, durationMs + SAFETY_BUFFER_MS)
|
|
214
|
+
*
|
|
215
|
+
* // In your animation's onComplete:
|
|
216
|
+
* animation.onfinish = finish
|
|
217
|
+
*
|
|
218
|
+
* // The safetyTimer ensures finish() runs even if onfinish never fires.
|
|
219
|
+
*/
|
|
220
|
+
export function createFinishGuard (cleanup, timeoutMs) {
|
|
221
|
+
let completed = false
|
|
222
|
+
const safetyTimer = setTimeout(() => {
|
|
223
|
+
if (!completed) { completed = true; cleanup() }
|
|
224
|
+
}, timeoutMs)
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
finish () {
|
|
228
|
+
if (completed) return
|
|
229
|
+
completed = true
|
|
230
|
+
clearTimeout(safetyTimer)
|
|
231
|
+
cleanup()
|
|
232
|
+
},
|
|
233
|
+
safetyTimer
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Matrix interpolation toolkit ────────────────────────────────────
|
|
238
|
+
// Used by matrix-based drivers (anime, motion, or any custom driver that
|
|
239
|
+
// needs to interpolate transforms through computed matrices).
|
|
240
|
+
|
|
241
|
+
/** Identity CSS matrix components. */
|
|
242
|
+
export function identityMatrix () {
|
|
243
|
+
return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Parse a CSS matrix() string into components.
|
|
248
|
+
* @param {string} mStr - e.g. "matrix(1, 0, 0, 1, 100, 50)"
|
|
249
|
+
* @returns {{ a, b, c, d, e, f }}
|
|
250
|
+
*/
|
|
251
|
+
export function parseMatrixString (mStr) {
|
|
252
|
+
if (!mStr || mStr === 'none') return identityMatrix()
|
|
253
|
+
const m = String(mStr).match(
|
|
254
|
+
/matrix\(([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+)\)/
|
|
255
|
+
)
|
|
256
|
+
if (!m) return identityMatrix()
|
|
257
|
+
return {
|
|
258
|
+
a: parseFloat(m[1]) || 1,
|
|
259
|
+
b: parseFloat(m[2]) || 0,
|
|
260
|
+
c: parseFloat(m[3]) || 0,
|
|
261
|
+
d: parseFloat(m[4]) || 1,
|
|
262
|
+
e: parseFloat(m[5]) || 0,
|
|
263
|
+
f: parseFloat(m[6]) || 0,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Convert matrix components back to a CSS matrix() string.
|
|
269
|
+
* @param {{ a, b, c, d, e, f }} m
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
export function matrixToString (m) {
|
|
273
|
+
return `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Linear interpolation between two numbers. */
|
|
277
|
+
export function lerp (a, b, t) {
|
|
278
|
+
return a + (b - a) * t
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Interpolate between two matrix objects.
|
|
283
|
+
* @param {{ a,b,c,d,e,f }} from
|
|
284
|
+
* @param {{ a,b,c,d,e,f }} to
|
|
285
|
+
* @param {number} t - Progress 0..1
|
|
286
|
+
* @returns {{ a,b,c,d,e,f }}
|
|
287
|
+
*/
|
|
288
|
+
export function interpolateMatrix (from, to, t) {
|
|
289
|
+
const tt = Math.max(0, Math.min(1, t))
|
|
290
|
+
return {
|
|
291
|
+
a: lerp(from.a, to.a, tt),
|
|
292
|
+
b: lerp(from.b, to.b, tt),
|
|
293
|
+
c: lerp(from.c, to.c, tt),
|
|
294
|
+
d: lerp(from.d, to.d, tt),
|
|
295
|
+
e: lerp(from.e, to.e, tt),
|
|
296
|
+
f: lerp(from.f, to.f, tt),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read the browser-computed matrix for a given transform + origin pair.
|
|
302
|
+
* ⚠️ Forces a reflow (getBoundingClientRect). Use sparingly — once per
|
|
303
|
+
* element per animation setup, not per frame.
|
|
304
|
+
*
|
|
305
|
+
* @param {HTMLElement} element
|
|
306
|
+
* @param {string} origin - CSS transform-origin
|
|
307
|
+
* @param {string} transformStr - CSS transform value
|
|
308
|
+
* @returns {{ a,b,c,d,e,f }}
|
|
309
|
+
*/
|
|
310
|
+
export function readComputedMatrix (element, origin, transformStr) {
|
|
311
|
+
element.style.transformOrigin = origin
|
|
312
|
+
element.style.transform = transformStr
|
|
313
|
+
try { element.getBoundingClientRect() } catch {}
|
|
314
|
+
return parseMatrixString(getComputedStyle(element).transform)
|
|
315
|
+
}
|