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