返回 DeepSeek-Reasonix
useGSAPCollapse.ts
根目录 / desktop / frontend / src / lib / useGSAPCollapse.ts
1 import { useLayoutEffect, useRef } from "react";
2 import gsap from "gsap";
3 import { DUR_BASE, EASE_OUT, prefersReducedMotion } from "./gsapAnimations";
4
5 /**
6 * useGSAPCollapse — animate a container's height between 0 and its
7 * scrollHeight whenever `open` flips. Replaces the old CSS max-height
8 * hack with a precise pixel-level GSAP tween.
9 *
10 * Usage:
11 * const ref = useRef<HTMLDivElement>(null);
12 * useGSAPCollapse(ref, open);
13 * return <div ref={ref}>{children}</div>;
14 *
15 * The container should have `overflow: hidden` in CSS. No extra wrapper
16 * elements needed.
17 */
18 export function useGSAPCollapse(
19 ref: React.RefObject<HTMLElement | null>,
20 open: boolean,
21 opts?: {
22 duration?: number;
23 ease?: string;
24 /** Called after the open animation completes. */
25 onOpenComplete?: () => void;
26 /** Called after the close animation completes. */
27 onCloseComplete?: () => void;
28 /** When closing, use this height as the starting point instead of
29 * measuring scrollHeight (which may have already shrunk due to
30 * content being conditionally removed). */
31 prevHeight?: number;
32 },
33 ) {
34 const prevOpen = useRef<boolean | null>(null);
35 const onOpenRef = useRef(opts?.onOpenComplete);
36 const onCloseRef = useRef(opts?.onCloseComplete);
37 onOpenRef.current = opts?.onOpenComplete;
38 onCloseRef.current = opts?.onCloseComplete;
39
40 useLayoutEffect(() => {
41 const el = ref.current;
42 if (!el) return;
43
44 // Skip the very first render — we don't want to animate from 0→auto
45 // on mount. Use a direct style write (no GSAP overhead) for the
46 // initial state so 400+ collapsed items don't each go through
47 // gsap.set property resolution.
48 if (prevOpen.current === null) {
49 prevOpen.current = open;
50 el.style.height = open ? "auto" : "0px";
51 return;
52 }
53
54 // No change — nothing to do.
55 if (prevOpen.current === open) return;
56 prevOpen.current = open;
57
58 const reduced = prefersReducedMotion();
59 const dur = reduced ? 0.001 : (opts?.duration ?? DUR_BASE);
60 const ease = opts?.ease ?? EASE_OUT;
61
62 // Kill any in-flight GSAP animations on this element so we always
63 // start from the current rendered height.
64 gsap.killTweensOf(el);
65
66 if (open) {
67 // Phase 1 — measure the target (auto) height without visible change.
68 gsap.set(el, { height: "auto" });
69 const targetHeight = el.scrollHeight;
70 // Phase 2 — animate from current (which is 0 or whatever the kill
71 // left us at) to the measured target height; then clear the inline
72 // style so the element returns to `height: auto` / CSS-driven flow.
73 gsap.fromTo(
74 el,
75 { height: 0 },
76 {
77 height: targetHeight,
78 duration: dur,
79 ease,
80 clearProps: "height",
81 onComplete: () => onOpenRef.current?.(),
82 },
83 );
84 } else {
85 // Close: if caller provided a pre-swap height use it as the start,
86 // otherwise measure the current (already-swapped) scrollHeight.
87 const startHeight = opts?.prevHeight && opts.prevHeight > 0
88 ? opts.prevHeight
89 : (gsap.set(el, { height: "auto" }), el.scrollHeight);
90 gsap.fromTo(
91 el,
92 { height: startHeight },
93 {
94 height: 0,
95 duration: dur,
96 ease,
97 onComplete: () => onCloseRef.current?.(),
98 },
99 );
100 }
101 }, [open, ref]);
102 }
103
103 lines TYPESCRIPT