| 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 |