返回 presentation-ai
SlideWrapper.tsx
根目录 / src / components / presentation / slides / SlideWrapper.tsx
1 "use client";
2
3 import { useSortable } from "@dnd-kit/sortable";
4 import { CSS } from "@dnd-kit/utilities";
5 import { Plus } from "lucide-react";
6 import React, { useEffect, useMemo } from "react";
7
8 import { usePresentationTheme } from "@/components/presentation/providers/PresentationThemeProvider";
9 import { Button } from "@/components/ui/button";
10 import { useSlideContentScaling } from "@/hooks/presentation/useSlideContentScaling";
11 import { useSlideOperations } from "@/hooks/presentation/useSlideOperations";
12 import { DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO } from "@/lib/presentation/aspect-ratio";
13 import { resolvePresentationThemeData } from "@/lib/presentation/theme-resolution";
14 import { cn } from "@/lib/utils";
15 import { usePresentationState } from "@/states/presentation-state";
16 import {
17 getPresentModeOverlayClasses,
18 getPresentModeSlideClasses,
19 } from "../present-mode/present-mode-styles";
20 import { SlideEditor } from "./SlideEditor";
21
22 interface SlideWrapperProps {
23 children: React.ReactNode;
24 id: string;
25 className?: string;
26 isReadOnly?: boolean;
27 slideWidth?: string;
28 slidesCount?: number;
29 }
30
31 export function SlideWrapper({
32 children,
33 id,
34 className,
35 isReadOnly = false,
36 slideWidth,
37 }: SlideWrapperProps) {
38 const isPresenting = usePresentationState((s) => s.isPresenting);
39 const isReorderingSlides = usePresentationState((s) => s.isReorderingSlides);
40 const currentSlideId = usePresentationState((s) => s.currentSlideId);
41 const isFirstSlide = usePresentationState((s) => s.slides[0]?.id === id);
42 const presentModeSlideOffset = usePresentationState((s) => {
43 const slideIndex = s.slides.findIndex((slide) => slide.id === id);
44 const currentSlideIndex = s.slides.findIndex(
45 (slide) => slide.id === s.currentSlideId,
46 );
47
48 if (slideIndex < 0 || currentSlideIndex < 0) {
49 return 0;
50 }
51
52 return slideIndex - currentSlideIndex;
53 });
54 // setSlides no longer needed after extracting operations
55 // Select only this slide's data so other slides don't re-render on unrelated changes
56 const currentSlide = usePresentationState((s) =>
57 s.slides.find((slide) => slide.id === id),
58 );
59 // Resolve format category and aspect ratio with defaults
60 // If not set, default to presentation format with fluid aspect ratio
61 const formatCategory = currentSlide?.formatCategory ?? "presentation";
62 const aspectRatio =
63 currentSlide?.aspectRatio ?? DEFAULT_PRESENTATION_SLIDE_ASPECT_RATIO;
64 const zoomLevel = usePresentationState((s) => s.zoomLevel);
65
66 // Get theme data for computing overlay background (same as ThemeBackground)
67 const presentationTheme = usePresentationState((s) => s.theme);
68 const customThemeData = usePresentationState((s) => s.customThemeData);
69 const pageBackground = usePresentationState((s) => s.pageBackground);
70 const { resolvedTheme } = usePresentationTheme();
71 const isDark = resolvedTheme === "dark";
72
73 // Compute the theme background (same logic as ThemeBackground component)
74 const themeBackground = useMemo((): string => {
75 const currentTheme = resolvePresentationThemeData({
76 customThemeData,
77 theme: presentationTheme,
78 });
79
80 const bgOverride = pageBackground.backgroundOverride;
81 const themeBgOverride = currentTheme?.background?.override;
82
83 // Ensure we return a valid string
84 if (typeof bgOverride === "string" && bgOverride) return bgOverride;
85 if (typeof themeBgOverride === "string" && themeBgOverride)
86 return themeBgOverride;
87 return isDark ? "#0a0a0a" : "#ffffff";
88 }, [
89 customThemeData,
90 presentationTheme,
91 pageBackground.backgroundOverride,
92 isDark,
93 ]);
94
95 const scalingConfig = useSlideContentScaling(
96 (slideWidth ?? currentSlide?.width ?? "M") as "S" | "M" | "L",
97 isPresenting,
98 formatCategory,
99 aspectRatio,
100 undefined, // containerRefOverride
101 zoomLevel,
102 );
103 const setPresentingScaleLock = usePresentationState(
104 (s) => s.setPresentingScaleLock,
105 );
106
107 const { contentRef, scaledHeight } = scalingConfig;
108 const {
109 attributes,
110 listeners,
111 setNodeRef,
112 transform,
113 transition,
114 isDragging,
115 } = useSortable({
116 id,
117 disabled: isPresenting || isReadOnly,
118 });
119
120 const presentModeTranslateY =
121 presentModeSlideOffset === 0
122 ? "0"
123 : presentModeSlideOffset > 0
124 ? "100dvh"
125 : "-100dvh";
126 const style = {
127 transform: isPresenting
128 ? `translate3d(0, ${presentModeTranslateY}, 0)`
129 : CSS.Transform.toString(transform),
130 transition: isPresenting
131 ? "transform 320ms cubic-bezier(0.22, 1, 0.36, 1)"
132 : transition,
133 };
134
135 const [dragTransparent, setDragTransparent] = React.useState(false);
136 const [areSlideControlsActive, setAreSlideControlsActive] =
137 React.useState(false);
138 const slideControlsHideTimeoutRef = React.useRef<number | null>(null);
139
140 const showSlideControls = React.useCallback(() => {
141 if (slideControlsHideTimeoutRef.current !== null) {
142 window.clearTimeout(slideControlsHideTimeoutRef.current);
143 slideControlsHideTimeoutRef.current = null;
144 }
145 setAreSlideControlsActive(true);
146 }, []);
147
148 const hideSlideControls = React.useCallback(() => {
149 if (slideControlsHideTimeoutRef.current !== null) {
150 window.clearTimeout(slideControlsHideTimeoutRef.current);
151 }
152 slideControlsHideTimeoutRef.current = window.setTimeout(() => {
153 setAreSlideControlsActive(false);
154 slideControlsHideTimeoutRef.current = null;
155 }, 180);
156 }, []);
157
158 useEffect(() => {
159 let timeout: number;
160
161 if (isDragging) {
162 timeout = window.setTimeout(() => {
163 setDragTransparent(true);
164 }, 200);
165 } else {
166 timeout = window.setTimeout(() => {
167 setDragTransparent(false);
168 }, 0);
169 }
170
171 return () => window.clearTimeout(timeout);
172 }, [isDragging]);
173
174 useEffect(() => {
175 return () => {
176 if (slideControlsHideTimeoutRef.current !== null) {
177 window.clearTimeout(slideControlsHideTimeoutRef.current);
178 }
179 };
180 }, []);
181
182 useEffect(() => {
183 if (!isPresenting) return;
184 if (!scalingConfig.isScaleLocked) return;
185 setPresentingScaleLock(id, true);
186 }, [id, isPresenting, scalingConfig.isScaleLocked, setPresentingScaleLock]);
187
188 const { addSlide, deleteSlide: deleteSlideWithId } = useSlideOperations();
189
190 const deleteSlide = () => {
191 deleteSlideWithId(id);
192 };
193
194 const presentWidth = Math.round(
195 scalingConfig.slideWidth * Math.max(scalingConfig.scale, 0.1),
196 );
197 const editModeScaledWidth = Math.round(
198 scalingConfig.slideWidth * Math.max(scalingConfig.scale, 0.1),
199 );
200
201 // When presentFitScale < 1, the content needs to shrink to fit the viewport.
202 // We render the content at full viewport width (presentWidth) but apply a
203 // CSS transform to scale it down visually. The outer wrapper is sized to
204 // the scaled dimensions so the layout (overflow, centering) is correct.
205 const fitScale = isPresenting ? scalingConfig.presentFitScale : 1;
206 const needsFitScaling = isPresenting && fitScale < 1;
207
208 return (
209 <div
210 ref={setNodeRef}
211 style={{
212 ...style,
213 // Ensure the parent participates in layout with the scaled height
214 ...(scaledHeight ? { height: `${scaledHeight}px` } : {}),
215 // Apply theme background for present mode overlay
216 ...(isPresenting ? { background: themeBackground } : {}),
217 }}
218 className={cn(
219 "group/card-container relative z-10 flex w-full flex-col pb-6",
220 `slide-container-${id}`,
221 isDragging && "z-50 opacity-50",
222 dragTransparent && "opacity-30",
223 isPresenting && getPresentModeOverlayClasses(formatCategory),
224 id === currentSlideId && isPresenting && "z-999",
225 id !== currentSlideId && isPresenting && "pointer-events-none",
226 )}
227 onPointerEnter={showSlideControls}
228 onPointerLeave={hideSlideControls}
229 {...attributes}
230 >
231 <div
232 className={cn(
233 "relative w-full",
234 !isPresenting && "flex justify-center",
235 )}
236 >
237 <div
238 className="relative"
239 style={
240 !isPresenting
241 ? {
242 width: `${editModeScaledWidth}px`,
243 maxWidth: "100%",
244 }
245 : needsFitScaling
246 ? {
247 // Set explicit dimensions to the scaled size so the
248 // grid/overflow parent sees the correct layout box.
249 width: `${Math.ceil(presentWidth * fitScale)}px`,
250 height: `${Math.ceil(scalingConfig.contentHeight * fitScale)}px`,
251 overflow: "hidden",
252 }
253 : undefined
254 }
255 >
256 <div
257 ref={contentRef}
258 className={cn(
259 "relative origin-[top_left]",
260 isPresenting && getPresentModeSlideClasses(formatCategory),
261 className,
262 )}
263 style={{
264 ...(!isPresenting && {
265 width: `${scalingConfig.slideWidth}px`,
266 transform: `scale(${scalingConfig.scale})`,
267 }),
268 ...(isPresenting && {
269 width: `${presentWidth}px`,
270 fontSize: `${scalingConfig.fontSize}px`,
271 ...(needsFitScaling && {
272 transform: `scale(${fitScale})`,
273 transformOrigin: "top left",
274 }),
275 }),
276 }}
277 >
278 {/* Overlay to prevent accidental editor/element drops during slide reordering */}
279 {isReorderingSlides && !isPresenting && (
280 <div className="absolute inset-0 z-101 cursor-grabbing bg-transparent" />
281 )}
282
283 {!isPresenting && !isReadOnly && (
284 <div
285 className={cn(
286 "absolute top-2 left-4 z-999999 flex opacity-0 transition-opacity duration-200 group-hover/card-container:opacity-100",
287 areSlideControlsActive && "opacity-100",
288 )}
289 style={{
290 transform: `scale(${0.6 + 0.4 / scalingConfig.scale})`,
291 transformOrigin: "center center",
292 }}
293 >
294 <SlideEditor
295 slideId={id}
296 dragListeners={listeners}
297 onDuplicate={() => addSlide("after", id, currentSlide)}
298 onDelete={deleteSlide}
299 />
300 </div>
301 )}
302
303 {children}
304 </div>
305 </div>
306 </div>
307
308 {!isPresenting && !isReadOnly && isFirstSlide && (
309 <div
310 className={cn(
311 "absolute top-0 left-1/2 z-999999 -translate-x-1/2 -translate-y-[calc(100%-0.25rem)] opacity-0 transition-opacity duration-200 group-hover/card-container:opacity-100",
312 areSlideControlsActive && "opacity-100",
313 )}
314 >
315 <Button
316 variant="outline"
317 size="icon"
318 className="h-8 w-8 rounded-full bg-background shadow-md"
319 onClick={() => addSlide("before", id)}
320 >
321 <Plus className="h-4 w-4" />
322 </Button>
323 </div>
324 )}
325
326 {!isPresenting && !isReadOnly && (
327 <div
328 className={cn(
329 "absolute bottom-0 left-1/2 z-999999 -translate-x-1/2 translate-y-[calc(100%-0.25rem)] opacity-0 transition-opacity duration-200 group-hover/card-container:opacity-100",
330 areSlideControlsActive && "opacity-100",
331 )}
332 >
333 <Button
334 variant="outline"
335 size="icon"
336 className="h-8 w-8 rounded-full bg-background shadow-md"
337 onClick={() => addSlide("after", id)}
338 >
339 <Plus className="h-4 w-4" />
340 </Button>
341 </div>
342 )}
343 </div>
344 );
345 }
346
346 lines Plain Text