135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
'use client'
|
|
|
|
import { useGSAP } from "@gsap/react"
|
|
import { ScrollTrigger } from "gsap/all"
|
|
import type { RefObject } from "react"
|
|
import { GSAP_DEBUG, nearestScroller, useGsapContext } from "~/app/_providers/GsapProvicer"
|
|
|
|
export type UseRevealOptions = {
|
|
position: gsap.Position
|
|
/** Skip the orchestrated entrance and let ScrollTrigger drive from the start. */
|
|
scrollOnly?: boolean
|
|
/**
|
|
* Reveal once and keep it: after the element animates in (entrance or first
|
|
* scroll-in) it never reverses on leave. Default false = animate out at the
|
|
* top and back in on scroll-up.
|
|
*/
|
|
once?: boolean
|
|
debugId?: string
|
|
/**
|
|
* Build the hidden -> shown animation for `el`. It must be a single,
|
|
* *independent* animation (not added to any timeline): `play()` reveals,
|
|
* `reverse()` hides. The hook pauses it, schedules its entrance through the
|
|
* shared timeline, and lets a ScrollTrigger drive the very same animation on
|
|
* scroll — so the two modes never fight over the element.
|
|
*/
|
|
makeReveal: (el: HTMLElement) => gsap.core.Tween | gsap.core.Timeline
|
|
}
|
|
|
|
/**
|
|
* Shared reveal behavior for cards, text and pop-ups: an element in view at
|
|
* load plays an orchestrated timeline entrance, then hands the *same* tween to
|
|
* a ScrollTrigger that animates it out at the top and back in on scroll-up. An
|
|
* element off-screen at load is ScrollTrigger-driven from the start.
|
|
*/
|
|
export function useReveal(
|
|
ref: RefObject<HTMLElement | null>,
|
|
{ position, scrollOnly = false, once = false, debugId, makeReveal }: UseRevealOptions,
|
|
) {
|
|
const ctx = useGsapContext()
|
|
useGSAP(() => {
|
|
const el = ref.current
|
|
if (!el || !ctx) {
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:skip]", { debugId, hasEl: !!el, hasCtx: !!ctx })
|
|
return
|
|
}
|
|
|
|
const scroller = nearestScroller(el)
|
|
const scrollerEl = scroller instanceof Element ? scroller : undefined
|
|
|
|
const rect = el.getBoundingClientRect()
|
|
let top = 0
|
|
let bottom = window.innerHeight
|
|
if (scrollerEl) {
|
|
const r = scrollerEl.getBoundingClientRect()
|
|
top = r.top
|
|
bottom = r.top + r.height
|
|
}
|
|
const isInView = rect.bottom > top && rect.top < bottom
|
|
if (GSAP_DEBUG) {
|
|
const scrollerRect = scrollerEl?.getBoundingClientRect()
|
|
console.log("[cv-debug][useReveal:register]", {
|
|
debugId,
|
|
position,
|
|
scrollOnly,
|
|
once,
|
|
isInView,
|
|
rect: { top: rect.top, bottom: rect.bottom, height: rect.height },
|
|
viewport: { top, bottom },
|
|
scroller:
|
|
scroller === window
|
|
? "window"
|
|
: {
|
|
slot: scrollerEl?.getAttribute("data-slot"),
|
|
className: scrollerEl?.className,
|
|
clientHeight: scrollerEl?.clientHeight,
|
|
scrollHeight: scrollerEl?.scrollHeight,
|
|
rect: scrollerRect ? { top: scrollerRect.top, bottom: scrollerRect.bottom, height: scrollerRect.height } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
const reveal = makeReveal(el)
|
|
// A reveal that animates height (pop-ups) shifts every trigger below it.
|
|
// Re-measure as it animates so positions track the real layout instead of
|
|
// only correcting at the very end. requestRefresh is throttled + deferred to
|
|
// the next frame, so this won't re-enter a ScrollTrigger callback.
|
|
reveal.eventCallback("onUpdate", () => ctx.requestRefresh())
|
|
reveal.pause()
|
|
|
|
const baseTrigger = {
|
|
trigger: el,
|
|
start: "top bottom",
|
|
end: "bottom top",
|
|
scroller: scrollerEl,
|
|
markers: GSAP_DEBUG,
|
|
id: GSAP_DEBUG ? debugId : undefined,
|
|
}
|
|
// Full behavior: in at the bottom, out at the top, and back on scroll-up.
|
|
const addReplayTrigger = () =>
|
|
ScrollTrigger.create({
|
|
...baseTrigger,
|
|
onEnter: () => reveal.play(),
|
|
onEnterBack: () => reveal.play(),
|
|
onLeave: () => reveal.reverse(),
|
|
onLeaveBack: () => reveal.reverse(),
|
|
})
|
|
|
|
if (isInView && !scrollOnly) {
|
|
// The shared timeline only decides *when* the entrance starts; the reveal
|
|
// plays independently so the ScrollTrigger can take it over afterwards.
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:schedule]", { debugId, position })
|
|
ctx.schedule(() => reveal.play(), position)
|
|
// `once` elements keep their revealed state — no scroll trigger at all.
|
|
if (!once) {
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:onReady]", { debugId })
|
|
ctx.onReady(addReplayTrigger)
|
|
}
|
|
} else if (isInView) {
|
|
// scrollOnly + already on screen: no enter crossing will fire, so reveal
|
|
// now. Keep a trigger for scroll-out unless this is a `once` element.
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:play-now]", { debugId, position })
|
|
reveal.play()
|
|
if (!once) addReplayTrigger()
|
|
} else if (once) {
|
|
// Off-screen: reveal when first reached, then self-destruct so it never
|
|
// reverses.
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:scroll-once]", { debugId, position })
|
|
ScrollTrigger.create({ ...baseTrigger, once: true, onEnter: () => reveal.play() })
|
|
} else {
|
|
if (GSAP_DEBUG) console.log("[cv-debug][useReveal:scroll-trigger-only]", { debugId, position })
|
|
addReplayTrigger()
|
|
}
|
|
}, { dependencies: [] })
|
|
}
|