gclass-anims 1.0.0-beta.7 → 1.0.0-beta.8

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.
Files changed (3) hide show
  1. package/Animations.js +54 -23
  2. package/Listeners.js +52 -12
  3. package/package.json +1 -1
package/Animations.js CHANGED
@@ -340,6 +340,9 @@ export function pulse (delay , target , amount , dur , ease){
340
340
 
341
341
  export function radiate (delay , target , amount , dur , ease , zIndex){
342
342
  const clone = target.cloneNode(true)
343
+ // Tagged so engine teardown can sweep up clones whose tween was killed
344
+ // before its onComplete (route changes mid-animation).
345
+ clone.setAttribute("data-gsap-radiate", "1")
343
346
  clone.style.cssText = `position:fixed;left:0;top:0;right:auto;bottom:auto;margin:0;pointer-events:none;transform-origin:50% 50%;${zIndex != null ? `z-index:${zIndex};` : ""}`
344
347
  // Keep the ripple glued to the target so it tracks scroll/resize instead of
345
348
  // getting stranded at the position captured when the animation was built.
@@ -363,6 +366,13 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
363
366
  applyRect()
364
367
  window.addEventListener("scroll", schedule, { passive: true })
365
368
  window.addEventListener("resize", schedule, { passive: true })
369
+ // Killing the tween (teardown, hover/click rebuilds) must clean up exactly
370
+ // like natural completion — otherwise clones + listeners leak.
371
+ const cleanup = () => {
372
+ clone.remove()
373
+ window.removeEventListener("scroll", schedule)
374
+ window.removeEventListener("resize", schedule)
375
+ }
366
376
 
367
377
  return gsap.fromTo(clone , {scale:1 , opacity:1} , {
368
378
  scale:amount / 10 ,
@@ -370,11 +380,8 @@ export function radiate (delay , target , amount , dur , ease , zIndex){
370
380
  duration:dur ,
371
381
  delay:delay ,
372
382
  ease:easeOf(ease) ,
373
- onComplete: () => {
374
- clone.remove()
375
- window.removeEventListener("scroll", schedule)
376
- window.removeEventListener("resize", schedule)
377
- } ,
383
+ onComplete: cleanup ,
384
+ onInterrupt: cleanup ,
378
385
  })
379
386
  }
380
387
 
@@ -400,28 +407,52 @@ export function marquee (target , dir , duration , xOffset = 0 , yOffset = 0 , n
400
407
  // which opens a gap on the trailing edge at some point in the loop.
401
408
  target.style.position = "relative"
402
409
  target.style.overflow = "hidden"
403
- const track = document.createElement("div")
404
- track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
405
- while (target.firstChild) track.appendChild(target.firstChild)
406
- target.appendChild(track)
407
-
408
- // The track is absolutely positioned, so once its content moves in, the host
409
- // has no in-flow children left and can collapse to zero height. With the
410
- // `overflow:hidden` set above that clips the track away entirely (e.g. a
411
- // bare-text `<h1 class="marquee-left">` disappears). Preserve the content's
412
- // height on the host when that happens so the marquee stays visible. Hosts
413
- // with their own height (flex cards etc.) are left untouched.
410
+
411
+ // Rebuilds over the SAME element (engine restarts, StrictMode remounts)
412
+ // must reuse the existing track. Re-creating it would swallow the old
413
+ // absolute track as the first "child", repeat THAT as the tiling unit —
414
+ // every copy stacks at the same offset and scrollWidth collapses.
415
+ let track = target._gcTrack
416
+ if (!track || !track.isConnected) {
417
+ track = document.createElement("div")
418
+ track.style.cssText = `position:absolute;top:${yOffset}px;left:${xOffset}px;display:flex;flex-direction:${horizontal ? "row" : "column"};width:max-content;will-change:transform;`
419
+ while (target.firstChild) track.appendChild(target.firstChild)
420
+ target.appendChild(track)
421
+ // Guard against legacy poisoned DOM: older builds could wrap a previous
422
+ // absolute track inside this one. Unwrap any nested engine tracks so
423
+ // the stashed unit is always the raw content.
424
+ while (
425
+ track.children.length &&
426
+ [...track.children].every((c) => c.style.position === "absolute" && c.style.display === "flex")
427
+ ) {
428
+ const inner = track.firstElementChild
429
+ while (inner.firstChild) track.insertBefore(inner.firstChild, inner)
430
+ track.removeChild(inner)
431
+ }
432
+ target._gcUnitHtml = track.innerHTML
433
+ }
434
+ track.style.flexDirection = horizontal ? "row" : "column"
435
+ const unitHtml = target._gcUnitHtml
436
+
437
+ // Measure ONE unit on its own: the live track may already hold N copies
438
+ // (or stale content), which would inflate unitSize and starve `copies`.
439
+ const measurer = document.createElement("div")
440
+ measurer.style.cssText = track.style.cssText + "visibility:hidden;"
441
+ measurer.innerHTML = unitHtml
442
+ target.appendChild(measurer)
443
+ const unitSize = horizontal ? measurer.scrollWidth : measurer.scrollHeight
444
+ measurer.remove()
445
+
446
+ // Nothing to tile (empty content) — return an inert tween rather than one
447
+ // dividing by a zero-width unit.
448
+ if (!unitSize) return gsap.fromTo(track, {}, { duration: 0 })
449
+
450
+ // The track is absolutely positioned, so a bare-text host can collapse to
451
+ // zero height and `overflow:hidden` would clip the strip away entirely.
414
452
  if (target.offsetHeight === 0 && track.offsetHeight > 0) {
415
453
  target.style.height = track.offsetHeight + "px"
416
454
  }
417
455
 
418
- const first = track.children[0]
419
- if (!first) return gsap.fromTo(track, {}, { duration: 0 })
420
-
421
- // A single copy of the content (the width one loop step must travel).
422
- const unitHtml = track.innerHTML
423
- const unitSize = horizontal ? track.scrollWidth : track.scrollHeight
424
-
425
456
  // Default: repeat the unit until the whole strip is at least as wide as the
426
457
  // viewport (plus one extra copy so the trailing edge stays covered mid-loop).
427
458
  // `.marquee-no-repeat` opts into the minimal 2-copy single-seam behaviour.
package/Listeners.js CHANGED
@@ -57,19 +57,37 @@ export default function initListeners() {
57
57
 
58
58
  // `.preserve` keeps an already-rendered element (e.g. one that persists
59
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.
60
+ // the Listeners setup re-runs. It applies to the element AND its
61
+ // children: any preserved ancestor also suppresses animation on this
62
+ // node. Two subtleties make this behave correctly:
63
+ // markPreserved() tags the FULL preserve-ancestor chain, so a bare
64
+ // `.preserve` container without its own spawn class (a site header,
65
+ // say) still gets tagged when one of its children animates.
66
+ // • Suppression requires the element ITSELF to carry data-gsap-wired
67
+ // (set when a previous run animated it). Freshly mounted content
68
+ // under a preserved root therefore still plays its entrance — only
69
+ // DOM that survived from an earlier run stays frozen.
66
70
  const isPreserved = (el) => {
71
+ if (!el.dataset.gsapWired) return false
72
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
73
+ if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
74
+ }
75
+ return false
76
+ }
77
+ const markPreserved = (el) => {
78
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
79
+ if (node.classList.contains("preserve")) node.dataset.gsapPreserved = "1"
80
+ }
81
+ }
82
+ // True when el sits inside a `.preserve` root that a run has already
83
+ // tagged. Such regions are frozen by design: later runs skip them, so
84
+ // teardown must leave their visual state untouched too.
85
+ const underPreservedRoot = (el) => {
67
86
  for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
68
87
  if (node.classList.contains("preserve") && node.dataset.gsapPreserved) return true
69
88
  }
70
89
  return false
71
90
  }
72
- const markPreserved = (el) => { if (el.classList.contains("preserve")) el.dataset.gsapPreserved = "1" }
73
91
 
74
92
  // `spawnConfigs` is derived from the config in Config.js (see top of
75
93
  // file). Adding/removing an entry there automatically re-wires every
@@ -699,7 +717,7 @@ export default function initListeners() {
699
717
  scrollTriggers.push(t.scrollTrigger)
700
718
  }
701
719
  }
