返回 DeepSeek-Reasonix
useCollapseAnimation.ts
根目录 / desktop / frontend / src / lib / useCollapseAnimation.ts
1 import { useLayoutEffect, useRef } from "react";
2 import { CSS_EASE_OUT, DUR_BASE, prefersReducedMotion } from "./motion";
3
4 /**
5 * Animates a container's height between 0 and its scrollHeight whenever
6 * `open` flips. The visual transition always fails open: an unavailable or
7 * rejecting Web Animations implementation must still apply the requested
8 * final state and completion callback.
9 */
10 export function useCollapseAnimation(
11 ref: React.RefObject<HTMLElement | null>,
12 open: boolean,
13 opts?: {
14 duration?: number;
15 /** Called after the open animation completes or falls back. */
16 onOpenComplete?: () => void;
17 /** Called after the close animation completes or falls back. */
18 onCloseComplete?: () => void;
19 /** When closing, use this height as the starting point instead of
20 * measuring scrollHeight after content has changed. */
21 prevHeight?: number;
22 },
23 ) {
24 const prevOpen = useRef<boolean | null>(null);
25 const onOpenRef = useRef(opts?.onOpenComplete);
26 const onCloseRef = useRef(opts?.onCloseComplete);
27 onOpenRef.current = opts?.onOpenComplete;
28 onCloseRef.current = opts?.onCloseComplete;
29
30 useLayoutEffect(() => {
31 const el = ref.current;
32 if (!el) return;
33
34 // Initial history can contain hundreds of collapsed rows. Seed the final
35 // state directly before paint instead of constructing animations on mount.
36 if (prevOpen.current === null) {
37 prevOpen.current = open;
38 el.style.height = open ? "auto" : "0px";
39 return;
40 }
41
42 if (prevOpen.current === open) return;
43 prevOpen.current = open;
44
45 const configuredDuration = opts?.duration ?? DUR_BASE;
46 const duration = prefersReducedMotion()
47 ? 0
48 : (Number.isFinite(configuredDuration) && configuredDuration >= 0 ? configuredDuration : DUR_BASE) * 1000;
49 let superseded = false;
50 let settled = false;
51 const finish = () => {
52 if (superseded || settled) return;
53 settled = true;
54 el.style.height = open ? "auto" : "0px";
55 if (open) onOpenRef.current?.();
56 else onCloseRef.current?.();
57 };
58
59 if (duration === 0 || typeof el.animate !== "function") {
60 finish();
61 return;
62 }
63
64 const keyframes = open
65 ? [{ height: "0px" }, { height: `${el.scrollHeight}px` }]
66 : [
67 {
68 height: `${opts?.prevHeight && opts.prevHeight > 0
69 ? opts.prevHeight
70 : Math.max(el.getBoundingClientRect().height, el.scrollHeight)}px`,
71 },
72 { height: "0px" },
73 ];
74
75 let animation: Animation;
76 try {
77 animation = el.animate(keyframes, { duration, easing: CSS_EASE_OUT });
78 } catch {
79 finish();
80 return;
81 }
82
83 animation.onfinish = finish;
84 animation.oncancel = finish;
85 return () => {
86 // A dependency change supersedes this direction. Suppress the old final
87 // state/callback before cancellation so the next effect owns settlement.
88 superseded = true;
89 try {
90 animation.cancel();
91 } catch {
92 // Cancellation is cleanup-only; the next render already owns state.
93 }
94 };
95 }, [open, ref]);
96 }
97
97 lines TYPESCRIPT