zumly 0.92.3 → 0.92.5
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 +10 -2
- package/dist/zumly.cjs +7 -0
- package/dist/zumly.css +2 -3
- package/dist/zumly.js +2 -2
- package/dist/zumly.min.css +1 -1
- package/dist/zumly.mjs +2 -2
- package/docs/DRIVER_API.md +1 -1
- package/package.json +10 -6
- package/src/drivers/anime-transition.js +185 -0
- package/src/drivers/css-transition.js +261 -0
- package/src/drivers/driver-helpers.d.ts +23 -0
- package/src/drivers/driver-helpers.js +11 -3
- package/src/drivers/gsap-transition.js +149 -0
- package/src/drivers/motion-transition.js +178 -0
- package/src/drivers/none-transition.js +54 -0
- package/src/drivers/waapi-transition.js +243 -0
- package/types/zumly.d.ts +12 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS-based transition driver (default).
|
|
3
|
+
* Uses CSS variables and keyframe classes; completes via animationend.
|
|
4
|
+
* Includes safety timeout so onComplete runs even if animationend is missed.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} spec - Transition spec from the engine
|
|
7
|
+
* @param {function} onComplete - MUST be called exactly once when done
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
parseDurationMs,
|
|
11
|
+
showViews,
|
|
12
|
+
applyZoomInEndState,
|
|
13
|
+
applyZoomOutPreviousState,
|
|
14
|
+
applyZoomOutLastState,
|
|
15
|
+
removeViewFromCanvas,
|
|
16
|
+
createFinishGuard,
|
|
17
|
+
SAFETY_BUFFER_MS,
|
|
18
|
+
} from './driver-helpers.js'
|
|
19
|
+
|
|
20
|
+
export function runTransition (spec, onComplete) {
|
|
21
|
+
const { type, currentView, previousView, lastView, currentStage, duration, ease, canvas } = spec
|
|
22
|
+
|
|
23
|
+
if (!currentView || !previousView || !currentStage) {
|
|
24
|
+
onComplete()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (type === 'lateral') {
|
|
29
|
+
runLateral(spec, onComplete)
|
|
30
|
+
} else if (type === 'zoomIn') {
|
|
31
|
+
runZoomIn(currentView, previousView, lastView, currentStage, duration, ease, onComplete)
|
|
32
|
+
} else if (type === 'zoomOut') {
|
|
33
|
+
runZoomOut(currentView, previousView, lastView, currentStage, duration, ease, canvas, onComplete)
|
|
34
|
+
} else {
|
|
35
|
+
onComplete()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Lateral ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function runLateral (spec, onComplete) {
|
|
42
|
+
const {
|
|
43
|
+
currentView: incomingView,
|
|
44
|
+
previousView: outgoingView,
|
|
45
|
+
backView,
|
|
46
|
+
backViewState,
|
|
47
|
+
lastView,
|
|
48
|
+
lastViewState,
|
|
49
|
+
incomingTransformStart,
|
|
50
|
+
incomingTransformEnd,
|
|
51
|
+
outgoingTransform,
|
|
52
|
+
outgoingTransformEnd,
|
|
53
|
+
currentStage,
|
|
54
|
+
duration,
|
|
55
|
+
ease,
|
|
56
|
+
canvas,
|
|
57
|
+
} = spec
|
|
58
|
+
const durationMs = parseDurationMs(duration)
|
|
59
|
+
const v0 = currentStage.views[0]
|
|
60
|
+
|
|
61
|
+
showViews(incomingView)
|
|
62
|
+
incomingView.classList.replace('is-new-current-view', 'is-current-view')
|
|
63
|
+
incomingView.classList.remove('zoom-current-view', 'has-no-events')
|
|
64
|
+
incomingView.style.transformOrigin = v0.forwardState.origin
|
|
65
|
+
|
|
66
|
+
// Set CSS variables and animation classes for each participating element
|
|
67
|
+
if (backView && backViewState) {
|
|
68
|
+
setCSSVars(backView, duration, ease, { '--lateral-from': backViewState.transformStart, '--lateral-to': backViewState.transformEnd })
|
|
69
|
+
backView.classList.add('zoom-lateral-back')
|
|
70
|
+
}
|
|
71
|
+
if (lastView && lastViewState) {
|
|
72
|
+
setCSSVars(lastView, duration, ease, { '--lateral-from': lastViewState.transformStart, '--lateral-to': lastViewState.transformEnd })
|
|
73
|
+
lastView.classList.add('zoom-lateral-back')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Skip outgoing animation in 'visible' keepAlive mode
|
|
77
|
+
if (spec.keepAlive !== 'visible') {
|
|
78
|
+
setCSSVars(outgoingView, duration, ease, { '--lateral-out-from': outgoingTransform, '--lateral-out-to': outgoingTransformEnd })
|
|
79
|
+
outgoingView.classList.add('zoom-lateral-out')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setCSSVars(incomingView, duration, ease, { '--lateral-in-from': incomingTransformStart, '--lateral-in-to': incomingTransformEnd })
|
|
83
|
+
incomingView.classList.add('zoom-lateral-in')
|
|
84
|
+
|
|
85
|
+
// Collect all animated elements
|
|
86
|
+
const elements = spec.keepAlive === 'visible' ? [incomingView] : [outgoingView, incomingView]
|
|
87
|
+
if (backView && backViewState) elements.push(backView)
|
|
88
|
+
if (lastView && lastViewState) elements.push(lastView)
|
|
89
|
+
|
|
90
|
+
let pending = elements.length
|
|
91
|
+
const { finish } = createFinishGuard(() => {
|
|
92
|
+
cleanupListeners(elements, handleEnd)
|
|
93
|
+
if (spec.keepAlive) {
|
|
94
|
+
outgoingView.classList.remove('zoom-lateral-out')
|
|
95
|
+
outgoingView.style.opacity = ''
|
|
96
|
+
} else {
|
|
97
|
+
removeViewFromCanvas(outgoingView, canvas)
|
|
98
|
+
}
|
|
99
|
+
if (backView) {
|
|
100
|
+
backView.classList.remove('zoom-lateral-back')
|
|
101
|
+
backView.style.transform = backViewState?.transformEnd || backView.style.transform
|
|
102
|
+
}
|
|
103
|
+
if (lastView) {
|
|
104
|
+
lastView.classList.remove('zoom-lateral-back')
|
|
105
|
+
lastView.style.transform = lastViewState?.transformEnd || lastView.style.transform
|
|
106
|
+
}
|
|
107
|
+
incomingView.classList.remove('zoom-lateral-in')
|
|
108
|
+
incomingView.style.transform = incomingTransformEnd
|
|
109
|
+
onComplete()
|
|
110
|
+
}, durationMs + SAFETY_BUFFER_MS)
|
|
111
|
+
|
|
112
|
+
function handleEnd (event) {
|
|
113
|
+
const el = event?.target
|
|
114
|
+
if (el) el.removeEventListener('animationend', handleEnd)
|
|
115
|
+
pending--
|
|
116
|
+
if (pending <= 0) finish()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
elements.forEach(el => el.addEventListener('animationend', handleEnd))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Zoom In ─────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function runZoomIn (currentView, previousView, lastView, currentStage, duration, ease, onComplete) {
|
|
125
|
+
showViews(currentView, previousView, lastView)
|
|
126
|
+
|
|
127
|
+
const stagger = currentStage.stagger || 0
|
|
128
|
+
|
|
129
|
+
// Set CSS variables for keyframe animations
|
|
130
|
+
setCSSVars(currentView, duration, ease, {
|
|
131
|
+
'--current-view-transform-start': currentStage.views[0].backwardState.transform,
|
|
132
|
+
'--current-view-transform-end': currentStage.views[0].forwardState.transform,
|
|
133
|
+
})
|
|
134
|
+
if (stagger > 0) currentView.style.setProperty('animation-delay', '0ms')
|
|
135
|
+
setCSSVars(previousView, duration, ease, {
|
|
136
|
+
'--previous-view-transform-start': currentStage.views[1].backwardState.transform,
|
|
137
|
+
'--previous-view-transform-end': currentStage.views[1].forwardState.transform,
|
|
138
|
+
})
|
|
139
|
+
if (stagger > 0) previousView.style.setProperty('animation-delay', `${stagger}ms`)
|
|
140
|
+
if (lastView) {
|
|
141
|
+
setCSSVars(lastView, duration, ease, {
|
|
142
|
+
'--last-view-transform-start': currentStage.views[2].backwardState.transform,
|
|
143
|
+
'--last-view-transform-end': currentStage.views[2].forwardState.transform,
|
|
144
|
+
})
|
|
145
|
+
if (stagger > 0) lastView.style.setProperty('animation-delay', `${stagger * 2}ms`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Trigger animations via classes
|
|
149
|
+
currentView.classList.add('zoom-current-view')
|
|
150
|
+
previousView.classList.add('zoom-previous-view')
|
|
151
|
+
if (lastView) lastView.classList.add('zoom-last-view')
|
|
152
|
+
|
|
153
|
+
const elements = lastView ? [currentView, previousView, lastView] : [currentView, previousView]
|
|
154
|
+
let pending = elements.length
|
|
155
|
+
const durationMs = parseDurationMs(duration)
|
|
156
|
+
const maxDelay = lastView ? stagger * 2 : stagger
|
|
157
|
+
|
|
158
|
+
const { finish } = createFinishGuard(() => {
|
|
159
|
+
cleanupListeners(elements, handleEnd)
|
|
160
|
+
// Clean up animation-delay
|
|
161
|
+
elements.forEach(el => el.style.removeProperty('animation-delay'))
|
|
162
|
+
onComplete()
|
|
163
|
+
}, durationMs + maxDelay + SAFETY_BUFFER_MS)
|
|
164
|
+
|
|
165
|
+
function handleEnd (event) {
|
|
166
|
+
const el = event?.target
|
|
167
|
+
if (el) el.removeEventListener('animationend', handleEnd)
|
|
168
|
+
if (el?.isConnected) {
|
|
169
|
+
try { applyZoomInEndState(el, currentStage) } catch (e) { /* ignore */ }
|
|
170
|
+
}
|
|
171
|
+
pending--
|
|
172
|
+
if (pending <= 0) finish()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
elements.forEach(el => el.addEventListener('animationend', handleEnd))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Zoom Out ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function runZoomOut (currentView, previousView, lastView, currentStage, duration, ease, canvas, onComplete) {
|
|
181
|
+
const v0 = currentStage.views[0]
|
|
182
|
+
const v1 = currentStage.views[1]
|
|
183
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
184
|
+
const stagger = currentStage.stagger || 0
|
|
185
|
+
|
|
186
|
+
setCSSVars(currentView, duration, ease, {
|
|
187
|
+
'--current-view-transform-start': v0.backwardState.transform,
|
|
188
|
+
'--current-view-transform-end': v0.forwardState.transform,
|
|
189
|
+
})
|
|
190
|
+
if (stagger > 0) currentView.style.setProperty('animation-delay', '0ms')
|
|
191
|
+
setCSSVars(previousView, duration, ease, {
|
|
192
|
+
'--previous-view-transform-start': v1.backwardState.transform,
|
|
193
|
+
'--previous-view-transform-end': v1.forwardState.transform,
|
|
194
|
+
})
|
|
195
|
+
if (stagger > 0) previousView.style.setProperty('animation-delay', `${stagger}ms`)
|
|
196
|
+
if (lastView && v2) {
|
|
197
|
+
setCSSVars(lastView, duration, ease, {
|
|
198
|
+
'--last-view-transform-start': v2.backwardState.transform,
|
|
199
|
+
'--last-view-transform-end': v2.forwardState.transform,
|
|
200
|
+
})
|
|
201
|
+
if (stagger > 0) lastView.style.setProperty('animation-delay', `${stagger * 2}ms`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Trigger reverse animations
|
|
205
|
+
currentView.classList.add('zoom-current-view-reverse')
|
|
206
|
+
previousView.classList.add('zoom-previous-view-reverse')
|
|
207
|
+
if (lastView) lastView.classList.add('zoom-last-view-reverse')
|
|
208
|
+
|
|
209
|
+
const elements = lastView ? [currentView, previousView, lastView] : [currentView, previousView]
|
|
210
|
+
let pending = elements.length
|
|
211
|
+
const durationMs = parseDurationMs(duration)
|
|
212
|
+
const maxDelay = lastView ? stagger * 2 : stagger
|
|
213
|
+
|
|
214
|
+
const { finish } = createFinishGuard(() => {
|
|
215
|
+
cleanupListeners(elements, handleEnd)
|
|
216
|
+
elements.forEach(el => el.style.removeProperty('animation-delay'))
|
|
217
|
+
onComplete()
|
|
218
|
+
}, durationMs + maxDelay + SAFETY_BUFFER_MS)
|
|
219
|
+
|
|
220
|
+
function handleEnd (event) {
|
|
221
|
+
const el = event?.target
|
|
222
|
+
if (el) el.removeEventListener('animationend', handleEnd)
|
|
223
|
+
if (el?.isConnected) {
|
|
224
|
+
try { applyZoomOutEndState(el, currentStage, canvas) } catch (e) { /* ignore */ }
|
|
225
|
+
}
|
|
226
|
+
pending--
|
|
227
|
+
if (pending <= 0) finish()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
elements.forEach(el => el.addEventListener('animationend', handleEnd))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyZoomOutEndState (element, currentStage, canvas) {
|
|
234
|
+
if (element.classList.contains('zoom-current-view-reverse')) {
|
|
235
|
+
removeViewFromCanvas(element, canvas)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (element.classList.contains('zoom-previous-view-reverse')) {
|
|
239
|
+
applyZoomOutPreviousState(element, currentStage.views[1].backwardState)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (element.classList.contains('zoom-last-view-reverse')) {
|
|
243
|
+
applyZoomOutLastState(element, currentStage.views[2].backwardState)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Internal helpers ────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function setCSSVars (el, duration, ease, vars) {
|
|
250
|
+
el.style.setProperty('--zoom-duration', duration)
|
|
251
|
+
el.style.setProperty('--zoom-ease', ease)
|
|
252
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
253
|
+
el.style.setProperty(key, value)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function cleanupListeners (elements, handler) {
|
|
258
|
+
for (const el of elements) {
|
|
259
|
+
if (el?.removeEventListener) el.removeEventListener('animationend', handler)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Type definitions for zumly/driver-helpers
|
|
2
|
+
|
|
3
|
+
import type { ZoomSnapshot, ViewState, TransitionSpec, MatrixComponents } from '../../types/zumly.js'
|
|
4
|
+
|
|
5
|
+
export function parseDurationMs(duration: string | number): number
|
|
6
|
+
export function parseDurationSec(duration: string | number): number
|
|
7
|
+
export function applyZoomInEndState(element: HTMLElement, currentStage: ZoomSnapshot): void
|
|
8
|
+
export function applyZoomOutPreviousState(element: HTMLElement, backwardState: ViewState): void
|
|
9
|
+
export function applyZoomOutLastState(element: HTMLElement, backwardState: ViewState): void
|
|
10
|
+
export function removeViewFromCanvas(element: HTMLElement, canvas: HTMLElement): void
|
|
11
|
+
export function showViews(...elements: (HTMLElement | null | undefined)[]): void
|
|
12
|
+
export function runLateralInstant(spec: TransitionSpec, onComplete: () => void): void
|
|
13
|
+
export const SAFETY_BUFFER_MS: number
|
|
14
|
+
export function createFinishGuard(
|
|
15
|
+
cleanup: () => void,
|
|
16
|
+
timeoutMs: number
|
|
17
|
+
): { finish: () => void; safetyTimer: number }
|
|
18
|
+
export function identityMatrix(): MatrixComponents
|
|
19
|
+
export function parseMatrixString(mStr: string): MatrixComponents
|
|
20
|
+
export function matrixToString(m: MatrixComponents): string
|
|
21
|
+
export function lerp(a: number, b: number, t: number): number
|
|
22
|
+
export function interpolateMatrix(from: MatrixComponents, to: MatrixComponents, t: number): MatrixComponents
|
|
23
|
+
export function readComputedMatrix(element: HTMLElement, origin: string, transformStr: string): MatrixComponents
|
|
@@ -23,15 +23,23 @@ import { showViewContent } from '../view-visibility.js'
|
|
|
23
23
|
*
|
|
24
24
|
* @example
|
|
25
25
|
* parseDurationMs('1s') // → 1000
|
|
26
|
+
* parseDurationMs('.7s') // → 700
|
|
26
27
|
* parseDurationMs('200ms') // → 200
|
|
27
28
|
* parseDurationMs(300) // → 300
|
|
28
29
|
* parseDurationMs('nope') // → 500
|
|
29
30
|
*/
|
|
30
31
|
export function parseDurationMs (duration) {
|
|
31
32
|
if (typeof duration === 'number' && !Number.isNaN(duration)) return Math.max(0, duration)
|
|
32
|
-
const str = String(duration)
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const str = String(duration).trim()
|
|
34
|
+
// \d*\.?\d+ also accepts a leading dot ('.7s'), which CSS allows —
|
|
35
|
+
// previously that fell silently into the 500ms fallback.
|
|
36
|
+
const m = str.match(/^(\d*\.?\d+)\s*(ms|s)?$/i)
|
|
37
|
+
if (!m) {
|
|
38
|
+
if (typeof console !== 'undefined') {
|
|
39
|
+
console.warn(`[zumly] Could not parse duration "${str}" — falling back to 500ms.`) // eslint-disable-line no-console
|
|
40
|
+
}
|
|
41
|
+
return 500
|
|
42
|
+
}
|
|
35
43
|
const val = parseFloat(m[1])
|
|
36
44
|
const unit = (m[2] || 's').toLowerCase()
|
|
37
45
|
return unit === 'ms' ? Math.max(0, val) : Math.max(0, val * 1000)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSAP transition driver.
|
|
3
|
+
* Requires global `gsap` — load from CDN before use:
|
|
4
|
+
* <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} spec - Transition spec from the engine
|
|
7
|
+
* @param {function} onComplete - MUST be called exactly once when done
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
parseDurationSec,
|
|
11
|
+
showViews,
|
|
12
|
+
applyZoomInEndState,
|
|
13
|
+
applyZoomOutPreviousState,
|
|
14
|
+
applyZoomOutLastState,
|
|
15
|
+
removeViewFromCanvas,
|
|
16
|
+
runLateralInstant,
|
|
17
|
+
} from './driver-helpers.js'
|
|
18
|
+
|
|
19
|
+
export function runTransition (spec, onComplete) {
|
|
20
|
+
const gsap = typeof globalThis !== 'undefined' && globalThis.gsap
|
|
21
|
+
if (!gsap || typeof gsap.to !== 'function') {
|
|
22
|
+
console.warn('Zumly GSAP driver: GSAP not loaded. Add <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>')
|
|
23
|
+
onComplete()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { type, currentView, previousView, lastView, currentStage, duration, ease, canvas } = spec
|
|
28
|
+
if (!currentView || !previousView || !currentStage) {
|
|
29
|
+
onComplete()
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const durationSec = parseDurationSec(duration)
|
|
34
|
+
|
|
35
|
+
if (type === 'lateral') {
|
|
36
|
+
runLateralInstant(spec, onComplete)
|
|
37
|
+
} else if (type === 'zoomIn') {
|
|
38
|
+
runZoomIn(gsap, currentView, previousView, lastView, currentStage, durationSec, ease, onComplete)
|
|
39
|
+
} else if (type === 'zoomOut') {
|
|
40
|
+
runZoomOut(gsap, currentView, previousView, lastView, currentStage, durationSec, ease, canvas, onComplete)
|
|
41
|
+
} else {
|
|
42
|
+
onComplete()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Zoom In ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function runZoomIn (gsap, currentView, previousView, lastView, currentStage, durationSec, ease, onComplete) {
|
|
49
|
+
showViews(currentView, previousView, lastView)
|
|
50
|
+
|
|
51
|
+
const v0 = currentStage.views[0]
|
|
52
|
+
const v1 = currentStage.views[1]
|
|
53
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
54
|
+
|
|
55
|
+
// Set initial transforms
|
|
56
|
+
setTransform(currentView, v0.backwardState)
|
|
57
|
+
setTransform(previousView, v1.backwardState)
|
|
58
|
+
if (v2) setTransform(lastView, v2.backwardState)
|
|
59
|
+
|
|
60
|
+
const tl = gsap.timeline({
|
|
61
|
+
onComplete: () => {
|
|
62
|
+
applyZoomInEndState(currentView, currentStage)
|
|
63
|
+
applyZoomInEndState(previousView, currentStage)
|
|
64
|
+
if (lastView) applyZoomInEndState(lastView, currentStage)
|
|
65
|
+
onComplete()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const gsapEase = normalizeEasing(ease)
|
|
70
|
+
const staggerSec = (currentStage.stagger || 0) / 1000
|
|
71
|
+
tl.to(currentView, { transform: v0.forwardState.transform, duration: durationSec, ease: gsapEase }, 0)
|
|
72
|
+
tl.to(previousView, { transform: v1.forwardState.transform, duration: durationSec, ease: gsapEase }, staggerSec)
|
|
73
|
+
if (v2) {
|
|
74
|
+
tl.to(lastView, { transform: v2.forwardState.transform, duration: durationSec, ease: gsapEase }, staggerSec * 2)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Zoom Out ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function runZoomOut (gsap, currentView, previousView, lastView, currentStage, durationSec, ease, canvas, onComplete) {
|
|
81
|
+
const v0 = currentStage.views[0]
|
|
82
|
+
const v1 = currentStage.views[1]
|
|
83
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
84
|
+
const to1 = v1.backwardState
|
|
85
|
+
const to2 = v2 ? v2.backwardState : null
|
|
86
|
+
|
|
87
|
+
setTransform(currentView, v0.forwardState)
|
|
88
|
+
|
|
89
|
+
const from1 = previousView.style.transform || getComputedStyle(previousView).transform || v1.forwardState.transform
|
|
90
|
+
const from2 = lastView && v2
|
|
91
|
+
? (lastView.style.transform || getComputedStyle(lastView).transform || v2.forwardState.transform)
|
|
92
|
+
: null
|
|
93
|
+
|
|
94
|
+
previousView.style.transformOrigin = v1.forwardState.origin
|
|
95
|
+
if (lastView && v2) lastView.style.transformOrigin = v2.forwardState.origin
|
|
96
|
+
|
|
97
|
+
const gsapEase = normalizeEasing(ease)
|
|
98
|
+
|
|
99
|
+
const tl = gsap.timeline({
|
|
100
|
+
onComplete: () => {
|
|
101
|
+
removeViewFromCanvas(currentView, canvas)
|
|
102
|
+
applyZoomOutPreviousState(previousView, to1)
|
|
103
|
+
if (lastView && to2) applyZoomOutLastState(lastView, to2)
|
|
104
|
+
onComplete()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const staggerSec = (currentStage.stagger || 0) / 1000
|
|
109
|
+
|
|
110
|
+
// Set initial transforms so views hold position during stagger delay
|
|
111
|
+
gsap.set(previousView, { transform: from1 })
|
|
112
|
+
if (lastView && from2) gsap.set(lastView, { transform: from2 })
|
|
113
|
+
|
|
114
|
+
tl.fromTo(currentView,
|
|
115
|
+
{ transform: v0.forwardState.transform },
|
|
116
|
+
{ transform: v0.backwardState.transform, duration: durationSec, ease: gsapEase },
|
|
117
|
+
0
|
|
118
|
+
)
|
|
119
|
+
tl.fromTo(previousView,
|
|
120
|
+
{ transform: from1 },
|
|
121
|
+
{ transform: to1.transform, duration: durationSec, ease: gsapEase },
|
|
122
|
+
staggerSec
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if (lastView && to2) {
|
|
126
|
+
tl.fromTo(lastView,
|
|
127
|
+
{ transform: from2 },
|
|
128
|
+
{ transform: to2.transform, duration: durationSec, ease: gsapEase },
|
|
129
|
+
staggerSec * 2
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function setTransform (el, state) {
|
|
137
|
+
el.style.transformOrigin = state.origin
|
|
138
|
+
el.style.transform = state.transform
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeEasing (ease) {
|
|
142
|
+
if (typeof ease !== 'string') return 'power2.inOut'
|
|
143
|
+
const s = ease.toLowerCase()
|
|
144
|
+
if (s === 'linear') return 'none'
|
|
145
|
+
if (s.includes('ease-in-out')) return 'power2.inOut'
|
|
146
|
+
if (s.includes('ease-in')) return 'power2.in'
|
|
147
|
+
if (s.includes('ease-out')) return 'power2.out'
|
|
148
|
+
return s
|
|
149
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion (motion.dev) transition driver.
|
|
3
|
+
* Interpolates computed transform matrices during animation.
|
|
4
|
+
* Load Motion before use:
|
|
5
|
+
* <script src="https://cdn.jsdelivr.net/npm/motion@11/dist/motion.min.js"></script>
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} spec - Transition spec from the engine
|
|
8
|
+
* @param {function} onComplete - MUST be called exactly once when done
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
parseDurationSec,
|
|
12
|
+
showViews,
|
|
13
|
+
applyZoomInEndState,
|
|
14
|
+
applyZoomOutPreviousState,
|
|
15
|
+
applyZoomOutLastState,
|
|
16
|
+
removeViewFromCanvas,
|
|
17
|
+
runLateralInstant,
|
|
18
|
+
readComputedMatrix,
|
|
19
|
+
interpolateMatrix,
|
|
20
|
+
matrixToString,
|
|
21
|
+
} from './driver-helpers.js'
|
|
22
|
+
|
|
23
|
+
export function runTransition (spec, onComplete) {
|
|
24
|
+
const animate = getMotionAnimate()
|
|
25
|
+
if (!animate || typeof animate !== 'function') {
|
|
26
|
+
console.warn('Zumly Motion driver: Motion not loaded. Add <script src="https://cdn.jsdelivr.net/npm/motion@11/dist/motion.min.js"></script>')
|
|
27
|
+
onComplete()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { type, currentView, previousView, lastView, currentStage, duration, ease, canvas } = spec
|
|
32
|
+
if (!currentView || !previousView || !currentStage) {
|
|
33
|
+
onComplete()
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const durationSec = parseDurationSec(duration)
|
|
38
|
+
|
|
39
|
+
if (type === 'lateral') {
|
|
40
|
+
runLateralInstant(spec, onComplete)
|
|
41
|
+
} else if (type === 'zoomIn') {
|
|
42
|
+
runZoomIn(animate, currentView, previousView, lastView, currentStage, durationSec, ease, onComplete)
|
|
43
|
+
} else if (type === 'zoomOut') {
|
|
44
|
+
runZoomOut(animate, currentView, previousView, lastView, currentStage, durationSec, ease, canvas, onComplete)
|
|
45
|
+
} else {
|
|
46
|
+
onComplete()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Zoom In ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function runZoomIn (animate, currentView, previousView, lastView, currentStage, durationSec, ease, onComplete) {
|
|
53
|
+
showViews(currentView, previousView, lastView)
|
|
54
|
+
|
|
55
|
+
const matrices = computeMatrixPairs(
|
|
56
|
+
currentView, previousView, lastView, currentStage, 'forward'
|
|
57
|
+
)
|
|
58
|
+
applyMatricesAtProgress(matrices, 0)
|
|
59
|
+
|
|
60
|
+
const stagger = currentStage.stagger || 0
|
|
61
|
+
const staggerSec = stagger / 1000
|
|
62
|
+
const durationMs = durationSec * 1000
|
|
63
|
+
const totalSec = durationSec + (matrices.length > 2 ? staggerSec * 2 : staggerSec)
|
|
64
|
+
|
|
65
|
+
const controls = animate(0, 1, {
|
|
66
|
+
duration: totalSec,
|
|
67
|
+
ease: normalizeEasing(ease),
|
|
68
|
+
onUpdate: t => {
|
|
69
|
+
const elapsed = t * totalSec * 1000
|
|
70
|
+
applyStaggeredMatrices(matrices, elapsed, durationMs, stagger)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
controls.then(() => {
|
|
75
|
+
applyZoomInEndState(currentView, currentStage)
|
|
76
|
+
applyZoomInEndState(previousView, currentStage)
|
|
77
|
+
if (lastView) applyZoomInEndState(lastView, currentStage)
|
|
78
|
+
onComplete()
|
|
79
|
+
}).catch(() => onComplete())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Zoom Out ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function runZoomOut (animate, currentView, previousView, lastView, currentStage, durationSec, ease, canvas, onComplete) {
|
|
85
|
+
const v1 = currentStage.views[1]
|
|
86
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
87
|
+
const to1 = v1.backwardState
|
|
88
|
+
const to2 = v2 ? v2.backwardState : null
|
|
89
|
+
|
|
90
|
+
const matrices = computeMatrixPairs(
|
|
91
|
+
currentView, previousView, lastView, currentStage, 'backward'
|
|
92
|
+
)
|
|
93
|
+
applyMatricesAtProgress(matrices, 0)
|
|
94
|
+
|
|
95
|
+
const stagger = currentStage.stagger || 0
|
|
96
|
+
const staggerSec = stagger / 1000
|
|
97
|
+
const durationMs = durationSec * 1000
|
|
98
|
+
const totalSec = durationSec + (matrices.length > 2 ? staggerSec * 2 : staggerSec)
|
|
99
|
+
|
|
100
|
+
const controls = animate(0, 1, {
|
|
101
|
+
duration: totalSec,
|
|
102
|
+
ease: normalizeEasing(ease),
|
|
103
|
+
onUpdate: t => {
|
|
104
|
+
const elapsed = t * totalSec * 1000
|
|
105
|
+
applyStaggeredMatrices(matrices, elapsed, durationMs, stagger)
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
controls.then(() => {
|
|
110
|
+
removeViewFromCanvas(currentView, canvas)
|
|
111
|
+
applyZoomOutPreviousState(previousView, to1)
|
|
112
|
+
if (lastView && to2) applyZoomOutLastState(lastView, to2)
|
|
113
|
+
onComplete()
|
|
114
|
+
}).catch(() => onComplete())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Matrix computation ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function computeMatrixPairs (currentView, previousView, lastView, currentStage, direction) {
|
|
120
|
+
const v0 = currentStage.views[0]
|
|
121
|
+
const v1 = currentStage.views[1]
|
|
122
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
123
|
+
|
|
124
|
+
const entries = [
|
|
125
|
+
{ el: currentView, backward: v0.backwardState, forward: v0.forwardState },
|
|
126
|
+
{ el: previousView, backward: v1.backwardState, forward: v1.forwardState },
|
|
127
|
+
]
|
|
128
|
+
if (v2) entries.push({ el: lastView, backward: v2.backwardState, forward: v2.forwardState })
|
|
129
|
+
|
|
130
|
+
return entries.map(({ el, backward, forward }) => {
|
|
131
|
+
if (direction === 'forward') {
|
|
132
|
+
return {
|
|
133
|
+
el,
|
|
134
|
+
from: readComputedMatrix(el, backward.origin, backward.transform),
|
|
135
|
+
to: readComputedMatrix(el, backward.origin, forward.transform),
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
return {
|
|
139
|
+
el,
|
|
140
|
+
from: readComputedMatrix(el, forward.origin, forward.transform),
|
|
141
|
+
to: readComputedMatrix(el, forward.origin, backward.transform),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyMatricesAtProgress (matrices, t) {
|
|
148
|
+
for (const { el, from, to } of matrices) {
|
|
149
|
+
el.style.transform = matrixToString(interpolateMatrix(from, to, t))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyStaggeredMatrices (matrices, elapsed, durationMs, stagger) {
|
|
154
|
+
for (let i = 0; i < matrices.length; i++) {
|
|
155
|
+
const { el, from, to } = matrices[i]
|
|
156
|
+
const delay = i * stagger
|
|
157
|
+
const localElapsed = Math.max(0, elapsed - delay)
|
|
158
|
+
const t = durationMs > 0 ? Math.min(1, localElapsed / durationMs) : 1
|
|
159
|
+
el.style.transform = matrixToString(interpolateMatrix(from, to, t))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function getMotionAnimate () {
|
|
166
|
+
const g = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : {}
|
|
167
|
+
return g.motion?.animate || g.Motion?.animate || g.animate
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeEasing (ease) {
|
|
171
|
+
if (typeof ease !== 'string') return 'easeInOut'
|
|
172
|
+
const s = ease.toLowerCase()
|
|
173
|
+
if (s === 'linear') return 'linear'
|
|
174
|
+
if (s.includes('ease-in-out')) return 'easeInOut'
|
|
175
|
+
if (s.includes('ease-in')) return 'easeIn'
|
|
176
|
+
if (s.includes('ease-out')) return 'easeOut'
|
|
177
|
+
return ease
|
|
178
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-animation transition driver.
|
|
3
|
+
* Applies final state immediately and calls onComplete synchronously.
|
|
4
|
+
* Useful for tests, instant UX, or reduced-motion preference.
|
|
5
|
+
*
|
|
6
|
+
* This is the simplest possible Zumly driver — a good starting point
|
|
7
|
+
* for writing your own. See docs/DRIVER_API.md for the full guide.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} spec - Transition spec from the engine
|
|
10
|
+
* @param {function} onComplete - MUST be called exactly once when done
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
showViews,
|
|
14
|
+
applyZoomInEndState,
|
|
15
|
+
applyZoomOutPreviousState,
|
|
16
|
+
applyZoomOutLastState,
|
|
17
|
+
removeViewFromCanvas,
|
|
18
|
+
runLateralInstant,
|
|
19
|
+
} from './driver-helpers.js'
|
|
20
|
+
|
|
21
|
+
export function runTransition (spec, onComplete) {
|
|
22
|
+
const { type, currentView, previousView, lastView, currentStage, canvas } = spec
|
|
23
|
+
|
|
24
|
+
if (!currentView || !previousView || !currentStage) {
|
|
25
|
+
onComplete()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (type === 'lateral') {
|
|
30
|
+
runLateralInstant(spec, onComplete)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (type === 'zoomIn') {
|
|
35
|
+
showViews(currentView, previousView, lastView)
|
|
36
|
+
applyZoomInEndState(currentView, currentStage)
|
|
37
|
+
applyZoomInEndState(previousView, currentStage)
|
|
38
|
+
if (lastView) applyZoomInEndState(lastView, currentStage)
|
|
39
|
+
onComplete()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (type === 'zoomOut') {
|
|
44
|
+
removeViewFromCanvas(currentView, canvas)
|
|
45
|
+
showViews(previousView, lastView)
|
|
46
|
+
applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
|
|
47
|
+
if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
|
|
48
|
+
onComplete()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Unknown type — still must call onComplete
|
|
53
|
+
onComplete()
|
|
54
|
+
}
|