-
Notifications
You must be signed in to change notification settings - Fork 255
Expand file tree
/
Copy pathAnimationInitializer.tsx
More file actions
757 lines (664 loc) · 28.5 KB
/
Copy pathAnimationInitializer.tsx
File metadata and controls
757 lines (664 loc) · 28.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
'use client';
/**
* AnimationInitializer - Initializes GSAP animations based on layer interactions
* Runs on the client to set up all animations for preview/published pages.
*
* Initial styles for 'on-load' mode are applied server-side via generateInitialAnimationCSS()
* to prevent flickering. This component only handles animation triggers.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { ITEMS_INJECTED_EVENT, type ItemsInjectedDetail } from '@/components/FilterableCollection';
import { buildGsapProps, addTweenToTimeline, createSplitTextAnimation, generateInitialAnimationCSS, getEffectiveApplyStyle, setColorVariableResolver } from '@/lib/animation-utils';
import { BREAKPOINT_VALUES, getCurrentBreakpoint } from '@/lib/breakpoint-utils';
import { remapLayerIdsForCollectionItem } from '@/lib/collection-utils';
import { useColorVariablesStore } from '@/stores/useColorVariablesStore';
import type { Layer, LayerInteraction, Breakpoint } from '@/types';
// Register GSAP plugins
if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger, SplitText);
}
interface AnimationInitializerProps {
layers: Layer[];
injectInitialCSS?: boolean;
}
interface CollectedInteraction {
triggerLayerId: string;
interaction: LayerInteraction;
}
/** Recursively collect all interactions from layers */
function collectInteractions(layers: Layer[]): CollectedInteraction[] {
const interactions: CollectedInteraction[] = [];
const traverse = (layerList: Layer[]) => {
layerList.forEach((layer) => {
if (layer.interactions?.length) {
layer.interactions.forEach((interaction) => {
interactions.push({ triggerLayerId: layer.id, interaction });
});
}
if (layer.children) {
traverse(layer.children);
}
});
};
traverse(layers);
return interactions;
}
/** Check if interaction should run on specified breakpoint */
function shouldRunOnBreakpoint(interaction: LayerInteraction, breakpoint: Breakpoint): boolean {
if (!interaction.timeline?.breakpoints) return true;
return interaction.timeline.breakpoints.includes(breakpoint);
}
/** Get element by layer ID */
function getElement(layerId: string): HTMLElement | null {
return document.querySelector(`[data-layer-id="${layerId}"]`);
}
/**
* Whether a mutated node lives inside a Swiper slider. Swiper reshuffles
* existing slide nodes on transition/loop (childList mutations), which must
* not trigger an animation rebind — real new content arrives via the
* ITEMS_INJECTED_EVENT path instead.
*/
function isInsideSlider(node: Node | null): boolean {
const el = node instanceof Element ? node : node?.parentElement ?? null;
return !!el?.closest('[data-slider-id], .swiper-wrapper');
}
/**
* Pre-paint each tween's `from` state so the element rests in its intended
* initial appearance before the trigger fires. CSS via generateInitialAnimationCSS()
* only covers intro triggers (load, scroll-into-view); for hover/click we apply
* inline styles here so the layer's CSS default doesn't bleed through as the
* `to` state.
*/
function applyInitialFromState(interaction: LayerInteraction): void {
(interaction.tweens || []).forEach(tween => {
if (tween.splitText) return;
const el = getElement(tween.layer_id);
if (!el) return;
const { from } = buildGsapProps(tween);
if (Object.keys(from).length > 0) {
gsap.set(el, from);
}
});
}
/**
* Collect info about elements that should start hidden based on interactions
* Returns a map of layerId -> breakpoints (null means all breakpoints)
*/
function collectHiddenLayerInfo(interactions: CollectedInteraction[]): Map<string, string[] | null> {
const hiddenMap = new Map<string, string[] | null>();
interactions.forEach(({ interaction }) => {
const breakpoints = interaction.timeline?.breakpoints || null;
(interaction.tweens || []).forEach((tween) => {
// Hide if display:hidden with effective on-load mode (explicit, or implicit
// for intro triggers like load/scroll-into-view).
if (
tween.from?.display === 'hidden' &&
getEffectiveApplyStyle(interaction.trigger, 'display', tween.apply_styles) === 'on-load'
) {
hiddenMap.set(tween.layer_id, breakpoints);
}
});
});
return hiddenMap;
}
/**
* Layer IDs whose visibility is toggled by a user interaction (click/hover).
* Their live show/hide state must persist across breakpoint changes instead of
* reverting to the on-load default when animations reset on resize.
*/
function collectInteractiveDisplayTargets(interactions: CollectedInteraction[]): Set<string> {
const targets = new Set<string>();
interactions.forEach(({ interaction }) => {
if (interaction.trigger !== 'click' && interaction.trigger !== 'hover') return;
(interaction.tweens || []).forEach((tween) => {
if (tween.from?.display || tween.to?.display) targets.add(tween.layer_id);
});
});
return targets;
}
/**
* Whether an on-load hide applies uniformly to every breakpoint (or not at all).
* Breakpoint-specific hides carry responsive intent that the per-breakpoint
* reset must honor, so a user toggle should not be preserved over them.
*/
function isUniformOnLoadHide(breakpoints: string[] | null | undefined): boolean {
return breakpoints == null || BREAKPOINT_VALUES.every((bp) => breakpoints.includes(bp));
}
/**
* Reset GSAP inline styles and restore initial data attributes for a breakpoint
*/
function resetAnimationStates(
interactions: CollectedInteraction[],
hiddenLayerInfo: Map<string, string[] | null>,
newBreakpoint: Breakpoint
): void {
// Collect all layer IDs that are targeted by animations
const animatedLayerIds = new Set<string>();
interactions.forEach(({ interaction }) => {
(interaction.tweens || []).forEach((tween) => {
animatedLayerIds.add(tween.layer_id);
});
});
// Reset each animated element
animatedLayerIds.forEach((layerId) => {
const element = getElement(layerId);
if (!element) return;
// Clear GSAP inline styles
gsap.set(element, { clearProps: 'all' });
// Reset data-gsap-hidden attribute based on new breakpoint
const hiddenBreakpoints = hiddenLayerInfo.get(layerId);
if (hiddenBreakpoints !== undefined) {
// Check if element should be hidden for the new breakpoint
const shouldBeHidden = hiddenBreakpoints === null || hiddenBreakpoints.includes(newBreakpoint);
if (shouldBeHidden) {
// Restore hidden state with breakpoint info
element.setAttribute('data-gsap-hidden', hiddenBreakpoints?.join(' ') || '');
} else {
// Remove hidden state - not applicable to this breakpoint
element.removeAttribute('data-gsap-hidden');
}
}
});
}
/** Build a GSAP timeline from an interaction */
function buildTimeline(interaction: LayerInteraction): gsap.core.Timeline | null {
const isYoyo = interaction.timeline?.yoyo ?? false;
// Track elements with display transitions for handling show/hide
const displayTransitions: Array<{
element: HTMLElement;
displayStart: string | null;
displayEnd: string | null;
}> = [];
// Track SplitText instances for cleanup
const splitTextInstances: SplitText[] = [];
// Track split elements per layer to reuse across multiple tweens
const splitElementsCache = new Map<string, HTMLElement[]>();
const timeline = gsap.timeline({
paused: true,
repeat: interaction.timeline?.repeat ?? 0,
yoyo: isYoyo,
onComplete: () => {
// Clean up split text after animation completes (optional)
// splitTextInstances.forEach(split => split.revert());
},
});
// First pass: prepare all elements, split text, and collect data
interface PreparedTween {
element: HTMLElement;
splitElements?: HTMLElement[];
from: gsap.TweenVars;
to: gsap.TweenVars;
displayStart: string | null;
displayEnd: string | null;
position: string | number;
duration: number;
ease: string;
splitTextConfig?: typeof interaction.tweens[0]['splitText'];
}
const preparedTweens: PreparedTween[] = [];
(interaction.tweens || []).forEach((tween, index) => {
const element = getElement(tween.layer_id);
if (!element) return;
// Apply split text if configured using GSAP's SplitText
let splitElements: HTMLElement[] | undefined;
if (tween.splitText) {
// Check if we've already split this element in this timeline
const cacheKey = `${tween.layer_id}_${tween.splitText.type}`;
if (splitElementsCache.has(cacheKey)) {
// Reuse existing split elements
splitElements = splitElementsCache.get(cacheKey);
} else {
// Create new split for this element
const result = createSplitTextAnimation(
element,
tween.splitText,
tween,
gsap,
SplitText
);
if (result) {
splitTextInstances.push(result.splitInstance);
splitElements = result.splitElements;
// Cache the split elements for reuse
splitElementsCache.set(cacheKey, result.splitElements);
}
}
}
const { from, to, displayStart, displayEnd } = buildGsapProps(tween);
// Calculate position for timeline
let position: string | number = 0;
if (typeof tween.position === 'number') {
position = tween.position;
} else if (tween.position === '>' && index > 0) {
position = '>';
} else if (tween.position === '<' && index > 0) {
position = '<';
}
// Track display transitions for this tween
if (displayStart !== displayEnd) {
displayTransitions.push({ element, displayStart, displayEnd });
}
preparedTweens.push({
element,
splitElements,
from,
to,
displayStart,
displayEnd,
position,
duration: tween.duration,
ease: tween.ease,
splitTextConfig: tween.splitText,
});
});
// Second pass: Add all tweens to timeline
// For each tween, apply its "from" state at the same position it starts
preparedTweens.forEach(({ element, splitElements, from, to, displayStart, displayEnd, position, duration, ease, splitTextConfig }) => {
// Apply the "from" state at the same position as the tween starts
// This ensures sequenced animations have correct initial state when they begin
if (Object.keys(from).length > 0) {
const targets = splitElements && splitElements.length > 0 ? splitElements : element;
timeline.set(targets, from, position);
}
// Add tween to timeline using shared utility
addTweenToTimeline(timeline, {
element,
from,
to,
duration,
ease,
position,
splitText: splitTextConfig,
splitElements,
onComplete: displayEnd === 'hidden'
? () => element.setAttribute('data-gsap-hidden', '')
: undefined,
});
});
// Handle display state changes based on timeline direction
if (displayTransitions.length > 0) {
// When timeline starts playing forward, set elements to their "end" display state
timeline.eventCallback('onStart', () => {
displayTransitions.forEach(({ element, displayEnd }) => {
if (displayEnd === 'visible') {
element.removeAttribute('data-gsap-hidden');
}
});
});
// When timeline reverses back to start, restore initial display states
if (isYoyo) {
timeline.eventCallback('onReverseComplete', () => {
displayTransitions.forEach(({ element, displayStart }) => {
if (displayStart === 'hidden') {
element.setAttribute('data-gsap-hidden', '');
} else {
element.removeAttribute('data-gsap-hidden');
}
});
});
}
}
return timeline;
}
export default function AnimationInitializer({ layers, injectInitialCSS }: AnimationInitializerProps) {
const cleanupRef = useRef<(() => void)[]>([]);
const timelinesRef = useRef<Map<string, gsap.core.Timeline>>(new Map());
// ScrollTriggers for one-shot `scroll-into-view` intros, keyed by
// interaction id. Tracked separately so effect re-runs can detach a played
// trigger without killing its in-flight timeline (see detachAnimations).
const scrollTriggersRef = useRef<Map<string, ScrollTrigger>>(new Map());
const prevBreakpointRef = useRef<Breakpoint | null>(null);
const [currentBreakpoint, setCurrentBreakpoint] = useState<Breakpoint>(() => getCurrentBreakpoint());
const styleRef = useRef<HTMLStyleElement | null>(null);
// Tracks interaction ids whose one-shot animation (`load` or
// `scroll-into-view`) has already played. Keyed by interaction id, which
// is stable across effect re-runs for the same logical element:
// - Page-level layers keep their original id.
// - Filter/load-more items get `-fc-${itemId}` appended, so the same item
// re-injected later (e.g. filter toggled) keeps the same id and is
// skipped — no replay.
// Cleared on breakpoint changes since `resetAnimationStates` restores
// initial styles and animations need to re-run for the new breakpoint.
const playedOneShotInteractionsRef = useRef<Set<string>>(new Set());
// Extra layers built from collection items injected client-side (filter
// refetch, load-more). Indexed by collection layer id so a filter
// deactivation can drop just that collection's extras. Each value uses the
// `-fc-${itemId}` suffix that mirrors the server-side filter renderer.
const [extrasByCollection, setExtrasByCollection] = useState<Record<string, Layer[]>>({});
// Forces the bind effect to re-run when tracked DOM elements get remounted
// (e.g. when dynamic-imported wrappers like LoadMoreCollection finish loading
// and React replaces the Suspense fallback with their real subtree). Without
// this, gsap.set() and listeners attached on first paint die with the old
// nodes and the new ones never animate until the next state change.
const [rebindTick, setRebindTick] = useState(0);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<ItemsInjectedDetail>).detail;
const { collectionLayerId, layerTemplate, itemIds, append, collectionLayer } = detail || {};
if (!collectionLayerId || !collectionLayer || !layerTemplate) return;
setExtrasByCollection(prev => {
// Empty itemIds = filter deactivated, drop this collection's extras.
if (!append && (!itemIds || itemIds.length === 0)) {
if (!(collectionLayerId in prev)) return prev;
const next = { ...prev };
delete next[collectionLayerId];
return next;
}
// Rebuild the full collection layer per item so interactions on the
// wrapper itself are bound, then remap ids once.
const newLayers: Layer[] = itemIds.map(itemId => {
const fullLayer: Layer = { ...collectionLayer, children: layerTemplate };
return remapLayerIdsForCollectionItem(fullLayer, `-fc-${itemId}`);
});
if (append) {
return {
...prev,
[collectionLayerId]: [...(prev[collectionLayerId] || []), ...newLayers],
};
}
return { ...prev, [collectionLayerId]: newLayers };
});
};
window.addEventListener(ITEMS_INJECTED_EVENT, handler);
return () => window.removeEventListener(ITEMS_INJECTED_EVENT, handler);
}, []);
const effectiveLayers = useMemo(() => {
const extras = Object.values(extrasByCollection).flat();
return extras.length > 0 ? [...layers, ...extras] : layers;
}, [layers, extrasByCollection]);
// Register a color variable resolver so backgroundColor tweens that
// reference color variables (e.g. "color:var(--id)") can be resolved to a
// concrete rgba value GSAP can interpolate.
useEffect(() => {
setColorVariableResolver((id) => useColorVariablesStore.getState().getVariableById(id)?.value);
return () => setColorVariableResolver(null);
}, []);
// Inject initial animation CSS for subtrees not covered by the page-level style tag
// (e.g. components embedded in rich text whose layer IDs are namespaced differently)
useEffect(() => {
if (!injectInitialCSS) return;
const { css, hiddenLayerInfo } = generateInitialAnimationCSS(effectiveLayers);
if (css) {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
styleRef.current = style;
// Apply data-gsap-hidden to elements that should start hidden
hiddenLayerInfo.forEach(({ layerId, breakpoints }) => {
const el = getElement(layerId);
if (el) {
el.setAttribute('data-gsap-hidden', breakpoints || '');
}
});
}
return () => {
if (styleRef.current) {
styleRef.current.remove();
styleRef.current = null;
}
};
}, [injectInitialCSS, effectiveLayers]);
// Listen for breakpoint changes on resize
useEffect(() => {
const handleResize = () => {
const newBreakpoint = getCurrentBreakpoint();
setCurrentBreakpoint((prev) => (prev !== newBreakpoint ? newBreakpoint : prev));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const collectedInteractions = collectInteractions(effectiveLayers);
const hiddenLayerInfo = collectHiddenLayerInfo(collectedInteractions);
const isBreakpointChange = prevBreakpointRef.current !== null && prevBreakpointRef.current !== currentBreakpoint;
// Reset animation states when breakpoint changes
if (isBreakpointChange) {
// Snapshot user-toggled show/hide state (e.g. tab switchers) before the
// reset wipes it, so a resize crossing a breakpoint doesn't revert
// click/hover toggles back to their on-load default.
const interactiveDisplayTargets = collectInteractiveDisplayTargets(collectedInteractions);
const toggledDisplayState = new Map<string, string | null>();
interactiveDisplayTargets.forEach((layerId) => {
// Skip breakpoint-specific on-load hides so responsive intent still resets.
if (!isUniformOnLoadHide(hiddenLayerInfo.get(layerId))) return;
const el = getElement(layerId);
if (el) toggledDisplayState.set(layerId, el.getAttribute('data-gsap-hidden'));
});
resetAnimationStates(collectedInteractions, hiddenLayerInfo, currentBreakpoint);
// Restore the captured visibility so the user's current selection survives.
toggledDisplayState.forEach((value, layerId) => {
const el = getElement(layerId);
if (!el) return;
if (value === null) {
el.removeAttribute('data-gsap-hidden');
} else {
el.setAttribute('data-gsap-hidden', value);
}
});
playedOneShotInteractionsRef.current = new Set();
}
// Update previous breakpoint reference
prevBreakpointRef.current = currentBreakpoint;
// Detach animations from the previous run. One-shot intros (load,
// scroll-into-view) that have already played keep their timeline so
// effect re-runs (filter/load-more state changes, Suspense resolution)
// don't freeze a mid-flight animation. The early break in the
// per-trigger switch below avoids rebuilding their timelines/triggers.
//
// `scrollTrigger.kill()` defaults to also killing its linked timeline,
// so a played trigger must be detached with `kill(false, true)` to keep
// the animation running. Defined as a closure so the unmount/return
// cleanup can reuse it.
const detachAnimations = () => {
const playedIds = playedOneShotInteractionsRef.current;
scrollTriggersRef.current.forEach((st, interactionId) => {
// allowAnimation = played → don't kill the in-flight timeline.
st.kill(false, playedIds.has(interactionId));
scrollTriggersRef.current.delete(interactionId);
});
timelinesRef.current.forEach((tl, interactionId) => {
if (!playedIds.has(interactionId)) {
tl.kill();
timelinesRef.current.delete(interactionId);
}
});
};
cleanupRef.current.forEach((cleanup) => cleanup());
cleanupRef.current = [];
detachAnimations();
collectedInteractions.forEach(({ triggerLayerId, interaction }) => {
const triggerElement = getElement(triggerLayerId);
if (!triggerElement) return;
const { trigger } = interaction;
// Intro triggers (load, scroll-into-view) get their `from` state via
// server-emitted CSS. For hover/click, paint the `from` state inline so
// the resting appearance matches the author's intent instead of the
// layer's CSS default.
if (trigger === 'hover' || trigger === 'click') {
if (shouldRunOnBreakpoint(interaction, currentBreakpoint)) {
applyInitialFromState(interaction);
}
}
// Helper to get or create timeline (always lazy to avoid GSAP setting inline styles)
// Initial styles are handled by CSS via generateInitialAnimationCSS()
const getTimeline = (): gsap.core.Timeline | null => {
let tl = timelinesRef.current.get(interaction.id) || null;
if (!tl) {
tl = buildTimeline(interaction);
if (tl) timelinesRef.current.set(interaction.id, tl);
}
return tl;
};
switch (trigger) {
case 'load': {
if (!shouldRunOnBreakpoint(interaction, currentBreakpoint)) break;
// Skip if this interaction has already played. Stops effect
// re-runs (caused by filter/load-more state changes) from
// re-playing load animations on existing elements, and also
// prevents re-playing for filter items that get re-injected.
if (playedOneShotInteractionsRef.current.has(interaction.id)) break;
playedOneShotInteractionsRef.current.add(interaction.id);
const timeline = getTimeline();
timeline?.play();
break;
}
case 'click': {
let isForward = true;
const isLooped = (interaction.timeline?.repeat ?? 0) !== 0;
const handleClick = () => {
// Check breakpoint at trigger time for interactive triggers
if (!shouldRunOnBreakpoint(interaction, getCurrentBreakpoint())) return;
const timeline = getTimeline();
if (!timeline) return;
if (isLooped) {
if (timeline.isActive()) {
timeline.pause();
} else {
timeline.play();
}
} else if (interaction.timeline?.yoyo) {
if (isForward) {
timeline.play();
} else {
timeline.reverse();
}
isForward = !isForward;
} else {
timeline.invalidate().restart();
}
};
triggerElement.addEventListener('click', handleClick);
cleanupRef.current.push(() => triggerElement.removeEventListener('click', handleClick));
break;
}
case 'hover': {
const handleMouseEnter = () => {
// Check breakpoint at trigger time for interactive triggers
if (!shouldRunOnBreakpoint(interaction, getCurrentBreakpoint())) return;
getTimeline()?.play();
};
const handleMouseLeave = () => {
// Check breakpoint at trigger time for interactive triggers
if (!shouldRunOnBreakpoint(interaction, getCurrentBreakpoint())) return;
if (interaction.timeline?.yoyo) {
timelinesRef.current.get(interaction.id)?.reverse();
}
};
triggerElement.addEventListener('mouseenter', handleMouseEnter);
triggerElement.addEventListener('mouseleave', handleMouseLeave);
cleanupRef.current.push(() => {
triggerElement.removeEventListener('mouseenter', handleMouseEnter);
triggerElement.removeEventListener('mouseleave', handleMouseLeave);
});
break;
}
case 'scroll-into-view': {
if (!shouldRunOnBreakpoint(interaction, currentBreakpoint)) break;
// Already fired in a previous effect run — don't recreate the
// trigger (which would re-fire `play` if the element is still in
// viewport after a filter/load-more state change).
if (playedOneShotInteractionsRef.current.has(interaction.id)) break;
const scrollStart = interaction.timeline?.scrollStart || 'top 80%';
const toggleActions = interaction.timeline?.toggleActions || 'play none none none';
const timeline = getTimeline();
if (!timeline) break;
const scrollTrigger = ScrollTrigger.create({
trigger: triggerElement,
start: scrollStart,
toggleActions,
animation: timeline as any,
onEnter: () => {
playedOneShotInteractionsRef.current.add(interaction.id);
},
});
// Tracked in scrollTriggersRef (not cleanupRef) so a played trigger
// can be detached on re-run without killing its in-flight timeline.
scrollTriggersRef.current.set(interaction.id, scrollTrigger);
break;
}
case 'while-scrolling': {
// Skip if breakpoint restriction not met
if (!shouldRunOnBreakpoint(interaction, currentBreakpoint)) break;
// Scrub animations require timeline upfront
const timeline = getTimeline();
if (!timeline) break;
const scrollStart = interaction.timeline?.scrollStart || 'top bottom';
const scrollEnd = interaction.timeline?.scrollEnd || 'bottom top';
const scrub = interaction.timeline?.scrub ?? 1;
const scrollTrigger = ScrollTrigger.create({
trigger: triggerElement,
start: scrollStart,
end: scrollEnd,
scrub,
animation: timeline,
});
cleanupRef.current.push(() => scrollTrigger.kill());
break;
}
}
});
// Watch for tracked elements being remounted (e.g. dynamic chunk loading
// for LoadMoreCollection/FilterableCollection replaces the Suspense fallback
// with the real subtree). When a node carrying a tracked data-layer-id
// appears in the DOM, bump rebindTick to re-run this effect against the
// fresh nodes — re-applying gsap.set() and rebinding listeners.
const trackedLayerIds = new Set<string>();
collectedInteractions.forEach(({ triggerLayerId, interaction }) => {
trackedLayerIds.add(triggerLayerId);
(interaction.tweens || []).forEach(t => trackedLayerIds.add(t.layer_id));
});
let rebindTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRebind = () => {
if (rebindTimer) return;
rebindTimer = setTimeout(() => {
rebindTimer = null;
setRebindTick(t => t + 1);
}, 50);
};
const containsTrackedId = (node: Node): boolean => {
if (!(node instanceof Element)) return false;
const id = node.getAttribute('data-layer-id');
if (id && trackedLayerIds.has(id)) return true;
// Check descendants — items often nest deeply
const descendants = node.querySelectorAll('[data-layer-id]');
for (const el of descendants) {
const cid = el.getAttribute('data-layer-id');
if (cid && trackedLayerIds.has(cid)) return true;
}
return false;
};
const remountObserver = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type !== 'childList') continue;
// Skip Swiper's internal slide reshuffling (loop/transition) so a
// slide change doesn't rebind animations and reset user-toggled
// click/hover states (e.g. an open nav menu) elsewhere on the page.
if (isInsideSlider(m.target)) continue;
for (const n of m.addedNodes) {
if (containsTrackedId(n)) {
scheduleRebind();
return;
}
}
}
});
remountObserver.observe(document.body, { childList: true, subtree: true });
// Capture ref values for cleanup
const cleanups = cleanupRef.current;
return () => {
if (rebindTimer) clearTimeout(rebindTimer);
remountObserver.disconnect();
cleanups.forEach((cleanup) => cleanup());
// Detach played one-shots without killing their timeline (see note at
// top of effect), then sweep any remaining triggers. Preserved
// timelines have already been unlinked via kill(false, true), so the
// sweep below can't touch them.
detachAnimations();
ScrollTrigger.getAll().forEach((st) => st.kill());
};
}, [effectiveLayers, currentBreakpoint, rebindTick]);
return null;
}