zumly 0.92.4 → 0.92.6
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 +7 -1
- package/dist/zumly.cjs +7 -0
- package/dist/zumly.css +1 -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 +320 -0
- package/src/drivers/driver-helpers.d.ts +23 -0
- package/src/drivers/driver-helpers.js +13 -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 +4 -4
|
@@ -0,0 +1,320 @@
|
|
|
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 budgetMs = durationMs + SAFETY_BUFFER_MS
|
|
92
|
+
const zoomAnims = new Set(['zoom-lateral-in', 'zoom-lateral-out', 'zoom-lateral-back'])
|
|
93
|
+
const { finish, extend } = createFinishGuard(() => {
|
|
94
|
+
cleanupAnimListeners(elements, handleEnd, handleStart)
|
|
95
|
+
if (spec.keepAlive) {
|
|
96
|
+
outgoingView.classList.remove('zoom-lateral-out')
|
|
97
|
+
outgoingView.style.opacity = ''
|
|
98
|
+
} else {
|
|
99
|
+
removeViewFromCanvas(outgoingView, canvas)
|
|
100
|
+
}
|
|
101
|
+
if (backView) {
|
|
102
|
+
backView.classList.remove('zoom-lateral-back')
|
|
103
|
+
backView.style.transform = backViewState?.transformEnd || backView.style.transform
|
|
104
|
+
}
|
|
105
|
+
if (lastView) {
|
|
106
|
+
lastView.classList.remove('zoom-lateral-back')
|
|
107
|
+
lastView.style.transform = lastViewState?.transformEnd || lastView.style.transform
|
|
108
|
+
}
|
|
109
|
+
incomingView.classList.remove('zoom-lateral-in')
|
|
110
|
+
incomingView.style.transform = incomingTransformEnd
|
|
111
|
+
onComplete()
|
|
112
|
+
}, budgetMs)
|
|
113
|
+
|
|
114
|
+
// See runZoomIn: re-arm the deadline from the real animation start.
|
|
115
|
+
function handleStart (event) {
|
|
116
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
117
|
+
extend(budgetMs)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleEnd (event) {
|
|
121
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
122
|
+
const el = event.currentTarget
|
|
123
|
+
el.removeEventListener('animationend', handleEnd)
|
|
124
|
+
el.removeEventListener('animationstart', handleStart)
|
|
125
|
+
pending--
|
|
126
|
+
if (pending <= 0) finish()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
elements.forEach(el => {
|
|
130
|
+
el.addEventListener('animationstart', handleStart)
|
|
131
|
+
el.addEventListener('animationend', handleEnd)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Zoom In ─────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function runZoomIn (currentView, previousView, lastView, currentStage, duration, ease, onComplete) {
|
|
138
|
+
showViews(currentView, previousView, lastView)
|
|
139
|
+
|
|
140
|
+
const stagger = currentStage.stagger || 0
|
|
141
|
+
|
|
142
|
+
// Set CSS variables for keyframe animations
|
|
143
|
+
setCSSVars(currentView, duration, ease, {
|
|
144
|
+
'--current-view-transform-start': currentStage.views[0].backwardState.transform,
|
|
145
|
+
'--current-view-transform-end': currentStage.views[0].forwardState.transform,
|
|
146
|
+
})
|
|
147
|
+
if (stagger > 0) currentView.style.setProperty('animation-delay', '0ms')
|
|
148
|
+
setCSSVars(previousView, duration, ease, {
|
|
149
|
+
'--previous-view-transform-start': currentStage.views[1].backwardState.transform,
|
|
150
|
+
'--previous-view-transform-end': currentStage.views[1].forwardState.transform,
|
|
151
|
+
})
|
|
152
|
+
if (stagger > 0) previousView.style.setProperty('animation-delay', `${stagger}ms`)
|
|
153
|
+
if (lastView) {
|
|
154
|
+
setCSSVars(lastView, duration, ease, {
|
|
155
|
+
'--last-view-transform-start': currentStage.views[2].backwardState.transform,
|
|
156
|
+
'--last-view-transform-end': currentStage.views[2].forwardState.transform,
|
|
157
|
+
})
|
|
158
|
+
if (stagger > 0) lastView.style.setProperty('animation-delay', `${stagger * 2}ms`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Trigger animations via classes
|
|
162
|
+
currentView.classList.add('zoom-current-view')
|
|
163
|
+
previousView.classList.add('zoom-previous-view')
|
|
164
|
+
if (lastView) lastView.classList.add('zoom-last-view')
|
|
165
|
+
|
|
166
|
+
const elements = lastView ? [currentView, previousView, lastView] : [currentView, previousView]
|
|
167
|
+
let pending = elements.length
|
|
168
|
+
const durationMs = parseDurationMs(duration)
|
|
169
|
+
const maxDelay = lastView ? stagger * 2 : stagger
|
|
170
|
+
const budgetMs = durationMs + maxDelay + SAFETY_BUFFER_MS
|
|
171
|
+
const zoomAnims = new Set(['zoom-current-view', 'zoom-previous-view', 'zoom-last-view'])
|
|
172
|
+
|
|
173
|
+
const { finish, extend } = createFinishGuard(() => {
|
|
174
|
+
cleanupAnimListeners(elements, handleEnd, handleStart)
|
|
175
|
+
elements.forEach(el => el.style.removeProperty('animation-delay'))
|
|
176
|
+
// Safety path (missed animationend: render lag, hidden tab, removed
|
|
177
|
+
// element): apply the end state ourselves — never leave views stuck
|
|
178
|
+
// with zoom classes and has-no-events.
|
|
179
|
+
elements.forEach(el => {
|
|
180
|
+
if (el?.isConnected) {
|
|
181
|
+
try { applyZoomInEndState(el, currentStage) } catch (e) { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
onComplete()
|
|
185
|
+
}, budgetMs)
|
|
186
|
+
|
|
187
|
+
// Animations begin on the next render frame, which can lag far behind this
|
|
188
|
+
// JS call when the incoming view is heavy (iframes, large subtrees). Re-arm
|
|
189
|
+
// the safety deadline from the real start so it can't fire mid-animation.
|
|
190
|
+
function handleStart (event) {
|
|
191
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
192
|
+
extend(budgetMs)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleEnd (event) {
|
|
196
|
+
// Animation events bubble: ignore animations from view content, and only
|
|
197
|
+
// accept this element's own zoom keyframes.
|
|
198
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
199
|
+
const el = event.currentTarget
|
|
200
|
+
el.removeEventListener('animationend', handleEnd)
|
|
201
|
+
el.removeEventListener('animationstart', handleStart)
|
|
202
|
+
if (el.isConnected) {
|
|
203
|
+
try { applyZoomInEndState(el, currentStage) } catch (e) { /* ignore */ }
|
|
204
|
+
}
|
|
205
|
+
pending--
|
|
206
|
+
if (pending <= 0) finish()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
elements.forEach(el => {
|
|
210
|
+
el.addEventListener('animationstart', handleStart)
|
|
211
|
+
el.addEventListener('animationend', handleEnd)
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Zoom Out ────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function runZoomOut (currentView, previousView, lastView, currentStage, duration, ease, canvas, onComplete) {
|
|
218
|
+
const v0 = currentStage.views[0]
|
|
219
|
+
const v1 = currentStage.views[1]
|
|
220
|
+
const v2 = lastView && currentStage.views[2] ? currentStage.views[2] : null
|
|
221
|
+
const stagger = currentStage.stagger || 0
|
|
222
|
+
|
|
223
|
+
setCSSVars(currentView, duration, ease, {
|
|
224
|
+
'--current-view-transform-start': v0.backwardState.transform,
|
|
225
|
+
'--current-view-transform-end': v0.forwardState.transform,
|
|
226
|
+
})
|
|
227
|
+
if (stagger > 0) currentView.style.setProperty('animation-delay', '0ms')
|
|
228
|
+
setCSSVars(previousView, duration, ease, {
|
|
229
|
+
'--previous-view-transform-start': v1.backwardState.transform,
|
|
230
|
+
'--previous-view-transform-end': v1.forwardState.transform,
|
|
231
|
+
})
|
|
232
|
+
if (stagger > 0) previousView.style.setProperty('animation-delay', `${stagger}ms`)
|
|
233
|
+
if (lastView && v2) {
|
|
234
|
+
setCSSVars(lastView, duration, ease, {
|
|
235
|
+
'--last-view-transform-start': v2.backwardState.transform,
|
|
236
|
+
'--last-view-transform-end': v2.forwardState.transform,
|
|
237
|
+
})
|
|
238
|
+
if (stagger > 0) lastView.style.setProperty('animation-delay', `${stagger * 2}ms`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Trigger reverse animations
|
|
242
|
+
currentView.classList.add('zoom-current-view-reverse')
|
|
243
|
+
previousView.classList.add('zoom-previous-view-reverse')
|
|
244
|
+
if (lastView) lastView.classList.add('zoom-last-view-reverse')
|
|
245
|
+
|
|
246
|
+
const elements = lastView ? [currentView, previousView, lastView] : [currentView, previousView]
|
|
247
|
+
let pending = elements.length
|
|
248
|
+
const durationMs = parseDurationMs(duration)
|
|
249
|
+
const maxDelay = lastView ? stagger * 2 : stagger
|
|
250
|
+
const budgetMs = durationMs + maxDelay + SAFETY_BUFFER_MS
|
|
251
|
+
const zoomAnims = new Set(['zoom-current-view-reverse', 'zoom-previous-view-reverse', 'zoom-last-view-reverse'])
|
|
252
|
+
|
|
253
|
+
const { finish, extend } = createFinishGuard(() => {
|
|
254
|
+
cleanupAnimListeners(elements, handleEnd, handleStart)
|
|
255
|
+
elements.forEach(el => el.style.removeProperty('animation-delay'))
|
|
256
|
+
// Safety path: apply end states so the outgoing view is removed and the
|
|
257
|
+
// remaining views are restored even when animationend never arrived.
|
|
258
|
+
elements.forEach(el => {
|
|
259
|
+
if (el?.isConnected) {
|
|
260
|
+
try { applyZoomOutEndState(el, currentStage, canvas) } catch (e) { /* ignore */ }
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
onComplete()
|
|
264
|
+
}, budgetMs)
|
|
265
|
+
|
|
266
|
+
// See runZoomIn: re-arm the deadline from the real animation start.
|
|
267
|
+
function handleStart (event) {
|
|
268
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
269
|
+
extend(budgetMs)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function handleEnd (event) {
|
|
273
|
+
if (event.target !== event.currentTarget || !zoomAnims.has(event.animationName)) return
|
|
274
|
+
const el = event.currentTarget
|
|
275
|
+
el.removeEventListener('animationend', handleEnd)
|
|
276
|
+
el.removeEventListener('animationstart', handleStart)
|
|
277
|
+
if (el.isConnected) {
|
|
278
|
+
try { applyZoomOutEndState(el, currentStage, canvas) } catch (e) { /* ignore */ }
|
|
279
|
+
}
|
|
280
|
+
pending--
|
|
281
|
+
if (pending <= 0) finish()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
elements.forEach(el => {
|
|
285
|
+
el.addEventListener('animationstart', handleStart)
|
|
286
|
+
el.addEventListener('animationend', handleEnd)
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function applyZoomOutEndState (element, currentStage, canvas) {
|
|
291
|
+
if (element.classList.contains('zoom-current-view-reverse')) {
|
|
292
|
+
removeViewFromCanvas(element, canvas)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
if (element.classList.contains('zoom-previous-view-reverse')) {
|
|
296
|
+
applyZoomOutPreviousState(element, currentStage.views[1].backwardState)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (element.classList.contains('zoom-last-view-reverse')) {
|
|
300
|
+
applyZoomOutLastState(element, currentStage.views[2].backwardState)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Internal helpers ────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function setCSSVars (el, duration, ease, vars) {
|
|
307
|
+
el.style.setProperty('--zoom-duration', duration)
|
|
308
|
+
el.style.setProperty('--zoom-ease', ease)
|
|
309
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
310
|
+
el.style.setProperty(key, value)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function cleanupAnimListeners (elements, endHandler, startHandler) {
|
|
315
|
+
for (const el of elements) {
|
|
316
|
+
if (!el?.removeEventListener) continue
|
|
317
|
+
el.removeEventListener('animationend', endHandler)
|
|
318
|
+
el.removeEventListener('animationstart', startHandler)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -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
|
|
@@ -211,10 +211,10 @@ export const SAFETY_BUFFER_MS = 150
|
|
|
211
211
|
*
|
|
212
212
|
* @param {function} cleanup - The actual cleanup + onComplete work
|
|
213
213
|
* @param {number} timeoutMs - Safety timeout duration
|
|
214
|
-
* @returns {{ finish: function, safetyTimer: number }}
|
|
214
|
+
* @returns {{ finish: function, extend: function, safetyTimer: number }}
|
|
215
215
|
*
|
|
216
216
|
* @example
|
|
217
|
-
* const { finish,
|
|
217
|
+
* const { finish, extend } = createFinishGuard(() => {
|
|
218
218
|
* cancelAnimations()
|
|
219
219
|
* applyFinalState()
|
|
220
220
|
* onComplete()
|
|
@@ -224,10 +224,13 @@ export const SAFETY_BUFFER_MS = 150
|
|
|
224
224
|
* animation.onfinish = finish
|
|
225
225
|
*
|
|
226
226
|
* // The safetyTimer ensures finish() runs even if onfinish never fires.
|
|
227
|
+
* // Call extend(ms) when the animation actually starts to re-arm the
|
|
228
|
+
* // deadline from that moment (styles flush on the next render frame,
|
|
229
|
+
* // which can lag far behind the JS call on heavy views).
|
|
227
230
|
*/
|
|
228
231
|
export function createFinishGuard (cleanup, timeoutMs) {
|
|
229
232
|
let completed = false
|
|
230
|
-
|
|
233
|
+
let safetyTimer = setTimeout(() => {
|
|
231
234
|
if (!completed) { completed = true; cleanup() }
|
|
232
235
|
}, timeoutMs)
|
|
233
236
|
|
|
@@ -238,6 +241,13 @@ export function createFinishGuard (cleanup, timeoutMs) {
|
|
|
238
241
|
clearTimeout(safetyTimer)
|
|
239
242
|
cleanup()
|
|
240
243
|
},
|
|
244
|
+
extend (ms) {
|
|
245
|
+
if (completed) return
|
|
246
|
+
clearTimeout(safetyTimer)
|
|
247
|
+
safetyTimer = setTimeout(() => {
|
|
248
|
+
if (!completed) { completed = true; cleanup() }
|
|
249
|
+
}, ms)
|
|
250
|
+
},
|
|
241
251
|
safetyTimer
|
|
242
252
|
}
|
|
243
253
|
}
|
|
@@ -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
|
+
}
|