702
- gsap.utils.toArray('[class^="parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
720
+ gsap.utils.toArray('[class^="parallax-"],[class*=" parallax-"], .progress-bar, .scroll-fill, .scroll-fade-bg, .scroll-horizontal').forEach(setupScrollDriven)
703
721
 
704
722
  // `.scroll`/`.scroll-progress` entrance animation, driven by ScrollTrigger.
705
723
  // Split out into a helper so DYNAMICALLY-added elements (e.g. pagination
@@ -915,6 +933,7 @@ export default function initListeners() {
915
933
  })
916
934
  }
917
935
  markPreserved(el)
936
+ el.dataset.gsapWired = "1"
918
937
  })
919
938
  })
920
939
 
@@ -931,6 +950,7 @@ export default function initListeners() {
931
950
  const { delay, duration } = readTiming(el)
932
951
  el._spawnTween = playText(el, from, delay, duration, getEase(el))
933
952
  markPreserved(el)
953
+ el.dataset.gsapWired = "1"
934
954
  })
935
955
  })
936
956
 
@@ -1077,8 +1097,11 @@ export default function initListeners() {
1077
1097
  el[key]?.kill()
1078
1098
  el[key] = trackCompatLoop(el, build(el, ctx))
1079
1099
  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.
1100
+ // Track wired loops so teardown can kill them (radiate
1101
+ // relies on kill/onInterrupt to remove its clones).
1102
+ if (!loopEls.some((l) => l.el === el && l.key === key)) {
1103
+ loopEls.push({ el, key })
1104
+ }
1082
1105
  el[key].eventCallback(el[key].repeat() === -1 ? "onRepeat" : "onComplete",
1083
1106
  () => fireOnComplete(el, "loop"))
1084
1107
  }
