返回 presentation-ai
PresentationPage.tsx
根目录 / src / components / presentation / core / PresentationPage.tsx
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
273 lines Plain Text