gclass-anims 1.0.0-beta.1
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/AnimToggle.js +122 -0
- package/Animations.js +436 -0
- package/Config.js +160 -0
- package/CustomAnims.js +56 -0
- package/LICENSE +35 -0
- package/Listeners.js +1464 -0
- package/README.md +98 -0
- package/index.d.ts +180 -0
- package/index.js +5 -0
- package/package.json +50 -0
package/Listeners.js
ADDED
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
import gsap from 'gsap'
|
|
2
|
+
import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars } from './Animations'
|
|
3
|
+
import { customAnims } from './CustomAnims'
|
|
4
|
+
import { defaults, normalize } from './Config'
|
|
5
|
+
import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
|
|
6
|
+
|
|
7
|
+
// Prefix for SplitText text-reveal classes. Distinct from the raw `text-*`
|
|
8
|
+
// so it can't collide with Tailwind utility classes like `text-red-500`.
|
|
9
|
+
const TEXT_PREFIX = "spawn-text-"
|
|
10
|
+
const TEXT_PREFIX_LEN = TEXT_PREFIX.length
|
|
11
|
+
|
|
12
|
+
// The engine is fully config-driven. All animation definitions live in
|
|
13
|
+
// Config.js; here we just normalise them into the two internal views the
|
|
14
|
+
// machinery consumes (spawn/entrance + loop) plus the raw `all` list.
|
|
15
|
+
const { all: animAll, spawnConfigs, loopConfigs } = normalize(customAnims)
|
|
16
|
+
|
|
17
|
+
// --- Named onComplete handler registry -------------------------------------
|
|
18
|
+
// `on-<kind>-complete-<name>` classes resolve `<name>` to a function here
|
|
19
|
+
// (preferred) or to a global `window[<name>]` as a fallback. Register your
|
|
20
|
+
// handlers with `registerComplete(name, fn)` so the engine can find them
|
|
21
|
+
// without polluting the global scope.
|
|
22
|
+
const completeHandlers = new Map()
|
|
23
|
+
export function registerComplete(name, fn) {
|
|
24
|
+
if (typeof fn === "function") completeHandlers.set(name, fn)
|
|
25
|
+
return fn
|
|
26
|
+
}
|
|
27
|
+
export function resolveHandler(name) {
|
|
28
|
+
if (completeHandlers.has(name)) return completeHandlers.get(name)
|
|
29
|
+
if (typeof window !== "undefined" && typeof window[name] === "function") return window[name]
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function initListeners() {
|
|
34
|
+
gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
|
|
35
|
+
|
|
36
|
+
const registeredListeners = []
|
|
37
|
+
const onCompleteTweens = []
|
|
38
|
+
const addListener = (el, type, fn) => {
|
|
39
|
+
el.addEventListener(type, fn)
|
|
40
|
+
registeredListeners.push({ el, type, fn })
|
|
41
|
+
}
|
|
42
|
+
const readClassNumber = (el, prefix, fallback) => {
|
|
43
|
+
const match = [...el.classList].find(c => c.startsWith(prefix))
|
|
44
|
+
return match ? Number(match.slice(prefix.length)) : fallback
|
|
45
|
+
}
|
|
46
|
+
const getEase = (el) => {
|
|
47
|
+
const match = [...el.classList].find(c => c.startsWith("ease-"))
|
|
48
|
+
return match ? match.split("-")[1] : defaults.ease
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Reduced-motion support. `.reduced` is a per-element opt-out: when the
|
|
52
|
+
// OS has "reduce motion" enabled, any element carrying `.reduced` is left
|
|
53
|
+
// completely un-animated (its spawn/loop/click/scroll/setup all skip).
|
|
54
|
+
const reducedMotion = () =>
|
|
55
|
+
(typeof window !== "undefined" && window.matchMedia?.(`(prefers-reduced-motion: reduce)`)?.matches) ?? false
|
|
56
|
+
const isReduced = (el) => reducedMotion() && el.classList.contains("reduced")
|
|
57
|
+
|
|
58
|
+
// `.preserve` keeps an already-rendered element (e.g. one that persists
|
|
59
|
+
// in a shared layout across route changes) from being re-animated when
|
|
60
|
+
// the Listeners setup re-runs. The element is animated the first time it
|
|
61
|
+
// appears and tagged with data-gsap-preserved; on a later path change the
|
|
62
|
+
// tag survives on the persistent DOM node, so setup skips it.
|
|
63
|
+
// `.preserve` keeps an already-rendered element from being re-animated. It
|
|
64
|
+
// applies to the element AND its children: any preserved ancestor also
|
|
65
|
+
// suppresses animation on this node.
|
|
66
|
+
const isPreserved = (el) => {
|
|
67
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
68
|
+
if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
|
|
73
|
+
|
|
74
|
+
// `spawnConfigs` is derived from the config in Config.js (see top of
|
|
75
|
+
// file). Adding/removing an entry there automatically re-wires every
|
|
76
|
+
// spawn feature below: order, scroll, leave, appear and text variants.
|
|
77
|
+
|
|
78
|
+
// Leave animations derive from spawnConfigs so adding an entry here
|
|
79
|
+
// automatically enables its leave/exit reverse too (single source of truth).
|
|
80
|
+
const findSpawn = (el) => {
|
|
81
|
+
const direct = spawnConfigs.find(({ sel }) => el.matches?.(sel))
|
|
82
|
+
if (direct) return direct
|
|
83
|
+
const cls = [...el.classList].find(c => c.startsWith(TEXT_PREFIX))
|
|
84
|
+
if (!cls) return null
|
|
85
|
+
return spawnConfigs.find(({ sel }) => sel === "." + cls.slice(TEXT_PREFIX_LEN))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isGhost = (el) => el?.dataset?.gsapGhost === "1"
|
|
89
|
+
// Ghosts (leave proxies) must be ignored by every observer, so strip all
|
|
90
|
+
// magic classes and tag them. Otherwise they get re-captured and re-animated.
|
|
91
|
+
const markGhost = (el) => {
|
|
92
|
+
el.classList.remove("leave", "appear")
|
|
93
|
+
spawnConfigs.forEach(({ sel }) => el.classList.remove(sel.slice(1)))
|
|
94
|
+
el.setAttribute("data-gsap-ghost", "1")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const leaveStates = new WeakMap()
|
|
98
|
+
|
|
99
|
+
const captureLeave = (node) => {
|
|
100
|
+
if (!node.classList?.contains("leave")) return
|
|
101
|
+
const config = findSpawn(node)
|
|
102
|
+
if (!config || config.typewriter) return
|
|
103
|
+
leaveStates.set(node, {
|
|
104
|
+
html: node.outerHTML,
|
|
105
|
+
rect: node.getBoundingClientRect(),
|
|
106
|
+
tween: node._spawnTween || node._scrollTween,
|
|
107
|
+
from: config.from,
|
|
108
|
+
ease: getEase(node),
|
|
109
|
+
parent: node.parentNode,
|
|
110
|
+
next: node.nextElementSibling,
|
|
111
|
+
margin: getComputedStyle(node).margin,
|
|
112
|
+
zIndex: getComputedStyle(node).zIndex,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const flipStates = new WeakMap()
|
|
117
|
+
// Elements currently being flipped (re-entrancy guard).
|
|
118
|
+
const flipping = new WeakSet()
|
|
119
|
+
|
|
120
|
+
// Document-relative bounds. getBoundingClientRect() is viewport-relative,
|
|
121
|
+
// so its values shift by window scroll; using document coords keeps the
|
|
122
|
+
// FLIP delta free of any scroll that happened between capture and play.
|
|
123
|
+
const flipPos = (node) => {
|
|
124
|
+
const r = node.getBoundingClientRect()
|
|
125
|
+
return { x: r.x + window.scrollX, y: r.y + window.scrollY, w: r.width, h: r.height }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Capture the element's first (resting) bounds. A later layout change
|
|
129
|
+
// morphs from this snapshot to the live position — a vanilla FLIP.
|
|
130
|
+
const captureFlip = (node) => {
|
|
131
|
+
if (!node.classList?.contains("flip")) return
|
|
132
|
+
const config = findSpawn(node)
|
|
133
|
+
if (!config || config.typewriter) return
|
|
134
|
+
if (flipping.has(node)) return
|
|
135
|
+
flipStates.set(node, flipPos(node))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Last = current bounds; Invert = delta to rewind to the captured
|
|
139
|
+
// bounds; Play = fromTo(transform) back to 0 so the element slides
|
|
140
|
+
// from its first position into its new layout spot.
|
|
141
|
+
const playFlip = (node) => {
|
|
142
|
+
// A new flip supersedes an in-flight one: just kill the old tween
|
|
143
|
+
// and clear its transform so the rect below reads the true layout,
|
|
144
|
+
// otherwise the leftover mid-flight transform compounds each time.
|
|
145
|
+
if (node._flipTween) {
|
|
146
|
+
node._flipTween.kill()
|
|
147
|
+
node._flipTween = null
|
|
148
|
+
gsap.set(node, { clearProps: "transform" })
|
|
149
|
+
flipping.delete(node)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const old = flipStates.get(node)
|
|
153
|
+
const cur = flipPos(node)
|
|
154
|
+
// Re-anchor the baseline to the current layout; if we have no
|
|
155
|
+
// previous state this is just an initial capture.
|
|
156
|
+
flipStates.set(node, cur)
|
|
157
|
+
if (!old) return
|
|
158
|
+
|
|
159
|
+
const dx = Math.round(old.x - cur.x)
|
|
160
|
+
const dy = Math.round(old.y - cur.y)
|
|
161
|
+
if (!dx && !dy) return
|
|
162
|
+
|
|
163
|
+
flipping.add(node)
|
|
164
|
+
node._flipTween = gsap.fromTo(node,
|
|
165
|
+
{ x: dx, y: dy },
|
|
166
|
+
{
|
|
167
|
+
x: 0, y: 0,
|
|
168
|
+
duration: readClassNumber(node, "time-", defaults.effectDuration),
|
|
169
|
+
ease: getEase(node),
|
|
170
|
+
onComplete: () => {
|
|
171
|
+
flipping.delete(node)
|
|
172
|
+
node._flipTween = null
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Animate every captured `.flip` element under a scope whose layout just
|
|
179
|
+
// changed (called after reflow, so Flip reads the new bounds).
|
|
180
|
+
const animateFlip = (scope) => {
|
|
181
|
+
if (!scope) return
|
|
182
|
+
gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
|
|
183
|
+
.forEach((el) => { if (el.isConnected) playFlip(el) })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
const playLeave = (node) => {
|
|
188
|
+
const snap = leaveStates.get(node)
|
|
189
|
+
leaveStates.delete(node)
|
|
190
|
+
if (!snap || node._leaving) return
|
|
191
|
+
node._leaving = true
|
|
192
|
+
|
|
193
|
+
// Hold the removed node's layout space during the leave animation so
|
|
194
|
+
// the content below doesn't jump up the instant it's removed. The
|
|
195
|
+
// node is re-attached as a fixed ghost (out of layout), so without
|
|
196
|
+
// this placeholder everything beneath snaps into place before the
|
|
197
|
+
// exit finishes. The spacer is dropped once the leave completes.
|
|
198
|
+
const placeSpacer = (snap) => {
|
|
199
|
+
if (!snap.parent || snap.parent.nodeType !== 1) return null
|
|
200
|
+
const spacer = document.createElement("div")
|
|
201
|
+
spacer.style.cssText = `box-sizing:border-box;width:${snap.rect.width}px;height:${snap.rect.height}px;`
|
|
202
|
+
if (snap.margin) spacer.style.margin = snap.margin
|
|
203
|
+
// The captured `next` sibling may itself have been removed by the
|
|
204
|
+
// time this runs (e.g. several siblings leave together). Only use
|
|
205
|
+
// it as an anchor if it's still a live child; otherwise append.
|
|
206
|
+
const next = snap.next && snap.next.parentNode === snap.parent ? snap.next : null
|
|
207
|
+
snap.parent.insertBefore(spacer, next)
|
|
208
|
+
return spacer
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// True reverse of the real tween. If the node was already removed
|
|
212
|
+
// (external removal), re-attach that same element fixed at its last
|
|
213
|
+
// position so the reversed tween is actually visible. Observer-facing
|
|
214
|
+
// classes are stripped so the re-attach can't re-trigger enter/leave.
|
|
215
|
+
if (snap.tween) {
|
|
216
|
+
const reattached = !node.isConnected
|
|
217
|
+
const spacer = reattached ? placeSpacer(snap) : null
|
|
218
|
+
if (reattached) {
|
|
219
|
+
node.classList.remove("leave", "appear", "scroll", "scroll-progress")
|
|
220
|
+
node.style.cssText = `position:fixed;z-index:${snap.zIndex};left:${snap.rect.left}px;top:${snap.rect.top}px;
|
|
221
|
+
width:${snap.rect.width}px;height:${snap.rect.height}px;margin:0;`
|
|
222
|
+
document.body.appendChild(node)
|
|
223
|
+
}
|
|
224
|
+
snap.tween.reverse()
|
|
225
|
+
snap.tween.eventCallback("onReverseComplete", () => {
|
|
226
|
+
if (reattached) node.remove()
|
|
227
|
+
else node.style.display = "none"
|
|
228
|
+
spacer?.remove()
|
|
229
|
+
})
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Fallback: no tween captured -> clone a ghost and animate to the
|
|
234
|
+
// spawn's "from" state (best-effort reverse).
|
|
235
|
+
const ghost = document.createElement("div")
|
|
236
|
+
ghost.innerHTML = snap.html
|
|
237
|
+
const g = ghost.firstElementChild
|
|
238
|
+
g.classList.remove("leave", "appear", "scroll", "scroll-progress")
|
|
239
|
+
g.style.cssText = `position:fixed;z-index:${snap.zIndex};left:${snap.rect.left}px;top:${snap.rect.top}px;
|
|
240
|
+
width:${snap.rect.width}px;height:${snap.rect.height}px;margin:0;`
|
|
241
|
+
document.body.appendChild(g)
|
|
242
|
+
const spacer = placeSpacer(snap)
|
|
243
|
+
|
|
244
|
+
gsap.to(g, {
|
|
245
|
+
...snap.from,
|
|
246
|
+
duration: defaults.effectDuration,
|
|
247
|
+
ease: snap.ease || defaults.ease,
|
|
248
|
+
onComplete: () => { g.remove(); spacer?.remove() },
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const collectLeave = (node) => {
|
|
253
|
+
if (!node || node.nodeType !== 1) return []
|
|
254
|
+
if (node.classList?.contains("leave")) return [node]
|
|
255
|
+
return gsap.utils.toArray(node.querySelectorAll?.(".leave"))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Refresh the cached rect to the element's RESTING position (after its
|
|
259
|
+
// spawn transform settles), so re-attaching the leave ghost doesn't snap.
|
|
260
|
+
const refreshLeaveRect = (el) => {
|
|
261
|
+
const s = leaveStates.get(el)
|
|
262
|
+
if (s) {
|
|
263
|
+
s.rect = el.getBoundingClientRect()
|
|
264
|
+
// The spawn tween is created AFTER captureLeave (which runs on
|
|
265
|
+
// node-insert). Pick it up here so a later leave can reverse the
|
|
266
|
+
// real tween (fading + counting back down) instead of a bare ghost.
|
|
267
|
+
s.tween = el._spawnTween || el._scrollTween || s.tween
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Selector covering every spawn/expand class plus its auto-generated
|
|
272
|
+
// `.spawn-text-*` variant. Queried fresh inside getOrderDelay because the
|
|
273
|
+
// captured set must always reflect the current DOM (dynamically added or
|
|
274
|
+
// SplitText text elements can otherwise be missed, breaking their order).
|
|
275
|
+
const orderSelector = () =>
|
|
276
|
+
spawnConfigs.map(({ sel }) => sel).join(",") + "," +
|
|
277
|
+
spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
|
|
278
|
+
|
|
279
|
+
const getOrderDelay = (el, priority) => {
|
|
280
|
+
const samepri = gsap.utils.toArray(orderSelector())
|
|
281
|
+
.filter((e) => {
|
|
282
|
+
if (!e.classList.contains("order")) return false
|
|
283
|
+
const match = [...e.classList].find(p => p.startsWith("priority-"))
|
|
284
|
+
return (match ? Number(match.split("-")[1]) : 0) === priority
|
|
285
|
+
})
|
|
286
|
+
let order = samepri.indexOf(el)
|
|
287
|
+
if (el.classList.contains("reverse")) {
|
|
288
|
+
order = samepri.length - 1 - order
|
|
289
|
+
}
|
|
290
|
+
return order / defaults.orderDivide
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const readTiming = (el) => {
|
|
294
|
+
const priority = readClassNumber(el, "priority-", 0)
|
|
295
|
+
return {
|
|
296
|
+
delay: el.classList.contains("order")
|
|
297
|
+
? getOrderDelay(el, priority)
|
|
298
|
+
: priority * defaults.spawnDelayMultiplier,
|
|
299
|
+
duration: readClassNumber(el, "time-", 1),
|
|
300
|
+
ease: getEase(el),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- on-<kind>-complete-* handling ------------------------------------
|
|
305
|
+
// Triggered when a spawn / loop / click tween finishes. The class names a
|
|
306
|
+
// function to call, or (via `-anim-<name>`) an animation to play once.
|
|
307
|
+
// on-spawn-complete-<fn> on-spawn-complete-anim-<anim>
|
|
308
|
+
// on-loop-complete-<fn> on-loop-complete-anim-<anim>
|
|
309
|
+
// on-click-complete-<fn> on-click-complete-anim-<anim>
|
|
310
|
+
const playNamed = (el, name) => {
|
|
311
|
+
const entry = animAll.find((a) => a.sel === "." + name)
|
|
312
|
+
if (!entry) return
|
|
313
|
+
// `.complete-time-N` / `.complete-delay-N` override the triggered
|
|
314
|
+
// animation's duration / delay. They apply to spawn-style entries
|
|
315
|
+
// (passed into `play`); loop entries define their own duration, so
|
|
316
|
+
// only the delay is applied to them.
|
|
317
|
+
const delay = readClassNumber(el, "complete-delay-", 0)
|
|
318
|
+
const dur = readClassNumber(el, "complete-time-", 1)
|
|
319
|
+
const tween = entry.play
|
|
320
|
+
? entry.play(el, delay, dur, getEase(el))
|
|
321
|
+
: entry.build(el, readLoopCtx(el))
|
|
322
|
+
if (!tween) return
|
|
323
|
+
if (!entry.play) tween.delay(delay)
|
|
324
|
+
el[name] = tween
|
|
325
|
+
onCompleteTweens.push(tween)
|
|
326
|
+
}
|
|
327
|
+
const fireOnComplete = (el, kind) => {
|
|
328
|
+
const prefix = `on-${kind}-complete-`
|
|
329
|
+
const cls = [...el.classList].find((c) => c.startsWith(prefix))
|
|
330
|
+
if (!cls) return
|
|
331
|
+
const val = cls.slice(prefix.length)
|
|
332
|
+
if (val.startsWith("anim-")) playNamed(el, val.slice(5))
|
|
333
|
+
else {
|
|
334
|
+
const fn = resolveHandler(val)
|
|
335
|
+
if (fn) fn(el)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const scrollTriggers = []
|
|
340
|
+
const computeTo = (from) => {
|
|
341
|
+
const to = {}
|
|
342
|
+
for (const [key] of Object.entries(from)) {
|
|
343
|
+
if (key === "opacity") to[key] = 1
|
|
344
|
+
else if (key === "filter") to[key] = "blur(0px)"
|
|
345
|
+
else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
|
|
346
|
+
else to[key] = key.startsWith("scale") ? 1 : 0
|
|
347
|
+
}
|
|
348
|
+
return to
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// SplitText: `.spawn-text-<spawn/expand>` splits the element's text into
|
|
352
|
+
// chars (or words/lines) and animates each part with the same `from`
|
|
353
|
+
// state as its base spawn config. Granularity is chosen via an extra
|
|
354
|
+
// `.words` / `.lines` class (default `chars`). Per-part stagger is set
|
|
355
|
+
// with `stagger-N`.
|
|
356
|
+
const textSplits = []
|
|
357
|
+
const splitCache = new WeakMap()
|
|
358
|
+
const RTL_RE = /[\u0590-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
|
359
|
+
const isRTLText = (el) => RTL_RE.test(el.textContent || "")
|
|
360
|
+
// Split granularity. Explicit `.lines` / `.words` override everything;
|
|
361
|
+
// `.letter` opts back into the per-character mode (the old
|
|
362
|
+
// default). Default (no class) is per-WORD: far fewer split nodes, so
|
|
363
|
+
// the per-part spawn is much cheaper to animate and paint.
|
|
364
|
+
const getGranularity = (el) => {
|
|
365
|
+
if (el.classList.contains("lines")) return "lines"
|
|
366
|
+
if (el.classList.contains("words")) return "words"
|
|
367
|
+
if (el.classList.contains("letter")) return "chars"
|
|
368
|
+
return "words"
|
|
369
|
+
}
|
|
370
|
+
// Cursive RTL scripts (Arabic/Persian) render each letter as a distinct
|
|
371
|
+
// glyph and join neighbours during text shaping. Naively splitting at
|
|
372
|
+
// char level puts each letter in its own span, breaking those joins so
|
|
373
|
+
// words look disconnected/isolated (and for single-word strings would
|
|
374
|
+
// collapse into one part). Instead we use GSAP's splitArabicText trick:
|
|
375
|
+
// split into words first, then re-wrap every letter in its own span,
|
|
376
|
+
// injecting Zero-Width-Joiners so neighbours stay connected while each
|
|
377
|
+
// letter remains individually animatable.
|
|
378
|
+
const RTL_NON_JOINABLE = /[اأإآدذرزوؤءة]/
|
|
379
|
+
const RTL_DIACRITICS = /[\u064B-\u065F\u0670]/g
|
|
380
|
+
// Spacing-only characters (the Persian half-space ZWNJ, a literal ZWJ and
|
|
381
|
+
// whitespace) must not become tween targets; they are kept as inert text
|
|
382
|
+
// nodes so the natural gap is preserved and never animated.
|
|
383
|
+
const RTL_JOIN_BREAK = /[\u200C\u200D\s]/
|
|
384
|
+
// A real Arabic/Persian joining letter. Any other visible character —
|
|
385
|
+
// Latin, digits, punctuation (، ؟ ؛ . ! …) — is NOT a joining letter: it
|
|
386
|
+
// must not give the preceding letter a trailing Zero-Width-Joiner (which
|
|
387
|
+
// would render it in its connecting form instead of its correct END form),
|
|
388
|
+
// but it should still be split into its own span so it animates too.
|
|
389
|
+
const RTL_LETTER = /[\u0621-\u064A\u066E-\u06D5\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
|
390
|
+
const getRTLCharSplit = (el) => new SplitText(el, {
|
|
391
|
+
type: "words",
|
|
392
|
+
linesClass: "gsap-line",
|
|
393
|
+
wordsClass: "gsap-word",
|
|
394
|
+
charsClass: "gsap-char",
|
|
395
|
+
onSplit(self) {
|
|
396
|
+
const ZWJ = "\u200D"
|
|
397
|
+
const connects = (s) => !RTL_NON_JOINABLE.test(s.replace(RTL_DIACRITICS, "").slice(-1))
|
|
398
|
+
self.chars.length = 0
|
|
399
|
+
self.words.forEach((wordEl) => {
|
|
400
|
+
const chars = Array.from(wordEl.textContent)
|
|
401
|
+
const segs = [] // { text, brk:true=spacing, letter:false=punctuation }
|
|
402
|
+
let i = 0
|
|
403
|
+
while (i < chars.length) {
|
|
404
|
+
const c = chars[i]
|
|
405
|
+
if (RTL_JOIN_BREAK.test(c)) {
|
|
406
|
+
segs.push({ text: c, brk: true })
|
|
407
|
+
i++
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
if (!RTL_LETTER.test(c)) {
|
|
411
|
+
segs.push({ text: c, brk: false, letter: false })
|
|
412
|
+
i++
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
let g = c
|
|
416
|
+
if (c === "\u0644" && i + 1 < chars.length) {
|
|
417
|
+
let j = i + 1
|
|
418
|
+
let d = ""
|
|
419
|
+
while (j < chars.length && RTL_DIACRITICS.test(chars[j])) d += chars[j++]
|
|
420
|
+
if (j < chars.length && /[\u0622\u0623\u0625\u0627]/.test(chars[j])) {
|
|
421
|
+
g += d + chars[j]
|
|
422
|
+
i = j
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
while (i + 1 < chars.length && RTL_DIACRITICS.test(chars[i + 1])) {
|
|
426
|
+
g += chars[i + 1]
|
|
427
|
+
i++
|
|
428
|
+
}
|
|
429
|
+
i++
|
|
430
|
+
segs.push({ text: g, brk: false, letter: true })
|
|
431
|
+
}
|
|
432
|
+
wordEl.textContent = ""
|
|
433
|
+
let prevConnectable = false
|
|
434
|
+
segs.forEach((seg, si) => {
|
|
435
|
+
if (seg.brk) {
|
|
436
|
+
// keep spacing as an inert text node: it preserves the
|
|
437
|
+
// natural gap and never becomes a tween target
|
|
438
|
+
wordEl.appendChild(document.createTextNode(seg.text))
|
|
439
|
+
prevConnectable = false
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (!seg.letter) {
|
|
443
|
+
// visible non-letter (punctuation/digit/…): animate it
|
|
444
|
+
// as its own span but it never joins, and it ends the
|
|
445
|
+
// current run so the next letter can't connect to it
|
|
446
|
+
const pEl = document.createElement("div")
|
|
447
|
+
pEl.style.display = "inline-block"
|
|
448
|
+
pEl.className = "gsap-char"
|
|
449
|
+
pEl.textContent = seg.text
|
|
450
|
+
wordEl.appendChild(pEl)
|
|
451
|
+
self.chars.push(pEl)
|
|
452
|
+
prevConnectable = false
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const connectable = connects(seg.text)
|
|
456
|
+
let s = seg.text
|
|
457
|
+
if (prevConnectable) s = ZWJ + s
|
|
458
|
+
const nextIsLetter = si + 1 < segs.length && segs[si + 1].letter
|
|
459
|
+
if (nextIsLetter && connectable) s += ZWJ
|
|
460
|
+
const charEl = document.createElement("div")
|
|
461
|
+
charEl.style.display = "inline-block"
|
|
462
|
+
charEl.className = "gsap-char"
|
|
463
|
+
charEl.textContent = s
|
|
464
|
+
wordEl.appendChild(charEl)
|
|
465
|
+
self.chars.push(charEl)
|
|
466
|
+
prevConnectable = connectable
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
self.words.forEach((w) => w.replaceWith(...w.childNodes))
|
|
470
|
+
self.words.length = 0
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const getSplit = (el, gran) => {
|
|
475
|
+
let s = splitCache.get(el)
|
|
476
|
+
const rtlChars = gran === "chars" && isRTLText(el)
|
|
477
|
+
const key = rtlChars ? "rtl-chars" : gran
|
|
478
|
+
if (!s || s.granularity !== key) {
|
|
479
|
+
s?.revert()
|
|
480
|
+
s = rtlChars
|
|
481
|
+
? getRTLCharSplit(el)
|
|
482
|
+
: new SplitText(el, {
|
|
483
|
+
type: gran,
|
|
484
|
+
linesClass: "gsap-line",
|
|
485
|
+
wordsClass: "gsap-word",
|
|
486
|
+
charsClass: "gsap-char",
|
|
487
|
+
})
|
|
488
|
+
s.granularity = key
|
|
489
|
+
splitCache.set(el, s)
|
|
490
|
+
textSplits.push(s)
|
|
491
|
+
}
|
|
492
|
+
return s
|
|
493
|
+
}
|
|
494
|
+
const getParts = (el, gran) => {
|
|
495
|
+
const s = getSplit(el, gran)
|
|
496
|
+
const parts = (gran === "chars" && isRTLText(el)) ? (s.chars || []) : (s[gran] || [])
|
|
497
|
+
// Explicitly promote each part to its own compositing layer. Chromium
|
|
498
|
+
// does this automatically for animated elements; Firefox is
|
|
499
|
+
// conservative and otherwise repaints these inline-block parts on the
|
|
500
|
+
// main thread every frame, which is what makes split-text lag there.
|
|
501
|
+
for (let i = 0; i < parts.length; i++) {
|
|
502
|
+
parts[i].style.willChange = "transform, opacity"
|
|
503
|
+
}
|
|
504
|
+
return parts
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Collapse a split back into a single text node once the part-spawn
|
|
508
|
+
// finishes. Unless the author opts out with `.no-revert`, this frees the
|
|
509
|
+
// hundreds of per-letter elements so the browser stops reflowing them.
|
|
510
|
+
const revertSplit = (el) => {
|
|
511
|
+
if (el.classList.contains("no-revert")) return
|
|
512
|
+
const s = splitCache.get(el)
|
|
513
|
+
if (!s) return
|
|
514
|
+
splitCache.delete(el)
|
|
515
|
+
const idx = textSplits.indexOf(s)
|
|
516
|
+
if (idx !== -1) textSplits.splice(idx, 1)
|
|
517
|
+
s.revert()
|
|
518
|
+
}
|
|
519
|
+
// The whole point of `.time-X` on a `.spawn-text-X` element is that the
|
|
520
|
+
// FULL reveal (first part starting to last part finishing) takes X
|
|
521
|
+
// seconds, no matter how many chars/words/lines it got split into.
|
|
522
|
+
// GSAP staggered tweens actually finish at `duration + stagger * (n-1)`,
|
|
523
|
+
// so `dur` can't be handed straight to `duration` as before — instead we
|
|
524
|
+
// solve for `duration`/`stagger` together so they always sum to `dur`.
|
|
525
|
+
// An explicit `.stagger-N` class is honored as-is; only `duration` is
|
|
526
|
+
// back-solved in that case so the last part still lands on `dur`.
|
|
527
|
+
const playText = (el, from, delay, dur, ease) => {
|
|
528
|
+
const gran = getGranularity(el)
|
|
529
|
+
const parts = getParts(el, gran)
|
|
530
|
+
if (!parts.length) return
|
|
531
|
+
const requestedStagger = readClassNumber(el, "stagger-", null)
|
|
532
|
+
let stagger, duration
|
|
533
|
+
if (requestedStagger != null) {
|
|
534
|
+
// Explicit stagger: honor it as-is, back-solve duration so the
|
|
535
|
+
// last part still lands on `dur` (floored so motion stays visible
|
|
536
|
+
// even if that pushes the true total slightly past `dur`).
|
|
537
|
+
stagger = requestedStagger
|
|
538
|
+
duration = Math.max(dur - stagger * (parts.length - 1), defaults.minTextPartDuration)
|
|
539
|
+
} else {
|
|
540
|
+
// No explicit stagger: keep each part's own duration a real,
|
|
541
|
+
// visible chunk of time (not shrinking toward 0 as part count
|
|
542
|
+
// grows) and shrink the STAGGER instead, so parts overlap more
|
|
543
|
+
// as there are more of them but the whole reveal still finishes
|
|
544
|
+
// at `dur`.
|
|
545
|
+
duration = Math.min(dur, Math.max(dur / 3, defaults.minTextPartDuration))
|
|
546
|
+
stagger = parts.length > 1 ? (dur - duration) / (parts.length - 1) : 0
|
|
547
|
+
}
|
|
548
|
+
return gsap.fromTo(parts, { ...from }, {
|
|
549
|
+
...computeTo(from), ease, duration, delay, stagger,
|
|
550
|
+
onComplete: () => {
|
|
551
|
+
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
552
|
+
revertSplit(el)
|
|
553
|
+
fireOnComplete(el, "spawn")
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Per-letter typewriter: split into chars and reveal each one in place,
|
|
559
|
+
// one after another (stagger) so it reads like being typed, unlike the
|
|
560
|
+
// single-stream TextPlugin `typewriter`. Already-typed chars stay visible.
|
|
561
|
+
const playTypewriterSplit = (el, delay, dur, ease) => {
|
|
562
|
+
const gran = getGranularity(el)
|
|
563
|
+
const parts = getParts(el, gran)
|
|
564
|
+
if (!parts.length) return
|
|
565
|
+
const perChar = dur / Math.max(parts.length, 1)
|
|
566
|
+
return gsap.fromTo(parts, { opacity: 0 }, {
|
|
567
|
+
opacity: 1,
|
|
568
|
+
ease,
|
|
569
|
+
duration: Math.min(defaults.typewriterSplitCharDuration, perChar),
|
|
570
|
+
delay,
|
|
571
|
+
stagger: perChar,
|
|
572
|
+
onComplete: () => revertSplit(el),
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const textClsFor = (el) => [...el.classList].find(c => c.startsWith(TEXT_PREFIX))
|
|
577
|
+
const isTextElement = (el) => {
|
|
578
|
+
const cls = textClsFor(el)
|
|
579
|
+
if (!cls) return false
|
|
580
|
+
return !!spawnConfigs.find(({ sel }) => sel === "." + cls.slice(TEXT_PREFIX_LEN))
|
|
581
|
+
}
|
|
582
|
+
// Sticky-pin: hold the element fixed to the viewport across a scroll
|
|
583
|
+
// range. The range is `progress-start-N` -> `progress-end-N` when those
|
|
584
|
+
// classes are present (N = how many % into view to engage, and how far
|
|
585
|
+
// out of view to release), else the full `top top` -> `bottom bottom`.
|
|
586
|
+
const setupPin = (el) => {
|
|
587
|
+
if (!el.classList.contains("pin") || el.dataset.gsapPinned) return
|
|
588
|
+
const clamp = (n) => Math.min(100, Math.max(0, n))
|
|
589
|
+
const startClass = readClassNumber(el, "progress-start-", null)
|
|
590
|
+
const endClass = readClassNumber(el, "progress-end-", null)
|
|
591
|
+
const start = startClass != null ? `top ${clamp(100 - startClass)}%` : "top top"
|
|
592
|
+
const end = endClass != null ? `top ${clamp(100 - endClass)}%` : "bottom top"
|
|
593
|
+
const t = ScrollTrigger.create({
|
|
594
|
+
trigger: el,
|
|
595
|
+
start,
|
|
596
|
+
end,
|
|
597
|
+
pin: true,
|
|
598
|
+
pinSpacing: true,
|
|
599
|
+
anticipatePin: 1,
|
|
600
|
+
})
|
|
601
|
+
el.dataset.gsapPinned = "1"
|
|
602
|
+
scrollTriggers.push(t)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Pins inject spacers that shift everything below them, so they MUST be
|
|
606
|
+
// created before any scroll/scroll-progress trigger measures its position.
|
|
607
|
+
// Setting them up here (before the trigger pass below) keeps offsets correct
|
|
608
|
+
// and lets the single ScrollTrigger.refresh() at the end reconcile layout.
|
|
609
|
+
gsap.utils.toArray(".pin").forEach(setupPin)
|
|
610
|
+
|
|
611
|
+
// Scroll-driven extras — class-driven ScrollTrigger behaviours that don't
|
|
612
|
+
// fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
|
|
613
|
+
// .parallax-N - element drifts relative to scroll. N is a
|
|
614
|
+
// speed factor: 1 = static, <1 = slower,
|
|
615
|
+
// >1 = faster (opposite travel direction).
|
|
616
|
+
// .progress-bar/.scroll-fill - fill 0->100% across a scroll range
|
|
617
|
+
// (scaleX, anchored left). progress-start-N /
|
|
618
|
+
// progress-end-N / progress-reverse honored.
|
|
619
|
+
// .scroll-fade-bg - lerp background-position across scroll
|
|
620
|
+
// (needs a background larger than the box).
|
|
621
|
+
// .scroll-horizontal - pinned section that pans its `.scroll-track`
|
|
622
|
+
// child left across the pinned range.
|
|
623
|
+
// (.clip-reveal and .curtain-* are spawn classes defined in Config.js,
|
|
624
|
+
// so they flow through the normal spawn/scroll/appear/leave machinery.)
|
|
625
|
+
const setupScrollDriven = (el) => {
|
|
626
|
+
if (el.dataset?.gsapScrollDriven) return
|
|
627
|
+
el.dataset.gsapScrollDriven = "1"
|
|
628
|
+
if (isReduced(el)) return
|
|
629
|
+
const cls = [...el.classList]
|
|
630
|
+
const clamp = (n) => Math.min(100, Math.max(0, n))
|
|
631
|
+
|
|
632
|
+
const parallaxCls = cls.find((c) => c.startsWith("parallax-"))
|
|
633
|
+
if (parallaxCls) {
|
|
634
|
+
const factor = parseFloat(parallaxCls.slice("parallax-".length)) || 1
|
|
635
|
+
if (factor === 1) return
|
|
636
|
+
const amt = (factor - 1) * 50
|
|
637
|
+
const t = gsap.fromTo(el,
|
|
638
|
+
{ yPercent: -amt },
|
|
639
|
+
{
|
|
640
|
+
yPercent: amt, ease: "none",
|
|
641
|
+
scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true },
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
scrollTriggers.push(t.scrollTrigger)
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (el.classList.contains("progress-bar") || el.classList.contains("scroll-fill")) {
|
|
649
|
+
const startClass = readClassNumber(el, "progress-start-", null)
|
|
650
|
+
const endClass = readClassNumber(el, "progress-end-", null)
|
|
651
|
+
// `.progress-reverse` runs the fill in reverse (full -> empty).
|
|
652
|
+
// GSAP's ScrollTrigger has no `reversed` config; swap the from/to
|
|
653
|
+
// so the scrub maps in the opposite direction instead.
|
|
654
|
+
const reverse = el.classList.contains("progress-reverse")
|
|
655
|
+
const t = gsap.fromTo(el,
|
|
656
|
+
{ scaleX: reverse ? 1 : 0 },
|
|
657
|
+
{
|
|
658
|
+
scaleX: reverse ? 0 : 1, ease: "none", transformOrigin: "left center",
|
|
659
|
+
scrollTrigger: {
|
|
660
|
+
trigger: el,
|
|
661
|
+
start: startClass != null ? `top ${clamp(100 - startClass)}%` : "top bottom",
|
|
662
|
+
end: endClass != null ? `top ${clamp(100 - endClass)}%` : "bottom top",
|
|
663
|
+
scrub: true,
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
scrollTriggers.push(t.scrollTrigger)
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (el.classList.contains("scroll-fade-bg")) {
|
|
672
|
+
const t = gsap.fromTo(el,
|
|
673
|
+
{ backgroundPosition: "0% 0%" },
|
|
674
|
+
{
|
|
675
|
+
backgroundPosition: "100% 100%", ease: "none",
|
|
676
|
+
scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true },
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
scrollTriggers.push(t.scrollTrigger)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (el.classList.contains("scroll-horizontal")) {
|
|
684
|
+
const track = el.querySelector(".scroll-track")
|
|
685
|
+
if (!track) return
|
|
686
|
+
const getAmount = () => track.scrollWidth - el.clientWidth
|
|
687
|
+
const t = gsap.to(track, {
|
|
688
|
+
x: () => -getAmount(), ease: "none",
|
|
689
|
+
scrollTrigger: {
|
|
690
|
+
trigger: el,
|
|
691
|
+
start: "top top",
|
|
692
|
+
end: () => `+=${getAmount()}`,
|
|
693
|
+
pin: true,
|
|
694
|
+
scrub: 1,
|
|
695
|
+
anticipatePin: 1,
|
|
696
|
+
invalidateOnRefresh: true,
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
scrollTriggers.push(t.scrollTrigger)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
|
|
703
|
+
|
|
704
|
+
// `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
|
|
705
|
+
// Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
|
|
706
|
+
// rendered after a data fetch) get a trigger too, instead of only elements
|
|
707
|
+
// already in the DOM at setup time. A `.scroll` element is owned by its
|
|
708
|
+
// ScrollTrigger; `animateAppear` skips it so the entrance never double-fires.
|
|
709
|
+
const setupScroll = (el) => {
|
|
710
|
+
if (el.dataset?.gsapScroll) return
|
|
711
|
+
if (isTextElement(el)) return
|
|
712
|
+
if (isReduced(el)) return
|
|
713
|
+
el.dataset.gsapScroll = "1"
|
|
714
|
+
const config = findSpawn(el)
|
|
715
|
+
if (!config) return
|
|
716
|
+
const { from, typewriter: isTypewriter, typewriterSplit, play } = config
|
|
717
|
+
|
|
718
|
+
if (el.classList.contains("scroll-progress")) {
|
|
719
|
+
const ease = isTypewriter
|
|
720
|
+
? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
|
|
721
|
+
: getEase(el)
|
|
722
|
+
|
|
723
|
+
const to = {}
|
|
724
|
+
for (const [key] of Object.entries(from)) {
|
|
725
|
+
if (key === "opacity") to[key] = 1
|
|
726
|
+
else if (key === "filter") to[key] = "blur(0px)"
|
|
727
|
+
else if (key === "text") to[key] = el.innerHTML
|
|
728
|
+
else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
|
|
729
|
+
else to[key] = key.startsWith("scale") ? 1 : 0
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const startClass = readClassNumber(el, "progress-start-", null)
|
|
733
|
+
const endClass = readClassNumber(el, "progress-end-", null)
|
|
734
|
+
const clamp = (n) => Math.min(100, Math.max(0, n))
|
|
735
|
+
// `.progress-reverse` runs the scrub in reverse (revealed -> hidden).
|
|
736
|
+
// GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
|
|
737
|
+
// the scrub maps in the opposite direction instead.
|
|
738
|
+
const reverse = el.classList.contains("progress-reverse")
|
|
739
|
+
|
|
740
|
+
const tl = gsap.timeline({
|
|
741
|
+
scrollTrigger: {
|
|
742
|
+
trigger: el,
|
|
743
|
+
start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
|
|
744
|
+
end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
|
|
745
|
+
scrub: true,
|
|
746
|
+
},
|
|
747
|
+
})
|
|
748
|
+
if (config.count) {
|
|
749
|
+
// Counter driven by scroll progress: count from `.spawn-num-N`
|
|
750
|
+
// to the target as the scrub advances (no opacity fade).
|
|
751
|
+
const { start, end, decimals } = countTargetVars(el)
|
|
752
|
+
const obj = { n: reverse ? end : start }
|
|
753
|
+
tl.fromTo(obj, { n: reverse ? end : start }, { n: reverse ? start : end, ease, onUpdate: () => { el.textContent = obj.n.toFixed(decimals) } }, 0)
|
|
754
|
+
} else if (typewriterSplit) {
|
|
755
|
+
const parts = getParts(el, getGranularity(el))
|
|
756
|
+
if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
|
|
757
|
+
} else {
|
|
758
|
+
tl.fromTo(el, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
|
|
759
|
+
}
|
|
760
|
+
scrollTriggers.push(tl.scrollTrigger)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!el.classList.contains("scroll")) return
|
|
765
|
+
const { delay, duration } = readTiming(el)
|
|
766
|
+
const ease = isTypewriter
|
|
767
|
+
? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
|
|
768
|
+
: getEase(el)
|
|
769
|
+
|
|
770
|
+
const fullText = el.innerHTML
|
|
771
|
+
|
|
772
|
+
const enter = () => {
|
|
773
|
+
if (el._scrollTween) el._scrollTween.kill()
|
|
774
|
+
el._scrollTween = isTypewriter
|
|
775
|
+
? (typewriterSplit
|
|
776
|
+
? playTypewriterSplit(el, delay, duration, ease)
|
|
777
|
+
: typewriter(el, fullText, duration, delay, ease))
|
|
778
|
+
: play(el, delay, duration, ease)
|
|
779
|
+
el._scrollTween.eventCallback("onComplete", () => fireOnComplete(el, "spawn"))
|
|
780
|
+
}
|
|
781
|
+
const reverseToStart = () => {
|
|
782
|
+
if (config.count) {
|
|
783
|
+
// A count spawn is a pure number timeline. Reversing it counts
|
|
784
|
+
// back down to the `.spawn-num-N` start value, so re-entering
|
|
785
|
+
// view counts up cleanly from scratch.
|
|
786
|
+
const t = el._scrollTween
|
|
787
|
+
if (t && t.progress() > 0 && !t.reversed()) t.reverse()
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
el._scrollTween?.kill()
|
|
791
|
+
if (isTypewriter && !typewriterSplit) {
|
|
792
|
+
el.innerHTML = fullText
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
if (typewriterSplit) {
|
|
796
|
+
const parts = getParts(el, getGranularity(el))
|
|
797
|
+
if (parts.length) gsap.to(parts, { opacity: 0, ease, duration: 0.3 })
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
el._scrollTween = gsap.to(el, { ...from, ease, duration: 0.3 })
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const st = ScrollTrigger.create({
|
|
804
|
+
trigger: el,
|
|
805
|
+
start: "top bottom",
|
|
806
|
+
end: "bottom top",
|
|
807
|
+
onEnter: enter,
|
|
808
|
+
onEnterBack: enter,
|
|
809
|
+
onLeave: reverseToStart,
|
|
810
|
+
onLeaveBack: reverseToStart,
|
|
811
|
+
})
|
|
812
|
+
scrollTriggers.push(st)
|
|
813
|
+
}
|
|
814
|
+
gsap.utils.toArray(".scroll, .scroll-progress").forEach(setupScroll)
|
|
815
|
+
|
|
816
|
+
// SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
|
|
817
|
+
// tween when the element enters the viewport and reverses on exit.
|
|
818
|
+
spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
|
|
819
|
+
if (isTypewriter || text === false) return
|
|
820
|
+
const tSel = "." + TEXT_PREFIX + sel.slice(1)
|
|
821
|
+
|
|
822
|
+
gsap.utils.toArray(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
|
|
823
|
+
if (isReduced(el)) return
|
|
824
|
+
const { delay, duration } = readTiming(el)
|
|
825
|
+
const ease = getEase(el)
|
|
826
|
+
const enter = () => {
|
|
827
|
+
el._scrollTween?.kill()
|
|
828
|
+
el._scrollTween = playText(el, from, delay, duration, ease)
|
|
829
|
+
}
|
|
830
|
+
const reverseToStart = () => {
|
|
831
|
+
el._scrollTween?.kill()
|
|
832
|
+
const parts = getParts(el, getGranularity(el))
|
|
833
|
+
if (parts.length) gsap.to(parts, { ...from, ease, duration: 0.3 })
|
|
834
|
+
}
|
|
835
|
+
scrollTriggers.push(ScrollTrigger.create({
|
|
836
|
+
trigger: el,
|
|
837
|
+
start: "top bottom",
|
|
838
|
+
end: "top top",
|
|
839
|
+
onEnter: enter,
|
|
840
|
+
onEnterBack: enter,
|
|
841
|
+
onLeave: reverseToStart,
|
|
842
|
+
onLeaveBack: reverseToStart,
|
|
843
|
+
}))
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
gsap.utils.toArray(tSel + ".scroll-progress").forEach((el) => {
|
|
847
|
+
if (isReduced(el)) return
|
|
848
|
+
const ease = getEase(el)
|
|
849
|
+
const parts = getParts(el, getGranularity(el))
|
|
850
|
+
if (!parts.length) return
|
|
851
|
+
const to = computeTo(from)
|
|
852
|
+
const startClass = readClassNumber(el, "progress-start-", null)
|
|
853
|
+
const endClass = readClassNumber(el, "progress-end-", null)
|
|
854
|
+
const clamp = (n) => Math.min(100, Math.max(0, n))
|
|
855
|
+
const tl = gsap.timeline({
|
|
856
|
+
scrollTrigger: {
|
|
857
|
+
trigger: el,
|
|
858
|
+
start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
|
|
859
|
+
end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
|
|
860
|
+
scrub: true,
|
|
861
|
+
},
|
|
862
|
+
})
|
|
863
|
+
// `.progress-reverse` runs the split scrub in reverse; swap from/to.
|
|
864
|
+
const reverse = el.classList.contains("progress-reverse")
|
|
865
|
+
tl.fromTo(parts, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
|
|
866
|
+
scrollTriggers.push(tl.scrollTrigger)
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
ScrollTrigger.refresh()
|
|
870
|
+
window.addEventListener("load", ScrollTrigger.refresh)
|
|
871
|
+
|
|
872
|
+
// Entrance tweens (expand-down / spawn-down on containers) shift layout
|
|
873
|
+
// while they run. ScrollTriggers bound to their descendants (list rows,
|
|
874
|
+
// pagination, cards) that get measured mid-animation report stale,
|
|
875
|
+
// compressed positions, so they all fire onEnter/onLeave at the same
|
|
876
|
+
// scroll spot regardless of their real resting place. Debounce a refresh
|
|
877
|
+
// that fires shortly after the LAST entrance tween completes, re-measuring
|
|
878
|
+
// every trigger at its true position.
|
|
879
|
+
let refreshTimer = null
|
|
880
|
+
const scheduleRefresh = () => {
|
|
881
|
+
clearTimeout(refreshTimer)
|
|
882
|
+
refreshTimer = setTimeout(() => {
|
|
883
|
+
refreshTimer = null
|
|
884
|
+
ScrollTrigger.refresh()
|
|
885
|
+
}, 60)
|
|
886
|
+
}
|
|
887
|
+
// A couple of early passes too, in case no entrance tween completes on a
|
|
888
|
+
// scroll-only page: catch mounts that settle before anything finishes.
|
|
889
|
+
setTimeout(scheduleRefresh, 400)
|
|
890
|
+
setTimeout(scheduleRefresh, 1200)
|
|
891
|
+
|
|
892
|
+
spawnConfigs.forEach(({ sel, typewriter: isTypewriter, typewriterSplit, play }) => {
|
|
893
|
+
gsap.utils.toArray(sel).forEach((el) => {
|
|
894
|
+
if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
|
|
895
|
+
if (isPreserved(el)) return
|
|
896
|
+
if (isReduced(el)) return
|
|
897
|
+
const { delay, duration } = readTiming(el)
|
|
898
|
+
if (isTypewriter) {
|
|
899
|
+
const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
|
|
900
|
+
const elEase = easeClass ? easeClass.split("-")[1] : "none"
|
|
901
|
+
if (typewriterSplit) {
|
|
902
|
+
el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
|
|
903
|
+
} else {
|
|
904
|
+
el.typewriter?.kill()
|
|
905
|
+
el.typewriter = typewriter(el, el.innerHTML, duration, delay, elEase)
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
el._spawnTween = play(el, delay, duration, getEase(el))
|
|
909
|
+
el._spawnTween.eventCallback("onComplete", () => {
|
|
910
|
+
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
911
|
+
if (el.classList.contains("flip")) captureFlip(el)
|
|
912
|
+
if (isCompatibility(el)) resumeCompatLoops(el)
|
|
913
|
+
scheduleRefresh()
|
|
914
|
+
fireOnComplete(el, "spawn")
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
markPreserved(el)
|
|
918
|
+
})
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
// SplitText static pass: `.spawn-text-<spawn>` plays the per-part tween on
|
|
922
|
+
// load (no scroll/appear). Derived from spawnConfigs so any new spawn
|
|
923
|
+
// class automatically gets a `.spawn-text-` variant.
|
|
924
|
+
spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
|
|
925
|
+
if (isTypewriter || text === false) return
|
|
926
|
+
const tSel = "." + TEXT_PREFIX + sel.slice(1)
|
|
927
|
+
gsap.utils.toArray(tSel).forEach((el) => {
|
|
928
|
+
if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
|
|
929
|
+
if (isPreserved(el)) return
|
|
930
|
+
if (isReduced(el)) return
|
|
931
|
+
const { delay, duration } = readTiming(el)
|
|
932
|
+
el._spawnTween = playText(el, from, delay, duration, getEase(el))
|
|
933
|
+
markPreserved(el)
|
|
934
|
+
})
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
const magnetQuery = typeof window !== "undefined" ? window.matchMedia("(hover: none)") : null
|
|
938
|
+
const magnetState = []
|
|
939
|
+
const magnetListeners = []
|
|
940
|
+
const magnetOnMove = (el, pull, grow, duration, elEase) => (ev) => {
|
|
941
|
+
const r = el.getBoundingClientRect()
|
|
942
|
+
const dx = ev.clientX - (r.left + r.width / 2)
|
|
943
|
+
const dy = ev.clientY - (r.top + r.height / 2)
|
|
944
|
+
magnet(el, dx * pull, dy * pull, grow, duration, elEase)
|
|
945
|
+
}
|
|
946
|
+
const magnetOnLeave = (el, duration, elEase) => () => magnet(el, 0, 0, 1, duration, elEase)
|
|
947
|
+
|
|
948
|
+
// `.magnet3d` moves exactly like `.magnet` but also tilts the element to
|
|
949
|
+
// face the cursor. The tilt is proportional to the cursor's position
|
|
950
|
+
// within the element (`relX`/`relY` in -0.5..0.5), scaled by the
|
|
951
|
+
// `mtilt-` degrees class (default 12). Cursor right/left swings it around
|
|
952
|
+
// the vertical axis (rotationY), cursor up/down around the horizontal
|
|
953
|
+
// (rotationX), so the face tracks the pointer.
|
|
954
|
+
const magnet3dOnMove = (el, pull, grow, tilt, duration, elEase) => (ev) => {
|
|
955
|
+
const r = el.getBoundingClientRect()
|
|
956
|
+
const dx = ev.clientX - (r.left + r.width / 2)
|
|
957
|
+
const dy = ev.clientY - (r.top + r.height / 2)
|
|
958
|
+
const relX = r.width ? (ev.clientX - r.left) / r.width - 0.5 : 0
|
|
959
|
+
const relY = r.height ? (ev.clientY - r.top) / r.height - 0.5 : 0
|
|
960
|
+
magnet3d(el, dx * pull, dy * pull, grow, -relY * tilt, relX * tilt, duration, elEase)
|
|
961
|
+
}
|
|
962
|
+
const magnet3dOnLeave = (el, duration, elEase) => () => magnet3d(el, 0, 0, 1, 0, 0, duration, elEase)
|
|
963
|
+
|
|
964
|
+
const applyMagnet = () => {
|
|
965
|
+
if (!magnetQuery) return
|
|
966
|
+
const touch = magnetQuery.matches
|
|
967
|
+
|
|
968
|
+
magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
969
|
+
magnetListeners.length = 0
|
|
970
|
+
|
|
971
|
+
if (touch) return
|
|
972
|
+
|
|
973
|
+
magnetState.forEach(({ el, onMove, onLeave }) => {
|
|
974
|
+
el.addEventListener("mousemove", onMove)
|
|
975
|
+
el.addEventListener("mouseleave", onLeave)
|
|
976
|
+
magnetListeners.push({ el, type: "mousemove", fn: onMove }, { el, type: "mouseleave", fn: onLeave })
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const setupMagnet = (el) => {
|
|
981
|
+
if (!el.classList.contains("magnet") && !el.classList.contains("magnet3d")) return
|
|
982
|
+
const threeD = el.classList.contains("magnet3d")
|
|
983
|
+
const duration = readClassNumber(el, "mtime-", 0.4)
|
|
984
|
+
const pull = readClassNumber(el, "amount-", 0.3)
|
|
985
|
+
const grow = readClassNumber(el, "mgrow-", 1.1)
|
|
986
|
+
const tilt = readClassNumber(el, "mtilt-", 12)
|
|
987
|
+
const elEase = getEase(el)
|
|
988
|
+
|
|
989
|
+
const onMove = threeD
|
|
990
|
+
? magnet3dOnMove(el, pull, grow, tilt, duration, elEase)
|
|
991
|
+
: magnetOnMove(el, pull, grow, duration, elEase)
|
|
992
|
+
const onLeave = threeD
|
|
993
|
+
? magnet3dOnLeave(el, duration, elEase)
|
|
994
|
+
: magnetOnLeave(el, duration, elEase)
|
|
995
|
+
|
|
996
|
+
magnetState.push({ el, onMove, onLeave })
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (magnetQuery) magnetQuery.addEventListener("change", applyMagnet)
|
|
1000
|
+
|
|
1001
|
+
// `.compatibility` lets an always-on loop (shake/bounce/pulse/...) coexist
|
|
1002
|
+
// with a hover/click interaction on the SAME element. Both write to the
|
|
1003
|
+
// same transform properties, so without this the two tweens fight. While
|
|
1004
|
+
// a hover/click is active we pause every tracked loop tween on the element
|
|
1005
|
+
// and resume it once the interaction ends. Loops are only tracked when the
|
|
1006
|
+
// `.compatibility` class is present, so nothing else changes behaviour.
|
|
1007
|
+
const isCompatibility = (el) => el.classList.contains("compatibility")
|
|
1008
|
+
const compatLoopsOf = (el) => {
|
|
1009
|
+
if (!el._gsapCompatLoops) el._gsapCompatLoops = []
|
|
1010
|
+
return el._gsapCompatLoops
|
|
1011
|
+
}
|
|
1012
|
+
const trackCompatLoop = (el, tween) => {
|
|
1013
|
+
if (tween && isCompatibility(el)) compatLoopsOf(el).push(tween)
|
|
1014
|
+
return tween
|
|
1015
|
+
}
|
|
1016
|
+
const pauseCompatLoops = (el) => compatLoopsOf(el).forEach((t) => t.pause())
|
|
1017
|
+
const resumeCompatLoops = (el) => compatLoopsOf(el).forEach((t) => t.resume())
|
|
1018
|
+
|
|
1019
|
+
const setupClicks = (el) => {
|
|
1020
|
+
if (isReduced(el)) return
|
|
1021
|
+
setupMagnet(el)
|
|
1022
|
+
if (el.classList.contains("click-hover")) {
|
|
1023
|
+
const area = wrapTarget(el)
|
|
1024
|
+
let touch = false
|
|
1025
|
+
const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
|
|
1026
|
+
const lift = readClassNumber(el, "amount-", defaults.clickOffset)
|
|
1027
|
+
const elEase = getEase(el)
|
|
1028
|
+
|
|
1029
|
+
addListener(area, "mousedown", () => { if (!touch) { pauseCompatLoops(el); verticalmove(el, -lift / 2, duration, elEase) } })
|
|
1030
|
+
addListener(area, "mouseover", () => { if (!touch) { pauseCompatLoops(el); verticalmove(el, -lift, duration, elEase) } })
|
|
1031
|
+
addListener(area, "mouseleave", () => { if (!touch) { verticalmove(el, 0, duration, elEase); resumeCompatLoops(el) } })
|
|
1032
|
+
addListener(area, "mouseup", () => { if (!touch) verticalmove(el, -lift, duration, elEase) })
|
|
1033
|
+
|
|
1034
|
+
addListener(area, "touchstart", () => { touch = true, pauseCompatLoops(el), verticalmove(el, lift / 2, duration, elEase) })
|
|
1035
|
+
addListener(area, "touchend", () => {
|
|
1036
|
+
touch = true, verticalmove(el, 0, duration, elEase), setTimeout(() => { touch = false }, 0)
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
if (el.classList.contains("click-expand")) {
|
|
1040
|
+
let touch = false
|
|
1041
|
+
const duration = readClassNumber(el, "ctime-", defaults.clickDuration)
|
|
1042
|
+
const lift = readClassNumber(el, "amount-", defaults.clickExpandOffset)
|
|
1043
|
+
const elEase = getEase(el)
|
|
1044
|
+
|
|
1045
|
+
addListener(el, "mousedown", () => { if (!touch) { pauseCompatLoops(el); expandmove(el, 10, duration, elEase).eventCallback("onComplete", () => fireOnComplete(el, "click")) } })
|
|
1046
|
+
addListener(el, "mouseover", () => { if (!touch) { pauseCompatLoops(el); expandmove(el, lift, duration, elEase) } })
|
|
1047
|
+
addListener(el, "mouseleave", () => { if (!touch) { expandmove(el, 10, duration, elEase); resumeCompatLoops(el) } })
|
|
1048
|
+
addListener(el, "mouseup", () => { if (!touch) expandmove(el, lift, duration, elEase) })
|
|
1049
|
+
|
|
1050
|
+
addListener(el, "touchstart", () => { touch = true, pauseCompatLoops(el), expandmove(el, lift, duration, elEase) })
|
|
1051
|
+
addListener(el, "touchend", () => {
|
|
1052
|
+
touch = true, expandmove(el, 10, duration, elEase).eventCallback("onComplete", () => fireOnComplete(el, "click")), setTimeout(() => { touch = false }, 0)
|
|
1053
|
+
})
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// `loopConfigs` is derived from the config in Config.js (see top of file).
|
|
1058
|
+
// A shared ctx object bundles the per-element timing/ease classes so each
|
|
1059
|
+
// entry's `build(el, ctx)` stays simple and declarative.
|
|
1060
|
+
const readLoopCtx = (el) => ({
|
|
1061
|
+
edelay: readClassNumber(el, "edelay-", defaults.effectDelay),
|
|
1062
|
+
amount: readClassNumber(el, "amount-", defaults.effectOffset),
|
|
1063
|
+
etime: readClassNumber(el, "etime-", defaults.effectDuration),
|
|
1064
|
+
ease: getEase(el),
|
|
1065
|
+
time: readClassNumber(el, "time-", 20),
|
|
1066
|
+
mH: readClassNumber(el, "marquee-horizontal-offset-", 0),
|
|
1067
|
+
mV: readClassNumber(el, "marquee-vertical-offset-", 0),
|
|
1068
|
+
radiateZ: readClassNumber(el, "radiate-z-", null),
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
const loopEls = []
|
|
1072
|
+
const buildLoops = (el) => {
|
|
1073
|
+
if (isReduced(el)) return
|
|
1074
|
+
const ctx = readLoopCtx(el)
|
|
1075
|
+
loopConfigs.forEach(({ sel, build, key, loop }) => {
|
|
1076
|
+
if (el.matches(sel)) {
|
|
1077
|
+
el[key]?.kill()
|
|
1078
|
+
el[key] = trackCompatLoop(el, build(el, ctx))
|
|
1079
|
+
if (loop) el[key].repeat(-1)
|
|
1080
|
+
// Infinite loops never truly complete, so fire on each cycle
|
|
1081
|
+
// (onRepeat); finite ones fire on their real completion.
|
|
1082
|
+
el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
|
|
1083
|
+
() => fireOnComplete(el, "loop"))
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
1087
|
+
// Find an in-progress spawn tween on the element or any ancestor. For a
|
|
1088
|
+
// `.compatibility` element, loop building is deferred until that spawn
|
|
1089
|
+
// settles so clone-based loops (radiate) capture the rect at the element's
|
|
1090
|
+
// FINAL position instead of its mid-spawn transform offset.
|
|
1091
|
+
const findSpawnTween = (el) => {
|
|
1092
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
1093
|
+
const t = node._spawnTween
|
|
1094
|
+
if (t && t.progress() < 1) return t
|
|
1095
|
+
}
|
|
1096
|
+
return null
|
|
1097
|
+
}
|
|
1098
|
+
const deferLoopBuild = (el, tween) => {
|
|
1099
|
+
if (!tween.__gsapPendingLoops) tween.__gsapPendingLoops = new Set()
|
|
1100
|
+
tween.__gsapPendingLoops.add(el)
|
|
1101
|
+
if (tween.__gsapPendingHooked) return
|
|
1102
|
+
tween.__gsapPendingHooked = true
|
|
1103
|
+
const existing = tween.eventCallback("onComplete")
|
|
1104
|
+
tween.eventCallback("onComplete", function () {
|
|
1105
|
+
existing && existing.call(this)
|
|
1106
|
+
const pending = tween.__gsapPendingLoops
|
|
1107
|
+
tween.__gsapPendingLoops = new Set()
|
|
1108
|
+
tween.__gsapPendingHooked = false
|
|
1109
|
+
pending.forEach((e) => buildLoops(e))
|
|
1110
|
+
})
|
|
1111
|
+
}
|
|
1112
|
+
const setupLoops = (el) => {
|
|
1113
|
+
const spawnTween = isCompatibility(el) ? findSpawnTween(el) : null
|
|
1114
|
+
if (spawnTween) {
|
|
1115
|
+
deferLoopBuild(el, spawnTween)
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
buildLoops(el)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// `hover-<name>` and `click-<name>` trigger one of the loop animations on
|
|
1122
|
+
// mouseenter/mousedown. The element is wrapped in a parent div that acts as
|
|
1123
|
+
// the stable hover/click hit area, while the element itself animates — so
|
|
1124
|
+
// the WHOLE box moves/scales instead of just its text, and the area never
|
|
1125
|
+
// shifts under the cursor. Marquee is skipped (its build restructures the
|
|
1126
|
+
// DOM).
|
|
1127
|
+
const wrapTarget = (el) => {
|
|
1128
|
+
if (!el.classList.contains("wrapdiv")) return el
|
|
1129
|
+
if (el._gsapWrap) return el._gsapWrap
|
|
1130
|
+
const area = document.createElement("div")
|
|
1131
|
+
el.before(area)
|
|
1132
|
+
area.appendChild(el)
|
|
1133
|
+
el._gsapWrap = area
|
|
1134
|
+
return area
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const setupHoverClick = (el) => {
|
|
1138
|
+
if (isReduced(el)) return
|
|
1139
|
+
const ctx = readLoopCtx(el)
|
|
1140
|
+
loopConfigs.forEach(({ sel, build, key }) => {
|
|
1141
|
+
if (sel.startsWith(".marquee")) return
|
|
1142
|
+
const name = sel.slice(1)
|
|
1143
|
+
if (el.classList.contains("hover-" + name)) {
|
|
1144
|
+
const area = wrapTarget(el)
|
|
1145
|
+
addListener(area, "mouseenter", () => {
|
|
1146
|
+
pauseCompatLoops(el)
|
|
1147
|
+
el[key]?.kill()
|
|
1148
|
+
el[key] = build(el, ctx).repeat(-1)
|
|
1149
|
+
el[key].eventCallback("onRepeat", () => fireOnComplete(el, "loop"))
|
|
1150
|
+
})
|
|
1151
|
+
addListener(area, "mouseleave", () => {
|
|
1152
|
+
el[key]?.kill()
|
|
1153
|
+
el[key] = reset(el, readClassNumber(el, "etime-", defaults.effectDuration), getEase(el))
|
|
1154
|
+
resumeCompatLoops(el)
|
|
1155
|
+
})
|
|
1156
|
+
} else if (el.classList.contains("click-" + name)) {
|
|
1157
|
+
const area = wrapTarget(el)
|
|
1158
|
+
addListener(area, "mousedown", () => {
|
|
1159
|
+
pauseCompatLoops(el)
|
|
1160
|
+
el[key]?.kill()
|
|
1161
|
+
el[key] = build(el, ctx)
|
|
1162
|
+
el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
|
|
1163
|
+
})
|
|
1164
|
+
addListener(area, "mouseleave", () => {
|
|
1165
|
+
resumeCompatLoops(el)
|
|
1166
|
+
})
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Dynamic arbitrary-property animation. Class shape:
|
|
1172
|
+
// css-<prop>-<from>-<to> -> ping-pong loop (yoyo)
|
|
1173
|
+
// spawn-css-<prop>-<from>-<to> -> play once on load/appear
|
|
1174
|
+
// hover-css-<prop>-<from>-<to> -> ping-pong while hovered
|
|
1175
|
+
// click-css-<prop>-<from>-<to> -> play once on mousedown
|
|
1176
|
+
// hover-css-<prop>-<to> -> simple hold while hovered (no from),
|
|
1177
|
+
// reverts to the original value on leave
|
|
1178
|
+
// click-css-<prop>-<to> -> simple one-shot to the value on mousedown
|
|
1179
|
+
// Values are numbers (decimals/negatives ok). hover/click wrap in a div
|
|
1180
|
+
// so the hit area stays fixed; `from` should equal the resting value.
|
|
1181
|
+
const CSS_VAL = "(-?\\d+(?:\\.\\d+)?|#[0-9a-fA-F]{3,8})"
|
|
1182
|
+
const CSS_ANIM_RE = new RegExp(`^((spawn|hover|click)-)?css-([a-zA-Z]+)-${CSS_VAL}-${CSS_VAL}$`)
|
|
1183
|
+
const CSS_SINGLE_RE = new RegExp(`^((hover|click)-)css-([a-zA-Z]+)-${CSS_VAL}$`)
|
|
1184
|
+
const parseCssVal = (s) => /^#/.test(s) ? s : Number(s)
|
|
1185
|
+
const parseCssAnim = (el) => {
|
|
1186
|
+
for (const c of el.classList) {
|
|
1187
|
+
let m = c.match(CSS_ANIM_RE)
|
|
1188
|
+
if (m) return { mode: m[2] || "loop", prop: m[3], from: parseCssVal(m[4]), to: parseCssVal(m[5]) }
|
|
1189
|
+
m = c.match(CSS_SINGLE_RE)
|
|
1190
|
+
if (m) return { mode: m[2], prop: m[3], to: parseCssVal(m[4]), single: true }
|
|
1191
|
+
}
|
|
1192
|
+
return null
|
|
1193
|
+
}
|
|
1194
|
+
const cssTweens = []
|
|
1195
|
+
const setupCssAnims = (el) => {
|
|
1196
|
+
if (isReduced(el)) return
|
|
1197
|
+
const anim = parseCssAnim(el)
|
|
1198
|
+
if (!anim) return
|
|
1199
|
+
const dur = readClassNumber(el, "time-", 1)
|
|
1200
|
+
const ease = getEase(el)
|
|
1201
|
+
const key = "_cssAnim"
|
|
1202
|
+
const loopVars = { [anim.prop]: anim.to, duration: dur, ease, yoyo: true, repeat: -1 }
|
|
1203
|
+
if (anim.mode === "loop") {
|
|
1204
|
+
el[key]?.kill()
|
|
1205
|
+
el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, loopVars)
|
|
1206
|
+
cssTweens.push(el[key])
|
|
1207
|
+
} else if (anim.mode === "spawn") {
|
|
1208
|
+
el[key]?.kill()
|
|
1209
|
+
el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { [anim.prop]: anim.to, duration: dur, ease })
|
|
1210
|
+
cssTweens.push(el[key])
|
|
1211
|
+
} else if (anim.mode === "hover") {
|
|
1212
|
+
const area = wrapTarget(el)
|
|
1213
|
+
if (anim.single) {
|
|
1214
|
+
// Single-value hover: tween to the target and HOLD for as long
|
|
1215
|
+
// as it's hovered; on leave, revert to the element's original
|
|
1216
|
+
// value (captured at setup, before any animation touched it).
|
|
1217
|
+
const original = gsap.getProperty(el, anim.prop)
|
|
1218
|
+
addListener(area, "mouseenter", () => {
|
|
1219
|
+
el[key]?.kill()
|
|
1220
|
+
el[key] = gsap.to(el, { [anim.prop]: anim.to, duration: dur, ease })
|
|
1221
|
+
cssTweens.push(el[key])
|
|
1222
|
+
})
|
|
1223
|
+
addListener(area, "mouseleave", () => {
|
|
1224
|
+
el[key]?.kill()
|
|
1225
|
+
el[key] = gsap.to(el, { [anim.prop]: original, duration: dur, ease })
|
|
1226
|
+
cssTweens.push(el[key])
|
|
1227
|
+
})
|
|
1228
|
+
} else {
|
|
1229
|
+
addListener(area, "mouseenter", () => {
|
|
1230
|
+
el[key]?.kill()
|
|
1231
|
+
el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { ...loopVars })
|
|
1232
|
+
cssTweens.push(el[key])
|
|
1233
|
+
})
|
|
1234
|
+
addListener(area, "mouseleave", () => {
|
|
1235
|
+
el[key]?.kill()
|
|
1236
|
+
el[key] = gsap.to(el, { [anim.prop]: anim.from, duration: dur, ease })
|
|
1237
|
+
cssTweens.push(el[key])
|
|
1238
|
+
})
|
|
1239
|
+
}
|
|
1240
|
+
} else if (anim.mode === "click") {
|
|
1241
|
+
const area = wrapTarget(el)
|
|
1242
|
+
if (anim.single) {
|
|
1243
|
+
// Single-value click: tween to the target while pressed, then
|
|
1244
|
+
// revert to the element's original value on mouseup.
|
|
1245
|
+
const original = gsap.getProperty(el, anim.prop)
|
|
1246
|
+
addListener(area, "mousedown", () => {
|
|
1247
|
+
el[key]?.kill()
|
|
1248
|
+
el[key] = gsap.to(el, { [anim.prop]: anim.to, duration: dur, ease })
|
|
1249
|
+
el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
|
|
1250
|
+
cssTweens.push(el[key])
|
|
1251
|
+
})
|
|
1252
|
+
addListener(area, "mouseup", () => {
|
|
1253
|
+
el[key]?.kill()
|
|
1254
|
+
el[key] = gsap.to(el, { [anim.prop]: original, duration: dur, ease })
|
|
1255
|
+
cssTweens.push(el[key])
|
|
1256
|
+
})
|
|
1257
|
+
} else {
|
|
1258
|
+
addListener(area, "mousedown", () => {
|
|
1259
|
+
el[key]?.kill()
|
|
1260
|
+
el[key] = gsap.fromTo(el, { [anim.prop]: anim.from }, { [anim.prop]: anim.to, duration: dur, ease, yoyo: true, repeat: 1 })
|
|
1261
|
+
el[key].eventCallback("onComplete", () => fireOnComplete(el, "click"))
|
|
1262
|
+
cssTweens.push(el[key])
|
|
1263
|
+
})
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Per-entry `setup` hook: a "special abilities" extension point. Any
|
|
1269
|
+
// config entry with a `setup(el, ctx)` function runs it once for every
|
|
1270
|
+
// matching element at wiring time — for behaviour that doesn't fit the
|
|
1271
|
+
// scroll/order/loop machinery. If it RETURNS a function, that's treated
|
|
1272
|
+
// as a teardown and invoked when the whole engine is torn down, so
|
|
1273
|
+
// side-effects (listeners, observers, timers) can be cleaned up.
|
|
1274
|
+
const setupTeardowns = []
|
|
1275
|
+
const runSetup = (el) => {
|
|
1276
|
+
if (isReduced(el)) return
|
|
1277
|
+
const ctx = readLoopCtx(el)
|
|
1278
|
+
animAll.forEach((a) => {
|
|
1279
|
+
if (a.setup && el.matches?.(a.sel)) {
|
|
1280
|
+
const teardown = a.setup(el, ctx)
|
|
1281
|
+
if (typeof teardown === "function") setupTeardowns.push(teardown)
|
|
1282
|
+
}
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Bind click + loop animations to every element present at load, tagging
|
|
1287
|
+
// them so the MutationObserver below never double-binds a dynamic one.
|
|
1288
|
+
gsap.utils.toArray("body *").forEach((el) => {
|
|
1289
|
+
if (el.dataset?.gsapSetup) return
|
|
1290
|
+
el.dataset.gsapSetup = "1"
|
|
1291
|
+
setupClicks(el)
|
|
1292
|
+
setupLoops(el)
|
|
1293
|
+
setupHoverClick(el)
|
|
1294
|
+
setupCssAnims(el)
|
|
1295
|
+
runSetup(el)
|
|
1296
|
+
})
|
|
1297
|
+
applyMagnet()
|
|
1298
|
+
|
|
1299
|
+
// Layout-change morphs. Deferred to a single rAF past the mutation so
|
|
1300
|
+
// layout has settled and the morph reads final positions. Multiple
|
|
1301
|
+
// mutations from one commit coalesce; the re-entrancy `flipping` set
|
|
1302
|
+
// stops the observer<->morph feedback loop.
|
|
1303
|
+
let flipRoots = new Set()
|
|
1304
|
+
let flipPendingRaf = null
|
|
1305
|
+
const flipObserver = new MutationObserver((mutations) => {
|
|
1306
|
+
for (const mutation of mutations) {
|
|
1307
|
+
if (mutation.type !== "childList") continue
|
|
1308
|
+
const target = mutation.target
|
|
1309
|
+
if (target.nodeType !== 1) continue
|
|
1310
|
+
flipRoots.add(target)
|
|
1311
|
+
}
|
|
1312
|
+
if (!flipPendingRaf) {
|
|
1313
|
+
flipPendingRaf = requestAnimationFrame(() => {
|
|
1314
|
+
flipPendingRaf = null
|
|
1315
|
+
if (!flipRoots.size) return
|
|
1316
|
+
// A single frame can register several scopes for the SAME
|
|
1317
|
+
// element: removing a `.leave` node re-attaches a fixed ghost
|
|
1318
|
+
// to <body>, which adds `body` as a second scope alongside the
|
|
1319
|
+
// node's former parent. Running animateFlip per scope re-enters
|
|
1320
|
+
// playFlip on the same element, killing the in-flight tween and
|
|
1321
|
+
// clearing its transform — snapping the element into place.
|
|
1322
|
+
// Dedupe across scopes so each element flips exactly once.
|
|
1323
|
+
const toFlip = new Set()
|
|
1324
|
+
flipRoots.forEach((scope) => {
|
|
1325
|
+
if (!scope) return
|
|
1326
|
+
gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
|
|
1327
|
+
.forEach((el) => { if (el.isConnected) toFlip.add(el) })
|
|
1328
|
+
})
|
|
1329
|
+
toFlip.forEach(playFlip)
|
|
1330
|
+
// Refresh baselines for any .flip that settled this frame.
|
|
1331
|
+
gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
|
|
1332
|
+
flipRoots = new Set()
|
|
1333
|
+
})
|
|
1334
|
+
}
|
|
1335
|
+
})
|
|
1336
|
+
flipObserver.observe(document.body, { childList: true, subtree: true })
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
const animateAppear = (el) => {
|
|
1341
|
+
if (!el.classList.contains("appear") || el._appeared) return
|
|
1342
|
+
// A `.scroll`/`.scroll-progress` element is owned by its ScrollTrigger
|
|
1343
|
+
// (see setupScroll); `.appear` must not also fire, or it plays on mount
|
|
1344
|
+
// AND again on scroll-enter. Text elements are the exception: their
|
|
1345
|
+
// `.scroll` triggers are wired once at init, so a re-added (reset) text
|
|
1346
|
+
// element has no trigger to conflict with and must animate via `.appear`.
|
|
1347
|
+
if (!isTextElement(el) && (el.classList.contains("scroll") || el.classList.contains("scroll-progress"))) return
|
|
1348
|
+
if (isReduced(el)) return
|
|
1349
|
+
el._appeared = true
|
|
1350
|
+
const { delay, duration, ease } = readTiming(el)
|
|
1351
|
+
|
|
1352
|
+
const config = findSpawn(el)
|
|
1353
|
+
if (isTextElement(el)) {
|
|
1354
|
+
el._spawnTween = playText(el, config.from, delay, duration, ease)
|
|
1355
|
+
return
|
|
1356
|
+
}
|
|
1357
|
+
if (!config) return el._spawnTween = SpawnV(el, delay, -defaults.spawnOffset, duration, ease)
|
|
1358
|
+
|
|
1359
|
+
if (config.typewriter) {
|
|
1360
|
+
const easeClass = [...el.classList].find(c => c.startsWith("ease-"))
|
|
1361
|
+
const elEase = easeClass ? easeClass.split("-")[1] : "none"
|
|
1362
|
+
el._spawnTween = config.typewriterSplit
|
|
1363
|
+
? playTypewriterSplit(el, delay, duration, elEase)
|
|
1364
|
+
: config.play(el, delay, duration, elEase)
|
|
1365
|
+
} else {
|
|
1366
|
+
el._spawnTween = config.play(el, delay, duration, ease)
|
|
1367
|
+
el._spawnTween.eventCallback("onComplete", () => {
|
|
1368
|
+
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
1369
|
+
if (el.classList.contains("flip")) captureFlip(el)
|
|
1370
|
+
fireOnComplete(el, "spawn")
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const appearObserver = new MutationObserver((mutations) => {
|
|
1376
|
+
mutations.forEach((mutation) => {
|
|
1377
|
+
mutation.addedNodes.forEach((node) => {
|
|
1378
|
+
if (node.nodeType !== 1) return
|
|
1379
|
+
const els = node.querySelectorAll?.("*") ? [node, ...node.querySelectorAll("*")] : [node]
|
|
1380
|
+
let pinned = false
|
|
1381
|
+
els.forEach((el) => {
|
|
1382
|
+
// `.appear` is the opt-in gate for dynamically-added
|
|
1383
|
+
// elements: without it a newly inserted node is ignored.
|
|
1384
|
+
if (!el.classList?.contains("appear")) return
|
|
1385
|
+
animateAppear(el)
|
|
1386
|
+
setupScroll(el)
|
|
1387
|
+
setupScrollDriven(el)
|
|
1388
|
+
if (el.dataset?.gsapSetup) return
|
|
1389
|
+
el.dataset.gsapSetup = "1"
|
|
1390
|
+
setupClicks(el)
|
|
1391
|
+
setupLoops(el)
|
|
1392
|
+
setupHoverClick(el)
|
|
1393
|
+
setupCssAnims(el)
|
|
1394
|
+
runSetup(el)
|
|
1395
|
+
const wasPinned = el.dataset.gsapPinned
|
|
1396
|
+
setupPin(el)
|
|
1397
|
+
if (!wasPinned && el.dataset.gsapPinned) pinned = true
|
|
1398
|
+
})
|
|
1399
|
+
applyMagnet()
|
|
1400
|
+
// A newly added pin changes layout; refresh so its spacer is
|
|
1401
|
+
// accounted for before the next scroll calc.
|
|
1402
|
+
if (pinned) ScrollTrigger.refresh()
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1405
|
+
})
|
|
1406
|
+
appearObserver.observe(document.body, { childList: true, subtree: true })
|
|
1407
|
+
|
|
1408
|
+
// Capture any .leave elements already present so they can exit later
|
|
1409
|
+
gsap.utils.toArray(".leave").forEach(captureLeave)
|
|
1410
|
+
|
|
1411
|
+
const leaveObserver = new MutationObserver((mutations) => {
|
|
1412
|
+
mutations.forEach((mutation) => {
|
|
1413
|
+
if (mutation.type !== "childList") return
|
|
1414
|
+
mutation.addedNodes.forEach((n) => collectLeave(n).forEach(captureLeave))
|
|
1415
|
+
mutation.removedNodes.forEach((n) => collectLeave(n).forEach(playLeave))
|
|
1416
|
+
})
|
|
1417
|
+
})
|
|
1418
|
+
leaveObserver.observe(document.body, { childList: true, subtree: true })
|
|
1419
|
+
|
|
1420
|
+
// Keep the captured position fresh (throttled to one pass per frame)
|
|
1421
|
+
let positionTick = false
|
|
1422
|
+
const refreshLeavePositions = () => {
|
|
1423
|
+
if (positionTick) return
|
|
1424
|
+
positionTick = true
|
|
1425
|
+
requestAnimationFrame(() => {
|
|
1426
|
+
gsap.utils.toArray(".leave").forEach((el) => {
|
|
1427
|
+
const s = leaveStates.get(el)
|
|
1428
|
+
if (s) s.rect = el.getBoundingClientRect()
|
|
1429
|
+
})
|
|
1430
|
+
positionTick = false
|
|
1431
|
+
})
|
|
1432
|
+
}
|
|
1433
|
+
window.addEventListener("scroll", refreshLeavePositions, { passive: true })
|
|
1434
|
+
window.addEventListener("resize", refreshLeavePositions, { passive: true })
|
|
1435
|
+
|
|
1436
|
+
return () => {
|
|
1437
|
+
appearObserver.disconnect()
|
|
1438
|
+
leaveObserver.disconnect()
|
|
1439
|
+
flipObserver.disconnect()
|
|
1440
|
+
window.removeEventListener("scroll", refreshLeavePositions)
|
|
1441
|
+
window.removeEventListener("resize", refreshLeavePositions)
|
|
1442
|
+
window.removeEventListener("load", ScrollTrigger.refresh)
|
|
1443
|
+
clearTimeout(refreshTimer)
|
|
1444
|
+
scrollTriggers.forEach((t) => {
|
|
1445
|
+
t.kill()
|
|
1446
|
+
t.trigger._scrollTween?.kill()
|
|
1447
|
+
delete t.trigger._scrollTween
|
|
1448
|
+
})
|
|
1449
|
+
ScrollTrigger.refresh()
|
|
1450
|
+
registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
1451
|
+
magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
1452
|
+
magnetQuery?.removeEventListener("change", applyMagnet)
|
|
1453
|
+
loopEls.forEach(({ el, key }) => el[key]?.kill())
|
|
1454
|
+
cssTweens.forEach((t) => t?.kill())
|
|
1455
|
+
cssTweens.length = 0
|
|
1456
|
+
gsap.utils.toArray(".typewriter").forEach(el => el.typewriter?.kill())
|
|
1457
|
+
textSplits.forEach((s) => s.revert())
|
|
1458
|
+
textSplits.length = 0
|
|
1459
|
+
onCompleteTweens.forEach((t) => t?.kill())
|
|
1460
|
+
onCompleteTweens.length = 0
|
|
1461
|
+
setupTeardowns.forEach((fn) => { try { fn() } catch { /* ignore */ } })
|
|
1462
|
+
setupTeardowns.length = 0
|
|
1463
|
+
}
|
|
1464
|
+
}
|