返回 DeepSeek-Reasonix
useEntranceAnimation.ts
根目录 / desktop / frontend / src / lib / useEntranceAnimation.ts
1 import { useEffect, useMemo, useRef } from "react";
2 import { CSS_EASE_OUT, DUR_SLOW, prefersReducedMotion } from "./motion";
3
4 // Animates each data-entrance element in once. First mount (and every
5 // resetKey change) pre-seeds the seen set so restored history never animates;
6 // the scan only runs when deps changes, skipping streaming token updates.
7 export function useEntranceAnimation<T extends HTMLElement>(
8 resetKey?: unknown,
9 deps?: unknown,
10 selector = "[data-entrance]",
11 seedIds: readonly string[] = [],
12 ) {
13 const ref = useRef<T | null>(null);
14 // Virtualized history rows may mount after the first DOM scan. Seed their
15 // model IDs up front so a later append never mistakes restored rows for new
16 // content and animates the whole viewport.
17 const seen = useRef(new Set<string>());
18 const seeded = useRef(false);
19 if (!seeded.current) {
20 seen.current = new Set(seedIds);
21 seeded.current = true;
22 }
23 const timerRef = useRef<number | null>(null);
24 const timerAnimations = useRef<Animation[]>([]);
25 const firstRun = useRef(true);
26 const prevResetKey = useRef(resetKey);
27
28 // Reset on session switch.
29 if (prevResetKey.current !== resetKey) {
30 prevResetKey.current = resetKey;
31 seen.current = new Set(seedIds);
32 firstRun.current = true;
33 if (timerRef.current !== null) {
34 clearTimeout(timerRef.current);
35 timerRef.current = null;
36 }
37 }
38
39 // Single effect: on first mount, pre-seed the seen set (no animation).
40 // On subsequent deps changes, animate only newly-added elements.
41 // This avoids the double querySelectorAll that two separate effects cause.
42 useEffect(() => {
43 const container = ref.current;
44 if (!container) return;
45
46 const entries: HTMLElement[] = [];
47 container.querySelectorAll(selector).forEach((el) => {
48 const id = el.getAttribute("data-entrance");
49 if (id && !seen.current.has(id)) {
50 seen.current.add(id);
51 // First run: just record IDs, don't animate history items.
52 if (firstRun.current) return;
53 entries.push(el as HTMLElement);
54 }
55 });
56
57 if (firstRun.current) {
58 firstRun.current = false;
59 return; // Pre-seeded — no entrance animation for history items.
60 }
61
62 if (entries.length === 0) return;
63
64 const reduced = prefersReducedMotion();
65 if (reduced) {
66 for (const entry of entries) {
67 entry.style.opacity = "1";
68 entry.style.transform = "";
69 }
70 return;
71 }
72
73 // Batch: if multiple items arrive in the same tick, animate together.
74 if (timerRef.current !== null) clearTimeout(timerRef.current);
75 timerRef.current = window.setTimeout(() => {
76 timerRef.current = null;
77 const animations = entries.map((entry, index) => {
78 const settle = () => {
79 entry.style.opacity = "1";
80 entry.style.transform = "";
81 };
82 if (typeof entry.animate !== "function") {
83 settle();
84 return null;
85 }
86 let animation: Animation;
87 try {
88 animation = entry.animate(
89 [
90 { opacity: 0, transform: "translateY(12px)" },
91 { opacity: 1, transform: "translateY(0)" },
92 ],
93 {
94 duration: DUR_SLOW * 1000,
95 easing: CSS_EASE_OUT,
96 delay: index * itemsStagger(entries.length) * 1000,
97 },
98 );
99 } catch {
100 // Entrance motion is cosmetic. Keep later entries running and expose
101 // this entry immediately if a WebView rejects the animation.
102 settle();
103 return null;
104 }
105 animation.onfinish = settle;
106 animation.oncancel = settle;
107 return animation;
108 });
109 timerAnimations.current = animations.filter((animation): animation is Animation => animation !== null);
110 }, 16);
111
112 return () => {
113 if (timerRef.current !== null) clearTimeout(timerRef.current);
114 for (const animation of timerAnimations.current) {
115 try {
116 animation.cancel();
117 } catch {
118 // Cancellation is cleanup-only; each entry already has a final style.
119 }
120 }
121 timerAnimations.current = [];
122 };
123 // Only re-scan when deps change — NOT on every render.
124 }, [deps]); // eslint-disable-line react-hooks/exhaustive-deps
125
126 return ref;
127 }
128
129 export function useTranscriptEntranceAnimation<T extends HTMLElement>(
130 tabId: string | undefined,
131 revealSignal: unknown,
132 items: readonly { id: string }[],
133 ) {
134 const seedIds = useMemo(() => items.map((item) => item.id), [items]);
135 // A tail append preserves this key; a surface switch, reveal, or history
136 // prepend resets it and pre-seeds every model ID before virtual rows mount.
137 const resetKey = transcriptEntranceResetKey(tabId, revealSignal, items);
138 return useEntranceAnimation<T>(resetKey, items.length, "[data-entrance]", seedIds);
139 }
140
141 export function transcriptEntranceResetKey(
142 tabId: string | undefined,
143 revealSignal: unknown,
144 items: readonly { id: string }[],
145 ): string {
146 return `${tabId ?? ""}|${String(revealSignal)}|${items[0]?.id ?? ""}`;
147 }
148
149 function itemsStagger(count: number): number {
150 if (count <= 1) return 0;
151 if (count <= 3) return 0.06;
152 return 0.04;
153 }
154
154 lines TYPESCRIPT