gclass-anims 1.0.0-beta.2 → 1.0.0-beta.21
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 +199 -7
- package/Animations.js +324 -31
- package/CHANGELOG.md +53 -0
- package/Config.js +43 -5
- package/LICENSE +2 -2
- package/Listeners.js +450 -95
- package/README.md +4 -4
- package/index.d.ts +73 -4
- package/index.js +1 -1
- package/package.json +5 -5
package/Listeners.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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'
|
|
2
|
+
import { SpawnV, verticalmove, expandmove, magnet, magnet3d, reset, typewriter, countTargetVars, stashText, scrambleVars } from './Animations.js'
|
|
3
|
+
import { customAnims } from './CustomAnims.js'
|
|
4
|
+
import { defaults, normalize } from './Config.js'
|
|
5
5
|
import { TextPlugin, ScrollTrigger, SplitText } from 'gsap/all'
|
|
6
6
|
|
|
7
7
|
// Prefix for SplitText text-reveal classes. Distinct from the raw `text-*`
|
|
@@ -30,9 +30,90 @@ export function resolveHandler(name) {
|
|
|
30
30
|
return null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// --- .randomize-<prop>-[min]-[max] randomization -----------------------------
|
|
34
|
+
// Adds function-based values (e.g. rotation: () => gsap.utils.random(-90, 90))
|
|
35
|
+
// to the FROM state of spawn tweens, so each element enters from its own pose.
|
|
36
|
+
// GSAP evaluates function values on tween BUILD, and the engine always kills +
|
|
37
|
+
// rebuilds tweens on replay (.scroll re-enter, .appear re-insert), so every
|
|
38
|
+
// replay re-rolls automatically - no invalidate/repeatRefresh bookkeeping.
|
|
39
|
+
//
|
|
40
|
+
// The class is the guard: hasRandom() is a cheap className probe and nothing
|
|
41
|
+
// below allocates or patches anything unless it passes, so elements without
|
|
42
|
+
// .randomize-* run exactly the pre-feature code path.
|
|
43
|
+
//
|
|
44
|
+
// Notes:
|
|
45
|
+
// • randomize OVERRIDES the base spawn's value for that prop (a
|
|
46
|
+
// randomize-rotation on .spawn-cw replaces the spin).
|
|
47
|
+
// • A matching END value is derived per prop (scale* -> 1, opacity -> 1,
|
|
48
|
+
// transforms -> 0, ...) unless the config's own `to` already animates the
|
|
49
|
+
// prop, so a randomized prop on a spawn that doesn't natively use it still
|
|
50
|
+
// tweens back to rest instead of sticking at the rolled value.
|
|
51
|
+
// • Timeline-mediated builders (count/scramble/draw-split) construct via
|
|
52
|
+
// Timeline methods, not the exported gsap.fromTo, so they sit outside the
|
|
53
|
+
// injection - irrelevant in practice, since their props aren't randomize
|
|
54
|
+
// targets.
|
|
55
|
+
const RANDOMIZE_RE = /^randomize-(\w+)-\[(-?[\d.]+)\]-\[(-?[\d.]+)\]$/
|
|
56
|
+
const hasRandom = (el) => typeof el.className === "string" && /\brandomize-/.test(el.className)
|
|
57
|
+
const randomVars = (el) => {
|
|
58
|
+
const rnd = {}
|
|
59
|
+
for (const c of el.classList) {
|
|
60
|
+
const m = c.match(RANDOMIZE_RE)
|
|
61
|
+
if (m) rnd[m[1]] = () => gsap.utils.random(Number(m[2]), Number(m[3]))
|
|
62
|
+
}
|
|
63
|
+
return rnd
|
|
64
|
+
}
|
|
65
|
+
// Resting end-state for a randomized prop (mirrors computeTo's rules).
|
|
66
|
+
const randomEnds = (keys) => Object.fromEntries(keys.map((k) => [
|
|
67
|
+
k,
|
|
68
|
+
k === "opacity" ? 1
|
|
69
|
+
: k === "filter" ? "blur(0px)"
|
|
70
|
+
: k === "clipPath" ? "inset(0% 0% 0% 0%)"
|
|
71
|
+
: k === "drawSVG" ? "100%"
|
|
72
|
+
: k.startsWith("scale") ? 1
|
|
73
|
+
: 0,
|
|
74
|
+
]))
|
|
75
|
+
// Runs a spawn config's play(), injecting the element's .randomize-* function
|
|
76
|
+
// values into every from-state the config builds. Config helpers hardcode
|
|
77
|
+
// their from literals inside Animations.js, so the injection hooks the only
|
|
78
|
+
// interception point available: play() builds its tweens SYNCHRONOUSLY, which
|
|
79
|
+
// makes a scoped gsap.fromTo swap safe - patch, let the config construct,
|
|
80
|
+
// restore in finally{}. Without .randomize-* this is a bare passthrough.
|
|
81
|
+
const invokePlay = (config, el, delay, dur, ease) => {
|
|
82
|
+
if (!hasRandom(el)) return config.play(el, delay, dur, ease)
|
|
83
|
+
const rnd = randomVars(el)
|
|
84
|
+
const keys = Object.keys(rnd)
|
|
85
|
+
if (!keys.length) return config.play(el, delay, dur, ease)
|
|
86
|
+
const ends = randomEnds(keys)
|
|
87
|
+
const origFromTo = gsap.fromTo
|
|
88
|
+
gsap.fromTo = (t, from, to) => origFromTo(t, { ...from, ...rnd }, { ...ends, ...to })
|
|
89
|
+
try {
|
|
90
|
+
return config.play(el, delay, dur, ease)
|
|
91
|
+
} finally {
|
|
92
|
+
gsap.fromTo = origFromTo
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function initListeners(root = document, throttlePerFrame) {
|
|
97
|
+
// overload: initListeners(1) -> throttle only, root defaults to document
|
|
98
|
+
if (typeof root === 'number') {
|
|
99
|
+
throttlePerFrame = root
|
|
100
|
+
root = document
|
|
101
|
+
}
|
|
102
|
+
throttlePerFrame = Number(throttlePerFrame) || 0 // 0 = no throttling (default)
|
|
34
103
|
gsap.registerPlugin(TextPlugin, ScrollTrigger, SplitText)
|
|
35
104
|
|
|
105
|
+
// helper to scope queries to root (for boot screen: only boot-up subtree animates during boot)
|
|
106
|
+
const qAll = (sel) => {
|
|
107
|
+
if (root === document || root === document.documentElement || root === document.body) return gsap.utils.toArray(sel)
|
|
108
|
+
if (sel === "body *") return gsap.utils.toArray(root.querySelectorAll("*"))
|
|
109
|
+
try {
|
|
110
|
+
const els = [...(root.querySelectorAll ? root.querySelectorAll(sel) : [])]
|
|
111
|
+
if (root.matches?.(sel)) els.unshift(root)
|
|
112
|
+
// handle comma selectors where root itself may match one part
|
|
113
|
+
return els
|
|
114
|
+
} catch { return gsap.utils.toArray(sel) }
|
|
115
|
+
}
|
|
116
|
+
|
|
36
117
|
const registeredListeners = []
|
|
37
118
|
const onCompleteTweens = []
|
|
38
119
|
const addListener = (el, type, fn) => {
|
|
@@ -57,19 +138,37 @@ export default function initListeners() {
|
|
|
57
138
|
|
|
58
139
|
// `.preserve` keeps an already-rendered element (e.g. one that persists
|
|
59
140
|
// in a shared layout across route changes) from being re-animated when
|
|
60
|
-
// the Listeners setup re-runs.
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
141
|
+
// the Listeners setup re-runs. It applies to the element AND its
|
|
142
|
+
// children: any preserved ancestor also suppresses animation on this
|
|
143
|
+
// node. Two subtleties make this behave correctly:
|
|
144
|
+
// • markPreserved() tags the FULL preserve-ancestor chain, so a bare
|
|
145
|
+
// `.preserve` container without its own spawn class (a site header,
|
|
146
|
+
// say) still gets tagged when one of its children animates.
|
|
147
|
+
// • Suppression requires the element ITSELF to carry data-gsap-wired
|
|
148
|
+
// (set when a previous run animated it). Freshly mounted content
|
|
149
|
+
// under a preserved root therefore still plays its entrance - only
|
|
150
|
+
// DOM that survived from an earlier run stays frozen.
|
|
66
151
|
const isPreserved = (el) => {
|
|
152
|
+
if (!el.dataset.gsapWired) return false
|
|
153
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
154
|
+
if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
const markPreserved = (el) => {
|
|
159
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
160
|
+
if (node.classList.contains("preserve")) node.dataset.gsapPreserved = "1"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// True when el sits inside a `.preserve` root that a run has already
|
|
164
|
+
// tagged. Such regions are frozen by design: later runs skip them, so
|
|
165
|
+
// teardown must leave their visual state untouched too.
|
|
166
|
+
const underPreservedRoot = (el) => {
|
|
67
167
|
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
68
168
|
if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
|
|
69
169
|
}
|
|
70
170
|
return false
|
|
71
171
|
}
|
|
72
|
-
const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
|
|
73
172
|
|
|
74
173
|
// `spawnConfigs` is derived from the config in Config.js (see top of
|
|
75
174
|
// file). Adding/removing an entry there automatically re-wires every
|
|
@@ -126,7 +225,7 @@ export default function initListeners() {
|
|
|
126
225
|
}
|
|
127
226
|
|
|
128
227
|
// Capture the element's first (resting) bounds. A later layout change
|
|
129
|
-
// morphs from this snapshot to the live position
|
|
228
|
+
// morphs from this snapshot to the live position - a vanilla FLIP.
|
|
130
229
|
const captureFlip = (node) => {
|
|
131
230
|
if (!node.classList?.contains("flip")) return
|
|
132
231
|
const config = findSpawn(node)
|
|
@@ -277,7 +376,7 @@ export default function initListeners() {
|
|
|
277
376
|
spawnConfigs.map(({ sel }) => "." + TEXT_PREFIX + sel.slice(1)).join(",")
|
|
278
377
|
|
|
279
378
|
const getOrderDelay = (el, priority) => {
|
|
280
|
-
const samepri =
|
|
379
|
+
const samepri = qAll(orderSelector())
|
|
281
380
|
.filter((e) => {
|
|
282
381
|
if (!e.classList.contains("order")) return false
|
|
283
382
|
const match = [...e.classList].find(p => p.startsWith("priority-"))
|
|
@@ -317,7 +416,7 @@ export default function initListeners() {
|
|
|
317
416
|
const delay = readClassNumber(el, "complete-delay-", 0)
|
|
318
417
|
const dur = readClassNumber(el, "complete-time-", 1)
|
|
319
418
|
const tween = entry.play
|
|
320
|
-
? entry
|
|
419
|
+
? invokePlay(entry, el, delay, dur, getEase(el))
|
|
321
420
|
: entry.build(el, readLoopCtx(el))
|
|
322
421
|
if (!tween) return
|
|
323
422
|
if (!entry.play) tween.delay(delay)
|
|
@@ -337,12 +436,16 @@ export default function initListeners() {
|
|
|
337
436
|
}
|
|
338
437
|
|
|
339
438
|
const scrollTriggers = []
|
|
439
|
+
// Derives the fully-visible twin of a spawn's `from` state. drawSVG is
|
|
440
|
+
// inverted relative to the numeric default: its HIDDEN value is 0%, so
|
|
441
|
+
// the drawn end state is the full stroke.
|
|
340
442
|
const computeTo = (from) => {
|
|
341
443
|
const to = {}
|
|
342
|
-
for (const [key] of Object.entries(from)) {
|
|
444
|
+
for (const [key] of Object.entries(from || {})) {
|
|
343
445
|
if (key === "opacity") to[key] = 1
|
|
344
446
|
else if (key === "filter") to[key] = "blur(0px)"
|
|
345
447
|
else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
|
|
448
|
+
else if (key === "drawSVG") to[key] = "100%"
|
|
346
449
|
else to[key] = key.startsWith("scale") ? 1 : 0
|
|
347
450
|
}
|
|
348
451
|
return to
|
|
@@ -381,8 +484,8 @@ export default function initListeners() {
|
|
|
381
484
|
// whitespace) must not become tween targets; they are kept as inert text
|
|
382
485
|
// nodes so the natural gap is preserved and never animated.
|
|
383
486
|
const RTL_JOIN_BREAK = /[\u200C\u200D\s]/
|
|
384
|
-
// A real Arabic/Persian joining letter. Any other visible character
|
|
385
|
-
// Latin, digits, punctuation (، ؟ ؛ . ! …)
|
|
487
|
+
// A real Arabic/Persian joining letter. Any other visible character -
|
|
488
|
+
// Latin, digits, punctuation (، ؟ ؛ . ! …) - is NOT a joining letter: it
|
|
386
489
|
// must not give the preceding letter a trailing Zero-Width-Joiner (which
|
|
387
490
|
// would render it in its connecting form instead of its correct END form),
|
|
388
491
|
// but it should still be split into its own span so it animates too.
|
|
@@ -471,20 +574,80 @@ export default function initListeners() {
|
|
|
471
574
|
},
|
|
472
575
|
})
|
|
473
576
|
|
|
577
|
+
// Flex targets can't be split directly: the split parts would become
|
|
578
|
+
// flex ITEMS, so justify-content/gap would apply per letter, whitespace-
|
|
579
|
+
// only text nodes stop rendering (spaces vanish) and line grouping reads
|
|
580
|
+
// garbage. Loose text runs are therefore pre-wrapped in plain block
|
|
581
|
+
// divs - real boxes with normal inline flow inside - and the wrappers
|
|
582
|
+
// are undone whenever the split reverts. Non-text children (icons etc.)
|
|
583
|
+
// stay put, keeping their own flex-item status and the gaps around them.
|
|
584
|
+
const wrapFlexTarget = (el) => {
|
|
585
|
+
if (!/^(inline-)?flex$/.test(getComputedStyle(el).display)) return null
|
|
586
|
+
const wrappers = []
|
|
587
|
+
let run = []
|
|
588
|
+
const flush = () => {
|
|
589
|
+
if (!run.length) return
|
|
590
|
+
const w = document.createElement("div")
|
|
591
|
+
el.insertBefore(w, run[0])
|
|
592
|
+
run.forEach((n) => w.appendChild(n))
|
|
593
|
+
wrappers.push(w)
|
|
594
|
+
run = []
|
|
595
|
+
}
|
|
596
|
+
;[...el.childNodes].forEach((n) => {
|
|
597
|
+
if (n.nodeType === 3 && n.textContent.trim()) run.push(n)
|
|
598
|
+
else flush()
|
|
599
|
+
})
|
|
600
|
+
flush()
|
|
601
|
+
if (!wrappers.length) return null
|
|
602
|
+
return () => wrappers.forEach((w) => {
|
|
603
|
+
while (w.firstChild) el.insertBefore(w.firstChild, w)
|
|
604
|
+
w.remove()
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Revert a SplitText instance AND undo any flex pre-wrapping made for
|
|
609
|
+
// it. Every revert path funnels through here so wrappers can't leak.
|
|
610
|
+
const revertSplitInstance = (s) => {
|
|
611
|
+
s.revert()
|
|
612
|
+
s._gcFlexUnwrap?.()
|
|
613
|
+
}
|
|
614
|
+
|
|
474
615
|
const getSplit = (el, gran) => {
|
|
475
616
|
let s = splitCache.get(el)
|
|
476
617
|
const rtlChars = gran === "chars" && isRTLText(el)
|
|
477
618
|
const key = rtlChars ? "rtl-chars" : gran
|
|
478
619
|
if (!s || s.granularity !== key) {
|
|
479
|
-
s
|
|
620
|
+
if (s) revertSplitInstance(s)
|
|
621
|
+
const hasN = el.textContent.includes('\n')
|
|
622
|
+
// only set pre-wrap if element actually contains a newline, so \n renders without breaking other SplitText (flex/lines)
|
|
623
|
+
if (hasN && el.style) el.style.whiteSpace = 'pre-wrap'
|
|
480
624
|
s = rtlChars
|
|
481
625
|
? getRTLCharSplit(el)
|
|
482
626
|
: new SplitText(el, {
|
|
483
627
|
type: gran,
|
|
628
|
+
reduceWhiteSpace: hasN ? false : undefined,
|
|
484
629
|
linesClass: "gsap-line",
|
|
485
630
|
wordsClass: "gsap-word",
|
|
486
631
|
charsClass: "gsap-char",
|
|
632
|
+
onSplit(self) {
|
|
633
|
+
// make \n actually break line DURING split (not just after revert)
|
|
634
|
+
for (const c of self.chars || []) {
|
|
635
|
+
if (c.textContent === '\n') {
|
|
636
|
+
c.textContent = ''
|
|
637
|
+
c.style.display = 'block'
|
|
638
|
+
c.style.width = '100%'
|
|
639
|
+
c.style.height = '0'
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// also handle words split where \n is between words
|
|
643
|
+
for (const w of self.words || []) {
|
|
644
|
+
if (w.textContent.includes('\n')) {
|
|
645
|
+
w.style.whiteSpace = 'pre-wrap'
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
487
649
|
})
|
|
650
|
+
s._gcFlexUnwrap = wrapFlexTarget(el)
|
|
488
651
|
s.granularity = key
|
|
489
652
|
splitCache.set(el, s)
|
|
490
653
|
textSplits.push(s)
|
|
@@ -498,8 +661,10 @@ export default function initListeners() {
|
|
|
498
661
|
// does this automatically for animated elements; Firefox is
|
|
499
662
|
// conservative and otherwise repaints these inline-block parts on the
|
|
500
663
|
// main thread every frame, which is what makes split-text lag there.
|
|
664
|
+
// A: keep inline flow so <strong> etc stays inline during split
|
|
501
665
|
for (let i = 0; i < parts.length; i++) {
|
|
502
666
|
parts[i].style.willChange = "transform, opacity"
|
|
667
|
+
if (gran === "chars" || gran === "words") parts[i].style.display = "inline-block"
|
|
503
668
|
}
|
|
504
669
|
return parts
|
|
505
670
|
}
|
|
@@ -514,13 +679,13 @@ export default function initListeners() {
|
|
|
514
679
|
splitCache.delete(el)
|
|
515
680
|
const idx = textSplits.indexOf(s)
|
|
516
681
|
if (idx !== -1) textSplits.splice(idx, 1)
|
|
517
|
-
s
|
|
682
|
+
revertSplitInstance(s)
|
|
518
683
|
}
|
|
519
684
|
// The whole point of `.time-X` on a `.spawn-text-X` element is that the
|
|
520
685
|
// FULL reveal (first part starting to last part finishing) takes X
|
|
521
686
|
// seconds, no matter how many chars/words/lines it got split into.
|
|
522
687
|
// GSAP staggered tweens actually finish at `duration + stagger * (n-1)`,
|
|
523
|
-
// so `dur` can't be handed straight to `duration` as before
|
|
688
|
+
// so `dur` can't be handed straight to `duration` as before - instead we
|
|
524
689
|
// solve for `duration`/`stagger` together so they always sum to `dur`.
|
|
525
690
|
// An explicit `.stagger-N` class is honored as-is; only `duration` is
|
|
526
691
|
// back-solved in that case so the last part still lands on `dur`.
|
|
@@ -545,8 +710,17 @@ export default function initListeners() {
|
|
|
545
710
|
duration = Math.min(dur, Math.max(dur / 3, defaults.minTextPartDuration))
|
|
546
711
|
stagger = parts.length > 1 ? (dur - duration) / (parts.length - 1) : 0
|
|
547
712
|
}
|
|
548
|
-
|
|
549
|
-
|
|
713
|
+
// .randomize-*: per-PART roll (function values evaluate once per
|
|
714
|
+
// target, so every char/word/line lands on its own pose); end
|
|
715
|
+
// states recompute from the merged keys so new props tween to rest.
|
|
716
|
+
let rnd = null
|
|
717
|
+
if (hasRandom(el)) {
|
|
718
|
+
rnd = randomVars(el)
|
|
719
|
+
if (!Object.keys(rnd).length) rnd = null
|
|
720
|
+
}
|
|
721
|
+
const effFrom = { ...from, ...rnd }
|
|
722
|
+
return gsap.fromTo(parts, effFrom, {
|
|
723
|
+
...computeTo(effFrom), ease, duration, delay, stagger,
|
|
550
724
|
onComplete: () => {
|
|
551
725
|
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
552
726
|
revertSplit(el)
|
|
@@ -606,9 +780,9 @@ export default function initListeners() {
|
|
|
606
780
|
// created before any scroll/scroll-progress trigger measures its position.
|
|
607
781
|
// Setting them up here (before the trigger pass below) keeps offsets correct
|
|
608
782
|
// and lets the single ScrollTrigger.refresh() at the end reconcile layout.
|
|
609
|
-
|
|
783
|
+
qAll(".pin").forEach(setupPin)
|
|
610
784
|
|
|
611
|
-
// Scroll-driven extras
|
|
785
|
+
// Scroll-driven extras - class-driven ScrollTrigger behaviours that don't
|
|
612
786
|
// fit the spawn/loop machinery (no `play`/`build`), handled like `.pin`:
|
|
613
787
|
// .parallax-N - element drifts relative to scroll. N is a
|
|
614
788
|
// speed factor: 1 = static, <1 = slower,
|
|
@@ -699,7 +873,17 @@ export default function initListeners() {
|
|
|
699
873
|
scrollTriggers.push(t.scrollTrigger)
|
|
700
874
|
}
|
|
701
875
|
}
|
|
702
|
-
|
|
876
|
+
qAll('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
|
|
877
|
+
|
|
878
|
+
// Scroller resolution: a `.scroll`/`.scroll-progress` element inside a
|
|
879
|
+
// `.scroll-frame` container binds its trigger to THAT box instead of the
|
|
880
|
+
// window. Innermost frame wins (`closest` walks up); no frame ancestor
|
|
881
|
+
// keeps the default window scroller. The frame must be a real scroller
|
|
882
|
+
// (fixed height + overflow auto/scroll) or its triggers never fire.
|
|
883
|
+
const getScroller = (el) => {
|
|
884
|
+
const frame = el.closest?.(".scroll-frame")
|
|
885
|
+
return frame && frame !== el ? frame : undefined
|
|
886
|
+
}
|
|
703
887
|
|
|
704
888
|
// `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
|
|
705
889
|
// Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
|
|
@@ -721,11 +905,12 @@ export default function initListeners() {
|
|
|
721
905
|
: getEase(el)
|
|
722
906
|
|
|
723
907
|
const to = {}
|
|
724
|
-
for (const [key] of Object.entries(from)) {
|
|
908
|
+
for (const [key] of Object.entries(from || {})) {
|
|
725
909
|
if (key === "opacity") to[key] = 1
|
|
726
910
|
else if (key === "filter") to[key] = "blur(0px)"
|
|
727
|
-
else if (key === "text") to[key] = el
|
|
911
|
+
else if (key === "text") to[key] = stashText(el)
|
|
728
912
|
else if (key === "clipPath") to[key] = "inset(0% 0% 0% 0%)"
|
|
913
|
+
else if (key === "drawSVG") to[key] = "100%"
|
|
729
914
|
else to[key] = key.startsWith("scale") ? 1 : 0
|
|
730
915
|
}
|
|
731
916
|
|
|
@@ -736,10 +921,16 @@ export default function initListeners() {
|
|
|
736
921
|
// GSAP's ScrollTrigger ignores a `reversed` config; swap from/to so
|
|
737
922
|
// the scrub maps in the opposite direction instead.
|
|
738
923
|
const reverse = el.classList.contains("progress-reverse")
|
|
924
|
+
// .randomize-* applies to the HIDDEN start only (non-reverse):
|
|
925
|
+
// a scrub's resting end must stay deterministic or the element
|
|
926
|
+
// would sit permanently off-pose after being scrolled through.
|
|
927
|
+
const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
|
|
928
|
+
const rndEnds = rnd ? randomEnds(Object.keys(rnd)) : null
|
|
739
929
|
|
|
740
930
|
const tl = gsap.timeline({
|
|
741
931
|
scrollTrigger: {
|
|
742
932
|
trigger: el,
|
|
933
|
+
scroller: getScroller(el),
|
|
743
934
|
start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
|
|
744
935
|
end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
|
|
745
936
|
scrub: true,
|
|
@@ -751,11 +942,50 @@ export default function initListeners() {
|
|
|
751
942
|
const { start, end, decimals } = countTargetVars(el)
|
|
752
943
|
const obj = { n: reverse ? end : start }
|
|
753
944
|
tl.fromTo(obj, { n: reverse ? end : start }, { n: reverse ? start : end, ease, onUpdate: () => { el.textContent = obj.n.toFixed(decimals) } }, 0)
|
|
945
|
+
} else if (config.scramble) {
|
|
946
|
+
// Scramble driven by scroll progress: each top-level text
|
|
947
|
+
// run scrubs through garbage states (.progress-reverse
|
|
948
|
+
// swaps the endpoints). The generic from/to scrub can't
|
|
949
|
+
// express this (it only knows the numeric `from` keys),
|
|
950
|
+
// which is why the entry carries the scramble flag.
|
|
951
|
+
// .scramble-all has no empty state: its tween runs between
|
|
952
|
+
// identical endpoints so the scrub just drives the sweep.
|
|
953
|
+
// Same ease rule as play(): linear unless .ease-* present.
|
|
954
|
+
const { segs, chars, speed, revealDelay, rtl } = scrambleVars(el)
|
|
955
|
+
const all = el.classList.contains("scramble-all")
|
|
956
|
+
segs.forEach(({ t, text }) => {
|
|
957
|
+
if (all) {
|
|
958
|
+
tl.to(t,
|
|
959
|
+
{ scrambleText: { text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
|
|
960
|
+
} else {
|
|
961
|
+
tl.fromTo(t,
|
|
962
|
+
{ scrambleText: { text: reverse ? text : "", chars } },
|
|
963
|
+
{ scrambleText: { text: reverse ? "" : text, chars, speed, revealDelay, rightToLeft: rtl }, ease }, 0)
|
|
964
|
+
}
|
|
965
|
+
})
|
|
754
966
|
} else if (typewriterSplit) {
|
|
755
967
|
const parts = getParts(el, getGranularity(el))
|
|
756
968
|
if (parts.length) tl.fromTo(parts, { opacity: reverse ? 1 : 0 }, { opacity: reverse ? 0 : 1, ease })
|
|
969
|
+
} else if (el.classList.contains("fill-svg") && (el.classList.contains("draw") || el.classList.contains("draw-split"))) {
|
|
970
|
+
// fill-svg modifier for draw: stroke first, then fill, sequential scrub.
|
|
971
|
+
// For scrub the two phases are sequential tweens so scroll maps
|
|
972
|
+
// draw → fill. Reverse swaps order so unfill happens before undraw.
|
|
973
|
+
const fillEaseCls = [...el.classList].find(c => c.startsWith("fill-ease-"))
|
|
974
|
+
const fillEase = fillEaseCls ? fillEaseCls.slice("fill-ease-".length) : ease
|
|
975
|
+
const fillTimeCls = [...el.classList].find(c => c.startsWith("fill-time-"))
|
|
976
|
+
const fillDur = fillTimeCls ? Number(fillTimeCls.slice("fill-time-".length)) : 0.5
|
|
977
|
+
const drawDur = 1
|
|
978
|
+
// Ensure the hidden / revealed fill state is correct before scrub starts
|
|
979
|
+
gsap.set(el, { fillOpacity: reverse ? 1 : 0 })
|
|
980
|
+
if (!reverse) {
|
|
981
|
+
tl.fromTo(el, { drawSVG: "0%" , ...rnd }, { drawSVG: "100%" , ...rndEnds, ease, duration: drawDur })
|
|
982
|
+
tl.fromTo(el, { fillOpacity: 0 }, { fillOpacity: 1, ease: fillEase, duration: fillDur })
|
|
983
|
+
} else {
|
|
984
|
+
tl.fromTo(el, { fillOpacity: 1 }, { fillOpacity: 0, ease: fillEase, duration: fillDur })
|
|
985
|
+
tl.fromTo(el, { drawSVG: "100%" , ...rndEnds }, { drawSVG: "0%" , ...rnd, ease, duration: drawDur })
|
|
986
|
+
}
|
|
757
987
|
} else {
|
|
758
|
-
tl.fromTo(el, { ...(reverse ? to : from) }, { ...(reverse ? from : to), ease })
|
|
988
|
+
tl.fromTo(el, { ...(reverse ? to : from), ...rnd }, { ...rndEnds, ...(reverse ? from : to), ease })
|
|
759
989
|
}
|
|
760
990
|
scrollTriggers.push(tl.scrollTrigger)
|
|
761
991
|
return
|
|
@@ -767,7 +997,7 @@ export default function initListeners() {
|
|
|
767
997
|
? ([...el.classList].find(c => c.startsWith("ease-"))?.split("-")[1] ?? "none")
|
|
768
998
|
: getEase(el)
|
|
769
999
|
|
|
770
|
-
const fullText = el
|
|
1000
|
+
const fullText = stashText(el)
|
|
771
1001
|
|
|
772
1002
|
const enter = () => {
|
|
773
1003
|
if (el._scrollTween) el._scrollTween.kill()
|
|
@@ -775,7 +1005,7 @@ export default function initListeners() {
|
|
|
775
1005
|
? (typewriterSplit
|
|
776
1006
|
? playTypewriterSplit(el, delay, duration, ease)
|
|
777
1007
|
: typewriter(el, fullText, duration, delay, ease))
|
|
778
|
-
:
|
|
1008
|
+
: invokePlay(config, el, delay, duration, ease)
|
|
779
1009
|
el._scrollTween.eventCallback("onComplete", () => fireOnComplete(el, "spawn"))
|
|
780
1010
|
}
|
|
781
1011
|
const reverseToStart = () => {
|
|
@@ -802,6 +1032,7 @@ export default function initListeners() {
|
|
|
802
1032
|
|
|
803
1033
|
const st = ScrollTrigger.create({
|
|
804
1034
|
trigger: el,
|
|
1035
|
+
scroller: getScroller(el),
|
|
805
1036
|
start: "top bottom",
|
|
806
1037
|
end: "bottom top",
|
|
807
1038
|
onEnter: enter,
|
|
@@ -811,7 +1042,7 @@ export default function initListeners() {
|
|
|
811
1042
|
})
|
|
812
1043
|
scrollTriggers.push(st)
|
|
813
1044
|
}
|
|
814
|
-
|
|
1045
|
+
qAll(".scroll, .scroll-progress").forEach(setupScroll)
|
|
815
1046
|
|
|
816
1047
|
// SplitText scroll variants: `.spawn-text-<spawn>.scroll` plays the per-part
|
|
817
1048
|
// tween when the element enters the viewport and reverses on exit.
|
|
@@ -819,7 +1050,7 @@ export default function initListeners() {
|
|
|
819
1050
|
if (isTypewriter || text === false) return
|
|
820
1051
|
const tSel = "." + TEXT_PREFIX + sel.slice(1)
|
|
821
1052
|
|
|
822
|
-
|
|
1053
|
+
qAll(tSel + ".scroll:not(.scroll-progress)").forEach((el) => {
|
|
823
1054
|
if (isReduced(el)) return
|
|
824
1055
|
const { delay, duration } = readTiming(el)
|
|
825
1056
|
const ease = getEase(el)
|
|
@@ -834,6 +1065,7 @@ export default function initListeners() {
|
|
|
834
1065
|
}
|
|
835
1066
|
scrollTriggers.push(ScrollTrigger.create({
|
|
836
1067
|
trigger: el,
|
|
1068
|
+
scroller: getScroller(el),
|
|
837
1069
|
start: "top bottom",
|
|
838
1070
|
end: "top top",
|
|
839
1071
|
onEnter: enter,
|
|
@@ -843,7 +1075,7 @@ export default function initListeners() {
|
|
|
843
1075
|
}))
|
|
844
1076
|
})
|
|
845
1077
|
|
|
846
|
-
|
|
1078
|
+
qAll(tSel + ".scroll-progress").forEach((el) => {
|
|
847
1079
|
if (isReduced(el)) return
|
|
848
1080
|
const ease = getEase(el)
|
|
849
1081
|
const parts = getParts(el, getGranularity(el))
|
|
@@ -855,14 +1087,19 @@ export default function initListeners() {
|
|
|
855
1087
|
const tl = gsap.timeline({
|
|
856
1088
|
scrollTrigger: {
|
|
857
1089
|
trigger: el,
|
|
1090
|
+
scroller: getScroller(el),
|
|
858
1091
|
start: startClass != null ? `top ${clamp(100 - startClass)}%` : defaults.progressStart,
|
|
859
1092
|
end: endClass != null ? `top ${clamp(100 - endClass)}%` : defaults.progressEnd,
|
|
860
1093
|
scrub: true,
|
|
861
1094
|
},
|
|
862
1095
|
})
|
|
863
1096
|
// `.progress-reverse` runs the split scrub in reverse; swap from/to.
|
|
1097
|
+
// Randomize applies to the hidden (non-reverse) start only, same
|
|
1098
|
+
// rule as the element-level scrub above.
|
|
864
1099
|
const reverse = el.classList.contains("progress-reverse")
|
|
865
|
-
|
|
1100
|
+
const rnd = !reverse && hasRandom(el) ? randomVars(el) : null
|
|
1101
|
+
tl.fromTo(parts, { ...(reverse ? to : from), ...rnd },
|
|
1102
|
+
{ ...(rnd ? randomEnds(Object.keys(rnd)) : null), ...(reverse ? from : to), ease })
|
|
866
1103
|
scrollTriggers.push(tl.scrollTrigger)
|
|
867
1104
|
})
|
|
868
1105
|
})
|
|
@@ -889,8 +1126,9 @@ export default function initListeners() {
|
|
|
889
1126
|
setTimeout(scheduleRefresh, 400)
|
|
890
1127
|
setTimeout(scheduleRefresh, 1200)
|
|
891
1128
|
|
|
892
|
-
spawnConfigs.forEach((
|
|
893
|
-
|
|
1129
|
+
spawnConfigs.forEach((config) => {
|
|
1130
|
+
const { sel, typewriter: isTypewriter, typewriterSplit } = config
|
|
1131
|
+
qAll(sel).forEach((el) => {
|
|
894
1132
|
if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
|
|
895
1133
|
if (isPreserved(el)) return
|
|
896
1134
|
if (isReduced(el)) return
|
|
@@ -902,10 +1140,10 @@ export default function initListeners() {
|
|
|
902
1140
|
el._spawnTween = playTypewriterSplit(el, delay, duration, elEase)
|
|
903
1141
|
} else {
|
|
904
1142
|
el.typewriter?.kill()
|
|
905
|
-
el.typewriter = typewriter(el, el
|
|
1143
|
+
el.typewriter = typewriter(el, stashText(el), duration, delay, elEase)
|
|
906
1144
|
}
|
|
907
1145
|
} else {
|
|
908
|
-
el._spawnTween =
|
|
1146
|
+
el._spawnTween = invokePlay(config, el, delay, duration, getEase(el))
|
|
909
1147
|
el._spawnTween.eventCallback("onComplete", () => {
|
|
910
1148
|
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
911
1149
|
if (el.classList.contains("flip")) captureFlip(el)
|
|
@@ -915,6 +1153,7 @@ export default function initListeners() {
|
|
|
915
1153
|
})
|
|
916
1154
|
}
|
|
917
1155
|
markPreserved(el)
|
|
1156
|
+
el.dataset.gsapWired = "1"
|
|
918
1157
|
})
|
|
919
1158
|
})
|
|
920
1159
|
|
|
@@ -924,13 +1163,14 @@ export default function initListeners() {
|
|
|
924
1163
|
spawnConfigs.forEach(({ sel, from, typewriter: isTypewriter, text }) => {
|
|
925
1164
|
if (isTypewriter || text === false) return
|
|
926
1165
|
const tSel = "." + TEXT_PREFIX + sel.slice(1)
|
|
927
|
-
|
|
1166
|
+
qAll(tSel).forEach((el) => {
|
|
928
1167
|
if (el.classList.contains("scroll") || el.classList.contains("scroll-progress")) return
|
|
929
1168
|
if (isPreserved(el)) return
|
|
930
1169
|
if (isReduced(el)) return
|
|
931
1170
|
const { delay, duration } = readTiming(el)
|
|
932
1171
|
el._spawnTween = playText(el, from, delay, duration, getEase(el))
|
|
933
1172
|
markPreserved(el)
|
|
1173
|
+
el.dataset.gsapWired = "1"
|
|
934
1174
|
})
|
|
935
1175
|
})
|
|
936
1176
|
|
|
@@ -1077,8 +1317,11 @@ export default function initListeners() {
|
|
|
1077
1317
|
el[key]?.kill()
|
|
1078
1318
|
el[key] = trackCompatLoop(el, build(el, ctx))
|
|
1079
1319
|
if (loop) el[key].repeat(-1)
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1320
|
+
// Track wired loops so teardown can kill them (radiate
|
|
1321
|
+
// relies on kill/onInterrupt to remove its clones).
|
|
1322
|
+
if (!loopEls.some((l) => l.el === el && l.key === key)) {
|
|
1323
|
+
loopEls.push({ el, key })
|
|
1324
|
+
}
|
|
1082
1325
|
el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
|
|
1083
1326
|
() => fireOnComplete(el, "loop"))
|
|
1084
1327
|
}
|
|
@@ -1120,7 +1363,7 @@ export default function initListeners() {
|
|
|
1120
1363
|
|
|
1121
1364
|
// `hover-<name>` and `click-<name>` trigger one of the loop animations on
|
|
1122
1365
|
// 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
|
|
1366
|
+
// the stable hover/click hit area, while the element itself animates - so
|
|
1124
1367
|
// the WHOLE box moves/scales instead of just its text, and the area never
|
|
1125
1368
|
// shifts under the cursor. Marquee is skipped (its build restructures the
|
|
1126
1369
|
// DOM).
|
|
@@ -1267,7 +1510,7 @@ export default function initListeners() {
|
|
|
1267
1510
|
|
|
1268
1511
|
// Per-entry `setup` hook: a "special abilities" extension point. Any
|
|
1269
1512
|
// config entry with a `setup(el, ctx)` function runs it once for every
|
|
1270
|
-
// matching element at wiring time
|
|
1513
|
+
// matching element at wiring time - for behaviour that doesn't fit the
|
|
1271
1514
|
// scroll/order/loop machinery. If it RETURNS a function, that's treated
|
|
1272
1515
|
// as a teardown and invoked when the whole engine is torn down, so
|
|
1273
1516
|
// side-effects (listeners, observers, timers) can be cleaned up.
|
|
@@ -1285,7 +1528,7 @@ export default function initListeners() {
|
|
|
1285
1528
|
|
|
1286
1529
|
// Bind click + loop animations to every element present at load, tagging
|
|
1287
1530
|
// them so the MutationObserver below never double-binds a dynamic one.
|
|
1288
|
-
|
|
1531
|
+
qAll("body *").forEach((el) => {
|
|
1289
1532
|
if (el.dataset?.gsapSetup) return
|
|
1290
1533
|
el.dataset.gsapSetup = "1"
|
|
1291
1534
|
setupClicks(el)
|
|
@@ -1302,38 +1545,28 @@ export default function initListeners() {
|
|
|
1302
1545
|
// stops the observer<->morph feedback loop.
|
|
1303
1546
|
let flipRoots = new Set()
|
|
1304
1547
|
let flipPendingRaf = null
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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 })
|
|
1548
|
+
const flushFlipRoots = () => {
|
|
1549
|
+
if (!flipRoots.size) return
|
|
1550
|
+
// A single frame can register several scopes for the SAME
|
|
1551
|
+
// element: removing a `.leave` node re-attaches a fixed ghost
|
|
1552
|
+
// to <body>, which adds `body` as a second scope alongside the
|
|
1553
|
+
// node's former parent. Running animateFlip per scope re-enters
|
|
1554
|
+
// playFlip on the same element, killing the in-flight tween and
|
|
1555
|
+
// clearing its transform - snapping the element into place.
|
|
1556
|
+
// Dedupe across scopes so each element flips exactly once.
|
|
1557
|
+
const toFlip = new Set()
|
|
1558
|
+
flipRoots.forEach((scope) => {
|
|
1559
|
+
if (!scope) return
|
|
1560
|
+
gsap.utils.toArray(scope.querySelectorAll?.(".flip") || [])
|
|
1561
|
+
.forEach((el) => { if (el.isConnected) toFlip.add(el) })
|
|
1562
|
+
})
|
|
1563
|
+
toFlip.forEach(playFlip)
|
|
1564
|
+
// Refresh baselines for any .flip that settled this frame.
|
|
1565
|
+
gsap.utils.toArray(document.body.querySelectorAll?.(".flip") || []).forEach(captureFlip)
|
|
1566
|
+
flipRoots = new Set()
|
|
1567
|
+
}
|
|
1568
|
+
let flipObserver = null
|
|
1569
|
+
// flipObserver is created conditionally below (hub vs direct)
|
|
1337
1570
|
|
|
1338
1571
|
|
|
1339
1572
|
|
|
@@ -1361,9 +1594,9 @@ export default function initListeners() {
|
|
|
1361
1594
|
const elEase = easeClass ? easeClass.split("-")[1] : "none"
|
|
1362
1595
|
el._spawnTween = config.typewriterSplit
|
|
1363
1596
|
? playTypewriterSplit(el, delay, duration, elEase)
|
|
1364
|
-
: config
|
|
1597
|
+
: invokePlay(config, el, delay, duration, elEase)
|
|
1365
1598
|
} else {
|
|
1366
|
-
el._spawnTween = config
|
|
1599
|
+
el._spawnTween = invokePlay(config, el, delay, duration, ease)
|
|
1367
1600
|
el._spawnTween.eventCallback("onComplete", () => {
|
|
1368
1601
|
if (el.classList.contains("leave")) refreshLeaveRect(el)
|
|
1369
1602
|
if (el.classList.contains("flip")) captureFlip(el)
|
|
@@ -1372,7 +1605,23 @@ export default function initListeners() {
|
|
|
1372
1605
|
}
|
|
1373
1606
|
}
|
|
1374
1607
|
|
|
1375
|
-
|
|
1608
|
+
let appearObserver = null
|
|
1609
|
+
let leaveObserver = null
|
|
1610
|
+
let hubObserver = null
|
|
1611
|
+
let hubRaf = null
|
|
1612
|
+
let hubQueue = []
|
|
1613
|
+
let hubCurrentBatch = null
|
|
1614
|
+
let hubCursor = 0
|
|
1615
|
+
|
|
1616
|
+
const handleFlipBatch = (mutations) => {
|
|
1617
|
+
for (const mutation of mutations) {
|
|
1618
|
+
if (mutation.type !== "childList") continue
|
|
1619
|
+
const target = mutation.target
|
|
1620
|
+
if (target.nodeType !== 1) continue
|
|
1621
|
+
flipRoots.add(target)
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const handleAppearBatch = (mutations) => {
|
|
1376
1625
|
mutations.forEach((mutation) => {
|
|
1377
1626
|
mutation.addedNodes.forEach((node) => {
|
|
1378
1627
|
if (node.nodeType !== 1) return
|
|
@@ -1402,20 +1651,77 @@ export default function initListeners() {
|
|
|
1402
1651
|
if (pinned) ScrollTrigger.refresh()
|
|
1403
1652
|
})
|
|
1404
1653
|
})
|
|
1405
|
-
}
|
|
1406
|
-
|
|
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) => {
|
|
1654
|
+
}
|
|
1655
|
+
const handleLeaveBatch = (mutations) => {
|
|
1412
1656
|
mutations.forEach((mutation) => {
|
|
1413
1657
|
if (mutation.type !== "childList") return
|
|
1414
1658
|
mutation.addedNodes.forEach((n) => collectLeave(n).forEach(captureLeave))
|
|
1415
1659
|
mutation.removedNodes.forEach((n) => collectLeave(n).forEach(playLeave))
|
|
1416
1660
|
})
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1661
|
+
}
|
|
1662
|
+
const hubHandlers = [handleFlipBatch, handleAppearBatch, handleLeaveBatch]
|
|
1663
|
+
const hubDrain = () => {
|
|
1664
|
+
hubRaf = null
|
|
1665
|
+
if (!hubCurrentBatch) {
|
|
1666
|
+
if (!hubQueue.length) return
|
|
1667
|
+
hubCurrentBatch = hubQueue.splice(0, hubQueue.length)
|
|
1668
|
+
hubCursor = 0
|
|
1669
|
+
}
|
|
1670
|
+
const end = Math.min(hubCursor + throttlePerFrame, hubHandlers.length)
|
|
1671
|
+
for (let i = hubCursor; i < end; i++) {
|
|
1672
|
+
hubHandlers[i](hubCurrentBatch)
|
|
1673
|
+
if (hubHandlers[i] === handleFlipBatch) flushFlipRoots()
|
|
1674
|
+
}
|
|
1675
|
+
hubCursor = end
|
|
1676
|
+
if (hubCursor < hubHandlers.length) {
|
|
1677
|
+
hubRaf = requestAnimationFrame(hubDrain)
|
|
1678
|
+
} else {
|
|
1679
|
+
hubCurrentBatch = null
|
|
1680
|
+
hubCursor = 0
|
|
1681
|
+
if (hubQueue.length) hubRaf = requestAnimationFrame(hubDrain)
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const scheduleHub = () => {
|
|
1685
|
+
if (hubRaf) return
|
|
1686
|
+
hubRaf = requestAnimationFrame(hubDrain)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if (throttlePerFrame > 0) {
|
|
1690
|
+
hubObserver = new MutationObserver((mutations) => {
|
|
1691
|
+
hubQueue.push(...mutations)
|
|
1692
|
+
scheduleHub()
|
|
1693
|
+
})
|
|
1694
|
+
hubObserver.observe(document.body, { childList: true, subtree: true })
|
|
1695
|
+
} else {
|
|
1696
|
+
flipObserver = new MutationObserver((mutations) => {
|
|
1697
|
+
handleFlipBatch(mutations)
|
|
1698
|
+
if (!flipPendingRaf) {
|
|
1699
|
+
flipPendingRaf = requestAnimationFrame(() => {
|
|
1700
|
+
flipPendingRaf = null
|
|
1701
|
+
flushFlipRoots()
|
|
1702
|
+
})
|
|
1703
|
+
}
|
|
1704
|
+
})
|
|
1705
|
+
flipObserver.observe(document.body, { childList: true, subtree: true })
|
|
1706
|
+
|
|
1707
|
+
appearObserver = new MutationObserver((mutations) => {
|
|
1708
|
+
handleAppearBatch(mutations)
|
|
1709
|
+
})
|
|
1710
|
+
appearObserver.observe(document.body, { childList: true, subtree: true })
|
|
1711
|
+
|
|
1712
|
+
// Capture any .leave elements already present so they can exit later
|
|
1713
|
+
qAll(".leave").forEach(captureLeave)
|
|
1714
|
+
|
|
1715
|
+
leaveObserver = new MutationObserver((mutations) => {
|
|
1716
|
+
handleLeaveBatch(mutations)
|
|
1717
|
+
})
|
|
1718
|
+
leaveObserver.observe(document.body, { childList: true, subtree: true })
|
|
1719
|
+
// leave capture for throttled path is done below after branch
|
|
1720
|
+
}
|
|
1721
|
+
if (throttlePerFrame > 0) {
|
|
1722
|
+
// capture for throttled path (was inside else branch above for non-throttled)
|
|
1723
|
+
qAll(".leave").forEach(captureLeave)
|
|
1724
|
+
}
|
|
1419
1725
|
|
|
1420
1726
|
// Keep the captured position fresh (throttled to one pass per frame)
|
|
1421
1727
|
let positionTick = false
|
|
@@ -1423,7 +1729,7 @@ export default function initListeners() {
|
|
|
1423
1729
|
if (positionTick) return
|
|
1424
1730
|
positionTick = true
|
|
1425
1731
|
requestAnimationFrame(() => {
|
|
1426
|
-
|
|
1732
|
+
qAll(".leave").forEach((el) => {
|
|
1427
1733
|
const s = leaveStates.get(el)
|
|
1428
1734
|
if (s) s.rect = el.getBoundingClientRect()
|
|
1429
1735
|
})
|
|
@@ -1434,27 +1740,76 @@ export default function initListeners() {
|
|
|
1434
1740
|
window.addEventListener("resize", refreshLeavePositions, { passive: true })
|
|
1435
1741
|
|
|
1436
1742
|
return () => {
|
|
1437
|
-
appearObserver
|
|
1438
|
-
leaveObserver
|
|
1439
|
-
flipObserver
|
|
1743
|
+
appearObserver?.disconnect()
|
|
1744
|
+
leaveObserver?.disconnect()
|
|
1745
|
+
flipObserver?.disconnect()
|
|
1746
|
+
if (hubObserver) hubObserver.disconnect()
|
|
1747
|
+
if (hubRaf) cancelAnimationFrame(hubRaf)
|
|
1748
|
+
if (flipPendingRaf) cancelAnimationFrame(flipPendingRaf)
|
|
1440
1749
|
window.removeEventListener("scroll", refreshLeavePositions)
|
|
1441
1750
|
window.removeEventListener("resize", refreshLeavePositions)
|
|
1442
1751
|
window.removeEventListener("load", ScrollTrigger.refresh)
|
|
1443
1752
|
clearTimeout(refreshTimer)
|
|
1444
1753
|
scrollTriggers.forEach((t) => {
|
|
1445
|
-
t.
|
|
1754
|
+
const tw = t.trigger._scrollTween
|
|
1755
|
+
// Finalize a mid-flight typewriter/scramble entrance at its end state
|
|
1756
|
+
// BEFORE killing: stranded partial/garbled text would otherwise be read as
|
|
1757
|
+
// settled content by the next run's stash.
|
|
1758
|
+
const cls = t.trigger.classList
|
|
1759
|
+
if (tw && !tw.reversed() && (cls?.contains("typewriter") || cls?.contains("scramble"))) tw.progress(1)
|
|
1760
|
+
// kill(true): revert pinning (remove pin-spacers, restore inline
|
|
1761
|
+
// styles) so a later init can re-pin cleanly instead of nesting a
|
|
1762
|
+
// second spacer inside the leaked first one.
|
|
1763
|
+
t.kill(true)
|
|
1446
1764
|
t.trigger._scrollTween?.kill()
|
|
1447
1765
|
delete t.trigger._scrollTween
|
|
1448
1766
|
})
|
|
1449
1767
|
ScrollTrigger.refresh()
|
|
1768
|
+
// Clear per-element wire-up tags. Without this, any engine restart
|
|
1769
|
+
// (initAnimations() called again, StrictMode remounts, route-level
|
|
1770
|
+
// re-mounts that keep DOM nodes alive) would silently skip rewiring:
|
|
1771
|
+
// .scroll/.pin/parallax triggers stay dead (their old ones were just
|
|
1772
|
+
// killed) and clicks/loops/hover never re-bind. `_appeared` goes too,
|
|
1773
|
+
// so dynamically-added .appear elements can animate under the new
|
|
1774
|
+
// engine run. data-gsap-preserved (cross-reset memory) and
|
|
1775
|
+
// data-gsap-ghost (detached .leave clones) are intentionally KEPT.
|
|
1776
|
+
qAll('[data-gsap-scroll],[data-gsap-setup],[data-gsap-pinned],[data-gsap-scroll-driven]').forEach((el) => {
|
|
1777
|
+
delete el.dataset.gsapScroll
|
|
1778
|
+
delete el.dataset.gsapSetup
|
|
1779
|
+
delete el.dataset.gsapPinned
|
|
1780
|
+
delete el.dataset.gsapScrollDriven
|
|
1781
|
+
delete el._appeared
|
|
1782
|
+
})
|
|
1450
1783
|
registeredListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
1451
1784
|
magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
1452
1785
|
magnetQuery?.removeEventListener("change", applyMagnet)
|
|
1453
1786
|
loopEls.forEach(({ el, key }) => el[key]?.kill())
|
|
1787
|
+
// Sweep any radiate clones orphaned by pre-fix kills or edge cases.
|
|
1788
|
+
document.querySelectorAll('[data-gsap-radiate]').forEach((n) => n.remove())
|
|
1454
1789
|
cssTweens.forEach((t) => t?.kill())
|
|
1455
1790
|
cssTweens.length = 0
|
|
1456
|
-
|
|
1457
|
-
|
|
1791
|
+
qAll(".typewriter, .scramble").forEach(el => {
|
|
1792
|
+
// Preserved-region typewriters/scrambles stay as-is (already at
|
|
1793
|
+
// their end state); finalize the rest so a mid-type/mid-scramble
|
|
1794
|
+
// kill can't strand partial or garbled text where the next run's
|
|
1795
|
+
// stash would read it.
|
|
1796
|
+
const keep = el.isConnected && underPreservedRoot(el)
|
|
1797
|
+
const tw = el.typewriter || el._spawnTween || el._scrollTween
|
|
1798
|
+
if (!keep && tw && !tw.reversed()) tw.progress(1)
|
|
1799
|
+
tw?.kill()
|
|
1800
|
+
})
|
|
1801
|
+
textSplits.forEach((s) => {
|
|
1802
|
+
// Splits inside a tagged preserve region keep their spans - the
|
|
1803
|
+
// next run will skip those elements, and reverting here would
|
|
1804
|
+
// visibly strip their finished animation. Everything else reverts
|
|
1805
|
+
// cleanly (and drops out of splitCache so a reused element can be
|
|
1806
|
+
// re-split fresh).
|
|
1807
|
+
const keep = (s.elements || []).some((e) => e.isConnected && underPreservedRoot(e))
|
|
1808
|
+
if (!keep) {
|
|
1809
|
+
;(s.elements || []).forEach((e) => splitCache.delete(e))
|
|
1810
|
+
revertSplitInstance(s)
|
|
1811
|
+
}
|
|
1812
|
+
})
|
|
1458
1813
|
textSplits.length = 0
|
|
1459
1814
|
onCompleteTweens.forEach((t) => t?.kill())
|
|
1460
1815
|
onCompleteTweens.length = 0
|