返回 DeepSeek-Reasonix
useEntranceAnimation.ts
根目录 / desktop / frontend / src / lib / useEntranceAnimation.ts
1 import { useEffect, useRef } from "react";
2 import gsap from "gsap";
3 import { DUR_SLOW, EASE_OUT, prefersReducedMotion } from "./gsapAnimations";
4
5 // Animates each data-entrance element in once. First mount (and every
6 // resetKey change) pre-seeds the seen set so restored history never animates;
7 // the scan only runs when deps changes, skipping streaming token updates.
8 export function useEntranceAnimation<T extends HTMLElement>(
9 resetKey?: unknown,
10 deps?: unknown,
11 selector = "[data-entrance]",
12 ) {
13 const ref = useRef<T | null>(null);
14 const seen = useRef(new Set<string>());
15 const timerRef = useRef<number | null>(null);
16 const firstRun = useRef(true);
17 const prevResetKey = useRef(resetKey);
18
19 // Reset on session switch.
20 if (prevResetKey.current !== resetKey) {
21 prevResetKey.current = resetKey;
22 seen.current = new Set();
23 firstRun.current = true;
24 if (timerRef.current !== null) {
25 clearTimeout(timerRef.current);
26 timerRef.current = null;
27 }
28 }
29
30 // Single effect: on first mount, pre-seed the seen set (no animation).
31 // On subsequent deps changes, animate only newly-added elements.
32 // This avoids the double querySelectorAll that two separate effects cause.
33 useEffect(() => {
34 const container = ref.current;
35 if (!container) return;
36
37 const entries: HTMLElement[] = [];
38 container.querySelectorAll(selector).forEach((el) => {
39 const id = el.getAttribute("data-entrance");
40 if (id && !seen.current.has(id)) {
41 seen.current.add(id);
42 // First run: just record IDs, don't animate history items.
43 if (firstRun.current) return;
44 entries.push(el as HTMLElement);
45 }
46 });
47
48 if (firstRun.current) {
49 firstRun.current = false;
50 return; // Pre-seeded — no entrance animation for history items.
51 }
52
53 if (entries.length === 0) return;
54
55 const reduced = prefersReducedMotion();
56 if (reduced) {
57 gsap.set(entries, { opacity: 1, clearProps: "transform" });
58 return;
59 }
60
61 // Batch: if multiple items arrive in the same tick, animate together.
62 if (timerRef.current !== null) clearTimeout(timerRef.current);
63 timerRef.current = window.setTimeout(() => {
64 timerRef.current = null;
65 gsap.fromTo(
66 entries,
67 { opacity: 0, y: 12 },
68 {
69 opacity: 1,
70 y: 0,
71 duration: DUR_SLOW,
72 ease: EASE_OUT,
73 stagger: itemsStagger(entries.length),
74 clearProps: "transform",
75 },
76 );
77 }, 16);
78
79 return () => {
80 if (timerRef.current !== null) clearTimeout(timerRef.current);
81 };
82 // Only re-scan when deps change — NOT on every render.
83 }, [deps]); // eslint-disable-line react-hooks/exhaustive-deps
84
85 return ref;
86 }
87
88 function itemsStagger(count: number): number {
89 if (count <= 1) return 0;
90 if (count <= 3) return 0.06;
91 return 0.04;
92 }
93
93 lines TYPESCRIPT