返回 presentation-ai
usePresentationNavigation.ts
根目录 / src / hooks / presentation / usePresentationNavigation.ts
1 import { useEffect } from "react";
2
3 import { usePresentationRecordingState } from "@/states/presentation-recording-state";
4 import { usePresentationState } from "@/states/presentation-state";
5
6 const WHEEL_NAVIGATION_THRESHOLD = 42;
7 const WHEEL_NAVIGATION_COOLDOWN_MS = 420;
8 const PIXELS_PER_LINE = 16;
9 const SCROLL_EDGE_TOLERANCE_PX = 2;
10
11 function getWheelDeltaInPixels(event: WheelEvent): number {
12 if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
13 return event.deltaY * PIXELS_PER_LINE;
14 }
15
16 if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
17 return event.deltaY * window.innerHeight;
18 }
19
20 return event.deltaY;
21 }
22
23 function isElementVerticallyScrollable(element: HTMLElement): boolean {
24 const styles = window.getComputedStyle(element);
25 const canOverflow =
26 styles.overflowY === "auto" ||
27 styles.overflowY === "scroll" ||
28 styles.overflowY === "overlay";
29
30 return (
31 canOverflow &&
32 element.scrollHeight - element.clientHeight > SCROLL_EDGE_TOLERANCE_PX
33 );
34 }
35
36 function findScrollableAncestor(
37 target: EventTarget | null,
38 rootElement: HTMLElement,
39 ): HTMLElement | null {
40 if (!(target instanceof HTMLElement)) {
41 return null;
42 }
43
44 let element: HTMLElement | null = target;
45 while (element && rootElement.contains(element)) {
46 if (isElementVerticallyScrollable(element)) {
47 return element;
48 }
49
50 if (element === rootElement) {
51 break;
52 }
53
54 element = element.parentElement;
55 }
56
57 return null;
58 }
59
60 function getCurrentSlideScrollableElement(
61 target: EventTarget | null,
62 ): HTMLElement | null {
63 const { currentSlideId } = usePresentationState.getState();
64 if (!currentSlideId) {
65 return null;
66 }
67
68 const currentSlideContainer = document.querySelector<HTMLElement>(
69 `.slide-container-${CSS.escape(currentSlideId)}`,
70 );
71
72 if (!currentSlideContainer) {
73 return null;
74 }
75
76 const scrollableAncestor = findScrollableAncestor(
77 target,
78 currentSlideContainer,
79 );
80 if (scrollableAncestor) {
81 return scrollableAncestor;
82 }
83
84 if (isElementVerticallyScrollable(currentSlideContainer)) {
85 return currentSlideContainer;
86 }
87
88 const scrollableDescendants =
89 currentSlideContainer.querySelectorAll<HTMLElement>("*");
90 for (const element of scrollableDescendants) {
91 if (isElementVerticallyScrollable(element)) {
92 return element;
93 }
94 }
95
96 return null;
97 }
98
99 function canScrollInDirection(element: HTMLElement, deltaY: number): boolean {
100 if (deltaY > 0) {
101 return (
102 element.scrollTop + element.clientHeight <
103 element.scrollHeight - SCROLL_EDGE_TOLERANCE_PX
104 );
105 }
106
107 if (deltaY < 0) {
108 return element.scrollTop > SCROLL_EDGE_TOLERANCE_PX;
109 }
110
111 return false;
112 }
113
114 function shouldLetSlideScroll(
115 target: EventTarget | null,
116 deltaY: number,
117 ): boolean {
118 const scrollableElement = getCurrentSlideScrollableElement(target);
119
120 return scrollableElement
121 ? canScrollInDirection(scrollableElement, deltaY)
122 : false;
123 }
124
125 export function usePresentationNavigation() {
126 const isPresenting = usePresentationState((s) => s.isPresenting);
127 const nextSlide = usePresentationState((s) => s.nextSlide);
128 const previousSlide = usePresentationState((s) => s.previousSlide);
129 const setShouldShowExitHeader = usePresentationState(
130 (s) => s.setShouldShowExitHeader,
131 );
132
133 // Handle keyboard navigation in presentation mode
134 useEffect(() => {
135 const handleKeyDown = (event: KeyboardEvent) => {
136 if (!isPresenting) return;
137
138 const keyScrollDirection =
139 event.key === "ArrowDown" ||
140 event.key === "PageDown" ||
141 ((event.key === " " || event.key === "Space") && !event.shiftKey)
142 ? 1
143 : event.key === "ArrowUp" ||
144 event.key === "PageUp" ||
145 ((event.key === " " || event.key === "Space") && event.shiftKey)
146 ? -1
147 : 0;
148
149 if (
150 keyScrollDirection !== 0 &&
151 shouldLetSlideScroll(event.target, keyScrollDirection)
152 ) {
153 return;
154 }
155
156 if (
157 event.key === "ArrowRight" ||
158 event.key === "ArrowDown" ||
159 event.key === "Space" ||
160 event.key === " "
161 ) {
162 event.preventDefault();
163 nextSlide();
164 } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
165 event.preventDefault();
166 previousSlide();
167 } else if (event.key === "Escape") {
168 usePresentationState.getState().setIsPresenting(false);
169 usePresentationState.getState().setIsPresentingLoading(false);
170 usePresentationState.getState().resetPresentingScaleLocks();
171 usePresentationRecordingState.getState().setWantsToRecord(false);
172 }
173 };
174
175 window.addEventListener("keydown", handleKeyDown);
176 return () => window.removeEventListener("keydown", handleKeyDown);
177 }, [nextSlide, previousSlide, isPresenting]);
178
179 // Handle wheel navigation in presentation mode
180 useEffect(() => {
181 let accumulatedDelta = 0;
182 let lastNavigationTime = 0;
183
184 const handleWheel = (event: WheelEvent) => {
185 if (!isPresenting) return;
186
187 const now = window.performance.now();
188 if (now - lastNavigationTime < WHEEL_NAVIGATION_COOLDOWN_MS) {
189 return;
190 }
191
192 const deltaY = getWheelDeltaInPixels(event);
193
194 if (shouldLetSlideScroll(event.target, deltaY)) {
195 accumulatedDelta = 0;
196 return;
197 }
198
199 event.preventDefault();
200 event.stopPropagation();
201
202 accumulatedDelta += deltaY;
203
204 if (Math.abs(accumulatedDelta) < WHEEL_NAVIGATION_THRESHOLD) {
205 return;
206 }
207
208 if (accumulatedDelta > 0) {
209 nextSlide();
210 } else {
211 previousSlide();
212 }
213
214 accumulatedDelta = 0;
215 lastNavigationTime = now;
216 };
217
218 window.addEventListener("wheel", handleWheel, { passive: false });
219 return () => window.removeEventListener("wheel", handleWheel);
220 }, [isPresenting, nextSlide, previousSlide]);
221
222 // Handle showing header on mouse move
223 useEffect(() => {
224 const handleMouseMove = (event: MouseEvent) => {
225 if (!isPresenting) return; // Only show header when in presentation mode
226
227 if (event.clientY < 100) {
228 setShouldShowExitHeader(true);
229 } else {
230 setShouldShowExitHeader(false);
231 }
232 };
233
234 window.addEventListener("mousemove", handleMouseMove);
235 return () => window.removeEventListener("mousemove", handleMouseMove);
236 }, [isPresenting, setShouldShowExitHeader]);
237 }
238
238 lines TYPESCRIPT