返回 presentation-ai
usePresentationAutoScroll.ts
根目录 / src / hooks / presentation / usePresentationAutoScroll.ts
1 import { useEffect, useRef } from "react";
2
3 import { usePresentationState } from "@/states/presentation-state";
4
5 interface UsePresentationAutoScrollOptions {
6 viewportElement: HTMLDivElement | null;
7 scrollElement: HTMLDivElement | null;
8 bottomThreshold?: number;
9 disabled?: boolean;
10 }
11
12 function isAtBottom(
13 viewportElement: HTMLDivElement,
14 bottomThreshold: number,
15 ): boolean {
16 return (
17 viewportElement.scrollHeight -
18 viewportElement.scrollTop -
19 viewportElement.clientHeight <=
20 bottomThreshold
21 );
22 }
23
24 export function usePresentationAutoScroll({
25 viewportElement,
26 scrollElement,
27 bottomThreshold = 24,
28 disabled = false,
29 }: UsePresentationAutoScrollOptions) {
30 const isGeneratingPresentation = usePresentationState(
31 (state) => state.isGeneratingPresentation,
32 );
33 const slides = usePresentationState((state) => state.slides);
34
35 const hasUserScrolledUpRef = useRef(false);
36 const hasPendingWheelRef = useRef(false);
37 const isProgrammaticScrollRef = useRef(false);
38 const scrollFrameRef = useRef<number | null>(null);
39
40 function stopAutoScroll() {
41 if (scrollFrameRef.current !== null) {
42 window.cancelAnimationFrame(scrollFrameRef.current);
43 scrollFrameRef.current = null;
44 }
45
46 isProgrammaticScrollRef.current = false;
47 }
48
49 function getBottomTargetTop(): number {
50 if (!viewportElement || !scrollElement) {
51 return 0;
52 }
53
54 return Math.max(
55 0,
56 scrollElement.offsetTop +
57 scrollElement.offsetHeight -
58 viewportElement.clientHeight,
59 );
60 }
61
62 function smoothScrollToBottom() {
63 if (
64 disabled ||
65 !isGeneratingPresentation ||
66 !viewportElement ||
67 !scrollElement ||
68 hasUserScrolledUpRef.current
69 ) {
70 return;
71 }
72
73 if (scrollFrameRef.current !== null) {
74 return;
75 }
76
77 const step = () => {
78 if (
79 disabled ||
80 !isGeneratingPresentation ||
81 !viewportElement ||
82 !scrollElement ||
83 hasUserScrolledUpRef.current
84 ) {
85 stopAutoScroll();
86 return;
87 }
88
89 const targetTop = getBottomTargetTop();
90 const currentTop = viewportElement.scrollTop;
91 const delta = targetTop - currentTop;
92
93 if (Math.abs(delta) <= 1) {
94 viewportElement.scrollTo({
95 top: targetTop,
96 behavior: "auto",
97 });
98 stopAutoScroll();
99 return;
100 }
101
102 isProgrammaticScrollRef.current = true;
103
104 viewportElement.scrollTo({
105 top: currentTop + delta * 0.18,
106 behavior: "auto",
107 });
108
109 scrollFrameRef.current = window.requestAnimationFrame(step);
110 };
111
112 scrollFrameRef.current = window.requestAnimationFrame(step);
113 }
114
115 useEffect(() => {
116 if (disabled || !viewportElement) {
117 return;
118 }
119
120 const handleWheel = (event: WheelEvent) => {
121 if (event.deltaY < 0) {
122 stopAutoScroll();
123 hasPendingWheelRef.current = false;
124 hasUserScrolledUpRef.current = true;
125 return;
126 }
127
128 if (isProgrammaticScrollRef.current) {
129 return;
130 }
131
132 hasPendingWheelRef.current = true;
133 };
134
135 const handleScroll = () => {
136 if (isProgrammaticScrollRef.current) {
137 return;
138 }
139
140 const atBottom = isAtBottom(viewportElement, bottomThreshold);
141
142 if (atBottom) {
143 hasUserScrolledUpRef.current = false;
144 hasPendingWheelRef.current = false;
145 return;
146 }
147
148 if (hasPendingWheelRef.current) {
149 hasUserScrolledUpRef.current = true;
150 hasPendingWheelRef.current = false;
151 }
152 };
153
154 viewportElement.addEventListener("wheel", handleWheel, { passive: true });
155 viewportElement.addEventListener("scroll", handleScroll, { passive: true });
156
157 return () => {
158 viewportElement.removeEventListener("wheel", handleWheel);
159 viewportElement.removeEventListener("scroll", handleScroll);
160 };
161 }, [bottomThreshold, disabled, viewportElement]);
162
163 useEffect(() => {
164 if (!isGeneratingPresentation || disabled) {
165 stopAutoScroll();
166 hasUserScrolledUpRef.current = false;
167 hasPendingWheelRef.current = false;
168 return;
169 }
170
171 smoothScrollToBottom();
172 }, [
173 disabled,
174 isGeneratingPresentation,
175 slides,
176 viewportElement,
177 scrollElement,
178 ]);
179
180 useEffect(() => {
181 return () => {
182 stopAutoScroll();
183 };
184 }, []);
185 }
186
186 lines TYPESCRIPT