@@ -1474,13 +1497,30 @@ export default function initListeners() {
1474
1497
  magnetListeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
1475
1498
  magnetQuery?.removeEventListener("change", applyMagnet)
1476
1499
  loopEls.forEach(({ el, key }) => el[key]?.kill())
1500
+ // Sweep any radiate clones orphaned by pre-fix kills or edge cases.
1501
+ document.querySelectorAll('[data-gsap-radiate]').forEach((n) => n.remove())
1477
1502
  cssTweens.forEach((t) => t?.kill())
1478
1503
  cssTweens.length = 0
1479
1504
  gsap.utils.toArray(".typewriter").forEach(el => {
1480
- if (el.typewriter && !el.typewriter.reversed()) el.typewriter.progress(1)
1505
+ // Preserved-region typewriters stay as-is (already at their end
1506
+ // state); finalize the rest so a mid-type kill can't strand
1507
+ // partial text where the next run's stash would read it.
1508
+ const keep = el.isConnected && underPreservedRoot(el)
1509
+ if (!keep && el.typewriter && !el.typewriter.reversed()) el.typewriter.progress(1)
1481
1510
  el.typewriter?.kill()
1482
1511
  })
1483
- textSplits.forEach((s) => s.revert())
1512
+ textSplits.forEach((s) => {
1513
+ // Splits inside a tagged preserve region keep their spans — the
1514
+ // next run will skip those elements, and reverting here would
1515
+ // visibly strip their finished animation. Everything else reverts
1516
+ // cleanly (and drops out of splitCache so a reused element can be
1517
+ // re-split fresh).
1518
+ const keep = (s.elements || []).some((e) => e.isConnected && underPreservedRoot(e))
1519
+ if (!keep) {
1520
+ ;(s.elements || []).forEach((e) => splitCache.delete(e))
1521
+ s.revert()
1522
+ }
1523
+ })
1484
1524
  textSplits.length = 0
1485
1525
  onCompleteTweens.forEach((t) => t?.kill())
1486
1526
  onCompleteTweens.length = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gclass-anims",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "A Tailwind-style utility layer on top of GSAP. Framework-agnostic — works in vanilla JS, React, Vue, Svelte, or any bundler.",
5
5
  "type": "module",
6
6
  "main": "index.js",