295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
'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<gsap.core.Timeline | null>(null)
|
|
const scrollerRef = useRef<Element | Window | null>(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<Array<() => 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<ResizeObserver | null>(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 (
|
|
<GsapContext.Provider value={{ addAnimation, schedule, onReady, requestRefresh: scheduleRefresh, resetTimeline, resumeTimeline, getScroller }}>
|
|
{children}
|
|
</GsapContext.Provider>
|
|
)
|
|
}
|