'use client' import { useGSAP } from '@gsap/react' import gsap from 'gsap' import { SplitText } from 'gsap/SplitText' import { ScrollTrigger, GSDevTools } from 'gsap/all' import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' gsap.registerPlugin(useGSAP) gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(SplitText) gsap.registerPlugin(GSDevTools) // iOS Safari shows/hides its address bar at the scroll extremes, which resizes // the viewport and makes ScrollTrigger refresh + fire spurious onLeave/onEnter // toggles (text animating out at the bottom and not coming back). Ignoring those // mobile-toolbar resizes keeps the real enter/leave reverse behavior intact. ScrollTrigger.config({ ignoreMobileResize: true }) // Flip to true to draw ScrollTrigger start/end markers on every animated // element and mount the GSDevTools timeline scrubber. Handy for seeing exactly // where each card's enter/exit lines sit relative to the viewport. export const GSAP_DEBUG = false export function nearestScroller(el: Element): Element | Window { let node: Element | null = el.parentElement while (node) { if (node.getAttribute('data-slot') === 'scroll-area-viewport') { const viewport = node as HTMLElement const rect = viewport.getBoundingClientRect() const hasUsableBox = rect.width > 0 && rect.height > 0 const canScroll = viewport.scrollHeight > viewport.clientHeight || viewport.scrollWidth > viewport.clientWidth if (hasUsableBox && canScroll) return viewport } node = node.parentElement } return window } const GsapContext = createContext<{ // Add a real animation (with its own duration) to the entrance timeline. addAnimation: ( animation: gsap.core.TimelineChild, position: gsap.Position ) => void, // Schedule a zero-duration callback at `position` — used to *start* an // independent reveal tween so the timeline only orchestrates timing. schedule: (fn: () => void, position: gsap.Position) => void, // Run `cb` once the entrance is done (timeline complete or first user scroll). onReady: (cb: () => void) => void, // Re-measure all ScrollTriggers (throttled to once per frame). Call it // whenever an animation changes content height so trigger positions stay // aligned with the real layout. requestRefresh: () => void, resetTimeline: () => void, resumeTimeline: () => void, getScroller: () => Element | Window | null } | null>(null) export function useGsapContext() { return useContext(GsapContext) } export const useTimeLine = (dep:any,all?:boolean) => { const gsapContext = useGsapContext() useEffect(() => { if (GSAP_DEBUG) { console.log("[cv-debug][useTimeLine:effect]", { hasDep: !!dep, isArray: dep instanceof Array, length: dep instanceof Array ? dep.length : undefined, all, }) } if (dep instanceof Array && all) { let acc = true; let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc ) if (allDepsSatisfied) { if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume-all]") gsapContext?.resumeTimeline() } } else { if (dep) { if (GSAP_DEBUG) console.log("[cv-debug][useTimeLine:resume]") gsapContext?.resumeTimeline() } } },[dep]) useLayoutEffect(() => { return () => { gsapContext?.resetTimeline() } },[]) } export default function GsapProvider({ children }: { children: ReactNode }) { const tl = useRef(null) const scrollerRef = useRef(null) const getScroller = useCallback(() => { // const cached = scrollerRef.current // if (!cached || (cached instanceof Element && !document.contains(cached))) { let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]') if (scrollers.length < 1) { scrollerRef.current = window } else { let scrollerArray = Array.from(scrollers.values()).sort((a,b) => { const s1 = a as HTMLDivElement; const s2 = b as HTMLDivElement; // using bitwise not (~~) to coerce NaN values to 0 const aPriority = ~~Number(s1.dataset?.scrollerPriority) const bPriority = ~~Number(s2.dataset?.scrollerPriority) return aPriority - bPriority; }) let prioScroller = scrollerArray.pop(); scrollerRef.current = prioScroller || window; } // } return scrollerRef.current }, []) const devToolsCreated = useRef(false) useGSAP(() => { if (!tl.current) { tl.current = gsap.timeline({ paused: true }) } if (GSAP_DEBUG && tl.current && !devToolsCreated.current) { devToolsCreated.current = true GSDevTools.create({ animation: tl.current }) } return () => { if (GSAP_DEBUG) console.log("gsap cleanup") } }) // Handoff: fire registered callbacks once, when the entrance finishes. const readyFired = useRef(false) const readyCbs = useRef void>>([]) const fireReady = useCallback(() => { if (readyFired.current) return if (GSAP_DEBUG) { console.log("[cv-debug][gsap:ready]", { callbacks: readyCbs.current.length, duration: tl.current?.duration(), progress: tl.current?.progress(), }) } readyFired.current = true readyCbs.current.forEach((cb) => cb()) readyCbs.current = [] },[]) const onReady = useCallback((cb: () => void) => { if (GSAP_DEBUG) console.log("[cv-debug][gsap:onReady]", { readyFired: readyFired.current }) if (readyFired.current) cb() else readyCbs.current.push(cb) },[]) const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => { // Content can mount in waves (e.g. nested queries resolving after the // entrance already played). Parking a tween in a finished, paused timeline // would freeze it at its from-state, so once the entrance is done let the // (live) tween play on its own instead. if (GSAP_DEBUG) { console.log("[cv-debug][gsap:addAnimation]", { position, readyFired: readyFired.current, durationBefore: tl.current?.duration(), }) } if (readyFired.current) return tl.current?.add(animation, position); if (GSAP_DEBUG) { console.log("[cv-debug][gsap:addAnimation:done]", { position, durationAfter: tl.current?.duration(), children: tl.current?.getChildren(false, true, true).length, }) } },[]) const schedule = useCallback((fn: () => void, position: gsap.Position) => { // Same late-arrival case: a callback added past the playhead never fires, so // run the reveal immediately once the entrance has finished. if (GSAP_DEBUG) { console.log("[cv-debug][gsap:schedule]", { position, readyFired: readyFired.current, durationBefore: tl.current?.duration(), childrenBefore: tl.current?.getChildren(false, true, true).length, }) } if (readyFired.current) { fn(); return } tl.current?.add(fn, position) if (GSAP_DEBUG) { console.log("[cv-debug][gsap:schedule:done]", { position, durationAfter: tl.current?.duration(), childrenAfter: tl.current?.getChildren(false, true, true).length, }) } },[]) // Throttle ScrollTrigger.refresh() to once per frame so the ResizeObserver // can fire freely while content height animates. const refreshQueued = useRef(false) const scheduleRefresh = useCallback(() => { if (refreshQueued.current) return refreshQueued.current = true requestAnimationFrame(() => { refreshQueued.current = false ScrollTrigger.refresh() }) },[]) const scrollCleanup = useRef<(() => void) | null>(null) const resizeObserver = useRef(null) const resetTimeline = useCallback(() => { if (GSAP_DEBUG) { console.log("[cv-debug][gsap:reset]", { duration: tl.current?.duration(), progress: tl.current?.progress(), }) } tl.current?.kill() tl.current?.revert() ScrollTrigger.getAll().forEach(st => st.kill()) resizeObserver.current?.disconnect() scrollCleanup.current?.() scrollCleanup.current = null readyFired.current = false readyCbs.current = [] tl.current = gsap.timeline({paused:true}) },[]) const resumeTimeline = useCallback(() => { const t = tl.current if (!t) { if (GSAP_DEBUG) console.log("[cv-debug][gsap:resume:skip-no-timeline]") return } if (GSAP_DEBUG) { console.log("[cv-debug][gsap:resume:start]", { duration: t.duration(), progress: t.progress(), paused: t.paused(), readyFired: readyFired.current, children: t.getChildren(false, true, true).length, }) } // When the orchestrated entrance finishes, hand off to scroll control and // realign triggers against the now-settled layout. t.eventCallback("onComplete", () => { fireReady(); ScrollTrigger.refresh() }) const scroller = getScroller() // If the user scrolls before the entrance finishes, snap it to the end and // switch to scroll control so the timeline and ScrollTriggers never fight // over the same elements. scrollCleanup.current?.() const onFirstScroll = () => { t.progress(1); fireReady() } scroller?.addEventListener("scroll", onFirstScroll, { once: true, passive: true }) scrollCleanup.current = () => scroller?.removeEventListener("scroll", onFirstScroll) // Continuously realign triggers while content height changes — entrance // growth, scroll-driven collapses, late-loading media. if (scroller instanceof Element) { const target = scroller.firstElementChild ?? scroller resizeObserver.current?.disconnect() resizeObserver.current = new ResizeObserver(scheduleRefresh) resizeObserver.current.observe(target) } t.resume() if (GSAP_DEBUG) { console.log("[cv-debug][gsap:resume:after]", { duration: t.duration(), progress: t.progress(), paused: t.paused(), }) } },[getScroller, fireReady, scheduleRefresh]) // Fonts/markdown/images loading also change content height after the triggers // were created; refresh so start/end stay aligned with the real card sizes. useEffect(() => { const refresh = () => ScrollTrigger.refresh() window.addEventListener("load", refresh) document.fonts?.ready.then(refresh).catch(() => {}) return () => window.removeEventListener("load", refresh) }, []) return ( {children} ) }