| 1 | "use client"; |
| 2 | |
| 3 | import { useParams } from "next/navigation"; |
| 4 | import { PlateController } from "platejs/react"; |
| 5 | import { |
| 6 | useCallback, |
| 7 | useEffect, |
| 8 | useLayoutEffect, |
| 9 | useRef, |
| 10 | useState, |
| 11 | type UIEvent, |
| 12 | } from "react"; |
| 13 | |
| 14 | import TouchAwareDndProvider from "@/components/globals/TouchAwareDndProvider"; |
| 15 | import { ThemeBackground } from "@/components/notebook/presentation/components/theme/ThemeBackground"; |
| 16 | import RecordingControls from "@/components/notebook/presentation/recording/RecordingControls"; |
| 17 | import RecordingPreviewDialog from "@/components/notebook/presentation/recording/RecordingPreviewDialog"; |
| 18 | import WebcamOverlay from "@/components/notebook/presentation/recording/WebcamOverlay"; |
| 19 | import { usePresentationAutoScroll } from "@/hooks/presentation/usePresentationAutoScroll"; |
| 20 | import { usePresentationData } from "@/hooks/presentation/usePresentationData"; |
| 21 | import { usePresentationHistory } from "@/hooks/presentation/usePresentationHistory"; |
| 22 | import { type ThemeProperties } from "@/lib/presentation/themes"; |
| 23 | import { cn } from "@/lib/utils"; |
| 24 | import { usePresentationRecordingState } from "@/states/presentation-recording-state"; |
| 25 | import { |
| 26 | MAX_PRESENTATION_ZOOM_LEVEL, |
| 27 | MIN_PRESENTATION_ZOOM_LEVEL, |
| 28 | usePresentationState, |
| 29 | } from "@/states/presentation-state"; |
| 30 | import { RightEditPanel } from "../edit-panel/RightEditPanel"; |
| 31 | import { RightPanelRenderer } from "../edit-panel/RightPanelRenderer"; |
| 32 | import { LoadingState } from "../shared/LoadingState"; |
| 33 | import { PresentingLoadingOverlay } from "../shared/PresentingLoadingOverlay"; |
| 34 | import { ThemeFontLoader } from "../shared/ThemeFontLoader"; |
| 35 | import { SlideSidebar } from "../sidebar/SlideSidebar"; |
| 36 | import { SlidesContainer } from "../slides/SlidesContainer"; |
| 37 | import { PresentationCompletionFeedback } from "./PresentationCompletionFeedback"; |
| 38 | |
| 39 | export default function PresentationPage({ |
| 40 | readOnly = false, |
| 41 | }: { |
| 42 | readOnly?: boolean; |
| 43 | }) { |
| 44 | const params = useParams(); |
| 45 | const id = params.id as string; |
| 46 | const isGeneratingPresentation = usePresentationState( |
| 47 | (s) => s.isGeneratingPresentation, |
| 48 | ); |
| 49 | const isPresenting = usePresentationState((s) => s.isPresenting); |
| 50 | const isReadOnly = usePresentationState((s) => s.isReadOnly); |
| 51 | const setIsReadOnly = usePresentationState((s) => s.setIsReadOnly); |
| 52 | const isPresentingLoading = usePresentationState( |
| 53 | (s) => s.isPresentingLoading, |
| 54 | ); |
| 55 | // const activeRightPanel = usePresentationState((s) => s.activeRightPanel); |
| 56 | const wantsToRecord = usePresentationRecordingState((s) => s.wantsToRecord); |
| 57 | // Load presentation data |
| 58 | const { isLoading, currentThemeData } = usePresentationData(id, readOnly); |
| 59 | const currentPresentationTitle = usePresentationState( |
| 60 | (s) => s.currentPresentationTitle, |
| 61 | ); |
| 62 | const completedGenerationPresentationId = usePresentationState( |
| 63 | (s) => s.completedGenerationPresentationId, |
| 64 | ); |
| 65 | const setZoomLevel = usePresentationState((s) => s.setZoomLevel); |
| 66 | const dismissCompletedGeneration = usePresentationState( |
| 67 | (s) => s.dismissCompletedGeneration, |
| 68 | ); |
| 69 | |
| 70 | // Auto scroll refs |
| 71 | const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>( |
| 72 | null, |
| 73 | ); |
| 74 | const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>( |
| 75 | null, |
| 76 | ); |
| 77 | const handleViewportRef = useCallback((node: HTMLDivElement | null) => { |
| 78 | setViewportElement(node); |
| 79 | }, []); |
| 80 | const handleScrollRef = useCallback((node: HTMLDivElement | null) => { |
| 81 | setScrollElement(node); |
| 82 | }, []); |
| 83 | |
| 84 | usePresentationAutoScroll({ |
| 85 | viewportElement, |
| 86 | scrollElement, |
| 87 | }); |
| 88 | |
| 89 | useEffect(() => { |
| 90 | if (!viewportElement || isPresenting) return; |
| 91 | |
| 92 | const PIXELS_PER_LINE = 16; |
| 93 | const clampZoom = (value: number): number => |
| 94 | Math.min( |
| 95 | MAX_PRESENTATION_ZOOM_LEVEL, |
| 96 | Math.max(MIN_PRESENTATION_ZOOM_LEVEL, value), |
| 97 | ); |
| 98 | |
| 99 | const toPixelDelta = (event: WheelEvent): number => { |
| 100 | if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { |
| 101 | return event.deltaY * PIXELS_PER_LINE; |
| 102 | } |
| 103 | if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { |
| 104 | return event.deltaY * window.innerHeight; |
| 105 | } |
| 106 | return event.deltaY; |
| 107 | }; |
| 108 | |
| 109 | const handleWheel = (event: WheelEvent) => { |
| 110 | if (!event.ctrlKey && !event.metaKey) return; |
| 111 | if (!(event.target instanceof HTMLElement)) return; |
| 112 | if (!viewportElement.contains(event.target)) return; |
| 113 | |
| 114 | event.preventDefault(); |
| 115 | event.stopPropagation(); |
| 116 | |
| 117 | const { zoomLevel } = usePresentationState.getState(); |
| 118 | const pixelDelta = toPixelDelta(event); |
| 119 | const zoomFactor = Math.exp(-pixelDelta * 0.0015); |
| 120 | const nextZoom = clampZoom(zoomLevel * zoomFactor); |
| 121 | |
| 122 | if (Math.abs(nextZoom - zoomLevel) < 0.0005) return; |
| 123 | setZoomLevel(Number(nextZoom.toFixed(3))); |
| 124 | }; |
| 125 | |
| 126 | viewportElement.addEventListener("wheel", handleWheel, { |
| 127 | passive: false, |
| 128 | capture: true, |
| 129 | }); |
| 130 | |
| 131 | return () => { |
| 132 | viewportElement.removeEventListener("wheel", handleWheel, true); |
| 133 | }; |
| 134 | }, [isPresenting, setZoomLevel, viewportElement]); |
| 135 | |
| 136 | // Track presentation history |
| 137 | usePresentationHistory(); |
| 138 | |
| 139 | useEffect(() => { |
| 140 | setIsReadOnly(readOnly); |
| 141 | return () => { |
| 142 | setIsReadOnly(false); |
| 143 | }; |
| 144 | }, [readOnly, setIsReadOnly]); |
| 145 | |
| 146 | const effectiveReadOnly = readOnly || isReadOnly; |
| 147 | const showCompletionFeedback = completedGenerationPresentationId === id; |
| 148 | const shouldShowGenerationTail = |
| 149 | isGeneratingPresentation || showCompletionFeedback; |
| 150 | const editModeScrollTopRef = useRef(0); |
| 151 | const previousIsPresentingRef = useRef(isPresenting); |
| 152 | |
| 153 | const handleViewportScroll = useCallback( |
| 154 | (event: UIEvent<HTMLDivElement>) => { |
| 155 | if (isPresenting) return; |
| 156 | editModeScrollTopRef.current = event.currentTarget.scrollTop; |
| 157 | }, |
| 158 | [isPresenting], |
| 159 | ); |
| 160 | |
| 161 | useLayoutEffect(() => { |
| 162 | if (!viewportElement) return; |
| 163 | |
| 164 | const wasPresenting = previousIsPresentingRef.current; |
| 165 | previousIsPresentingRef.current = isPresenting; |
| 166 | |
| 167 | if (!wasPresenting && !isPresenting) { |
| 168 | editModeScrollTopRef.current = viewportElement.scrollTop; |
| 169 | return; |
| 170 | } |
| 171 | |
| 172 | if (!wasPresenting || isPresenting) return; |
| 173 | |
| 174 | const restoreScrollTop = editModeScrollTopRef.current; |
| 175 | const restoreScroll = () => { |
| 176 | viewportElement.scrollTop = restoreScrollTop; |
| 177 | viewportElement.scrollLeft = 0; |
| 178 | }; |
| 179 | |
| 180 | restoreScroll(); |
| 181 | const frameIds: number[] = []; |
| 182 | frameIds.push( |
| 183 | window.requestAnimationFrame(() => { |
| 184 | restoreScroll(); |
| 185 | frameIds.push(window.requestAnimationFrame(restoreScroll)); |
| 186 | }), |
| 187 | ); |
| 188 | |
| 189 | return () => { |
| 190 | for (const frameId of frameIds) { |
| 191 | window.cancelAnimationFrame(frameId); |
| 192 | } |
| 193 | }; |
| 194 | }, [isPresenting, viewportElement]); |
| 195 | |
| 196 | if (isLoading) { |
| 197 | return <LoadingState />; |
| 198 | } |
| 199 | |
| 200 | const themeData: ThemeProperties | undefined = currentThemeData ?? undefined; |
| 201 | const showSlideSidebar = !isPresenting; |
| 202 | const showEditPanels = !effectiveReadOnly && !isPresenting; |
| 203 | |
| 204 | return ( |
| 205 | <ThemeBackground className="flex h-full w-full"> |
| 206 | <TouchAwareDndProvider> |
| 207 | <PlateController> |
| 208 | {themeData && <ThemeFontLoader themeData={themeData} />} |
| 209 | |
| 210 | <div className="grid h-full w-full min-w-0 lg:grid-cols-[auto_minmax(0,1fr)]"> |
| 211 | <div className="relative z-10 hidden lg:flex"> |
| 212 | <SlideSidebar showSidebar={showSlideSidebar} /> |
| 213 | </div> |
| 214 | <div |
| 215 | ref={handleViewportRef} |
| 216 | onScroll={handleViewportScroll} |
| 217 | className={cn( |
| 218 | "presentation-slides flex w-full min-w-0 overflow-x-hidden overflow-y-auto", |
| 219 | isPresenting && "fixed inset-0 pb-0", |
| 220 | )} |
| 221 | > |
| 222 | <div className="mx-auto flex min-w-0 flex-1 flex-col space-y-8 px-3 pt-8 sm:px-0 sm:pt-16 lg:max-w-[95%] xl:max-w-[90%]"> |
| 223 | <div className="space-y-8"> |
| 224 | <SlidesContainer |
| 225 | isGeneratingPresentation={isGeneratingPresentation} |
| 226 | isReadOnly={effectiveReadOnly} |
| 227 | /> |
| 228 | </div> |
| 229 | {shouldShowGenerationTail ? ( |
| 230 | <div className="w-full shrink-0"> |
| 231 | {showCompletionFeedback ? ( |
| 232 | <div className="min-h-[32vh] sm:min-h-[36vh]"> |
| 233 | <PresentationCompletionFeedback |
| 234 | presentationId={id} |
| 235 | presentationTitle={currentPresentationTitle} |
| 236 | onHide={() => dismissCompletedGeneration(id)} |
| 237 | /> |
| 238 | </div> |
| 239 | ) : ( |
| 240 | <div |
| 241 | aria-hidden="true" |
| 242 | className="h-[32vh] min-h-32 w-full sm:h-[36vh]" |
| 243 | /> |
| 244 | )} |
| 245 | </div> |
| 246 | ) : null} |
| 247 | <div ref={handleScrollRef} /> |
| 248 | </div> |
| 249 | |
| 250 | {/* RightEditPanel shows buttons, hidden when any panel is open */} |
| 251 | {showEditPanels && <RightEditPanel />} |
| 252 | |
| 253 | {/* Recording UI (not captured) */} |
| 254 | {isPresenting && wantsToRecord && ( |
| 255 | <> |
| 256 | <WebcamOverlay /> |
| 257 | <RecordingControls /> |
| 258 | <RecordingPreviewDialog /> |
| 259 | </> |
| 260 | )} |
| 261 | </div> |
| 262 | </div> |
| 263 | |
| 264 | {/* Unified Right Panel Renderer with animations */} |
| 265 | {showEditPanels && <RightPanelRenderer />} |
| 266 | </PlateController> |
| 267 | </TouchAwareDndProvider> |
| 268 | |
| 269 | {isPresentingLoading && <PresentingLoadingOverlay />} |
| 270 | </ThemeBackground> |
| 271 | ); |
| 272 | } |
| 273 |