| 1 | "use client"; |
| 2 | |
| 3 | import { DRAG_ITEM_BLOCK, type ElementDragItemNode } from "@platejs/dnd"; |
| 4 | import { |
| 5 | Copy, |
| 6 | Download, |
| 7 | Edit, |
| 8 | ExternalLink, |
| 9 | FileText, |
| 10 | Link2, |
| 11 | Maximize2, |
| 12 | Trash2, |
| 13 | } from "lucide-react"; |
| 14 | import Image from "next/image"; |
| 15 | import { useDrop } from "react-dnd"; |
| 16 | import { toast } from "sonner"; |
| 17 | |
| 18 | import { |
| 19 | ContextMenu, |
| 20 | ContextMenuContent, |
| 21 | ContextMenuItem, |
| 22 | ContextMenuSeparator, |
| 23 | ContextMenuTrigger, |
| 24 | } from "@/components/ui/context-menu"; |
| 25 | import { Spinner } from "@/components/ui/spinner"; |
| 26 | import { cn } from "@/lib/utils"; |
| 27 | import { |
| 28 | usePresentationState, |
| 29 | type ImageEditorMode, |
| 30 | } from "@/states/presentation-state"; |
| 31 | import { type RootImage } from "../../../utils/parser"; |
| 32 | |
| 33 | interface ImageSlideProps { |
| 34 | image: RootImage; |
| 35 | slideId: string; |
| 36 | } |
| 37 | |
| 38 | export default function ImageSlide({ image, slideId }: ImageSlideProps) { |
| 39 | const slides = usePresentationState((s) => s.slides); |
| 40 | const setSlides = usePresentationState((s) => s.setSlides); |
| 41 | const setCurrentSlide = usePresentationState((s) => s.setCurrentSlideId); |
| 42 | const openImageEditor = usePresentationState((s) => s.openImageEditor); |
| 43 | const stockImageProvider = usePresentationState((s) => s.stockImageProvider); |
| 44 | const setImageSearchState = usePresentationState( |
| 45 | (s) => s.setImageSearchState, |
| 46 | ); |
| 47 | const rootImageGeneration = usePresentationState( |
| 48 | (s) => s.rootImageGeneration, |
| 49 | ); |
| 50 | |
| 51 | const rawComputedGen = rootImageGeneration[slideId]; |
| 52 | const imageQuery = image.query.trim(); |
| 53 | const computedGen = |
| 54 | rawComputedGen && |
| 55 | (!imageQuery || rawComputedGen.query.trim() === imageQuery) |
| 56 | ? rawComputedGen |
| 57 | : undefined; |
| 58 | const computedImageUrl = computedGen?.url ?? image.url; |
| 59 | const isGenerating = |
| 60 | image.isQueryStreaming || |
| 61 | computedGen?.status === "queued" || |
| 62 | computedGen?.status === "generating"; |
| 63 | |
| 64 | const handleAction = (action: string) => { |
| 65 | switch (action) { |
| 66 | case "copy": |
| 67 | if (computedImageUrl) { |
| 68 | fetch(computedImageUrl) |
| 69 | .then((response) => response.blob()) |
| 70 | .then((blob) => { |
| 71 | const item = new ClipboardItem({ [blob.type]: blob }); |
| 72 | navigator.clipboard.write([item]); |
| 73 | toast("Image copied to clipboard"); |
| 74 | }) |
| 75 | .catch((err) => { |
| 76 | console.error("Failed to copy image:", err); |
| 77 | toast("Failed to copy image"); |
| 78 | }); |
| 79 | } |
| 80 | break; |
| 81 | case "copyAddress": |
| 82 | if (computedImageUrl) { |
| 83 | navigator.clipboard.writeText(computedImageUrl); |
| 84 | toast("Image address copied to clipboard"); |
| 85 | } |
| 86 | break; |
| 87 | case "openNewTab": |
| 88 | if (computedImageUrl) { |
| 89 | window.open(computedImageUrl, "_blank"); |
| 90 | } |
| 91 | break; |
| 92 | case "download": |
| 93 | if (computedImageUrl) { |
| 94 | const link = document.createElement("a"); |
| 95 | link.href = computedImageUrl; |
| 96 | link.download = "downloaded-image"; |
| 97 | link.click(); |
| 98 | } |
| 99 | break; |
| 100 | case "replace": |
| 101 | setCurrentSlide(slideId); |
| 102 | const mode: ImageEditorMode = |
| 103 | image.imageSource === "search" |
| 104 | ? "search" |
| 105 | : image.imageSource === "gif" |
| 106 | ? "gif" |
| 107 | : "generate"; |
| 108 | if (mode === "search") { |
| 109 | setImageSearchState({ |
| 110 | mode: image.stockImageProvider ?? stockImageProvider, |
| 111 | }); |
| 112 | } |
| 113 | openImageEditor(mode); |
| 114 | break; |
| 115 | case "fit": |
| 116 | updateCropSettings({ |
| 117 | ...image.cropSettings, |
| 118 | objectFit: |
| 119 | image.cropSettings?.objectFit === "contain" ? "cover" : "contain", |
| 120 | objectPosition: image.cropSettings?.objectPosition ?? { |
| 121 | x: 50, |
| 122 | y: 50, |
| 123 | }, |
| 124 | }); |
| 125 | break; |
| 126 | case "convertToSlide": |
| 127 | // Convert this image slide back to a regular slide |
| 128 | const updatedSlides = slides.map((slide) => { |
| 129 | if (slide.id === slideId) { |
| 130 | return { |
| 131 | ...slide, |
| 132 | isImageSlide: false, |
| 133 | layoutType: "left" as const, // Set a default layout |
| 134 | content: |
| 135 | slide.content.length > 0 |
| 136 | ? slide.content |
| 137 | : [{ type: "h1", children: [{ text: "" }] }], |
| 138 | }; |
| 139 | } |
| 140 | return slide; |
| 141 | }); |
| 142 | setSlides(updatedSlides); |
| 143 | toast("Converted to slide"); |
| 144 | break; |
| 145 | case "removeSlide": |
| 146 | // Remove this slide entirely |
| 147 | const filteredSlides = slides.filter((slide) => slide.id !== slideId); |
| 148 | setSlides(filteredSlides); |
| 149 | toast("Slide removed"); |
| 150 | break; |
| 151 | default: |
| 152 | console.log(`Action: ${action}`); |
| 153 | } |
| 154 | }; |
| 155 | |
| 156 | const updateCropSettings = (newCropSettings: typeof image.cropSettings) => { |
| 157 | const updatedSlides = slides.map((slide) => { |
| 158 | if (slide.id === slideId && slide.rootImage) { |
| 159 | return { |
| 160 | ...slide, |
| 161 | rootImage: { |
| 162 | ...slide.rootImage, |
| 163 | cropSettings: newCropSettings, |
| 164 | }, |
| 165 | }; |
| 166 | } |
| 167 | return slide; |
| 168 | }); |
| 169 | setSlides(updatedSlides); |
| 170 | }; |
| 171 | |
| 172 | // Drop zone for accepting draggable elements |
| 173 | const [{ isOver, canDrop }, dropRef] = useDrop< |
| 174 | ElementDragItemNode, |
| 175 | void, |
| 176 | { isOver: boolean; canDrop: boolean } |
| 177 | >({ |
| 178 | accept: DRAG_ITEM_BLOCK, |
| 179 | collect: (monitor) => ({ |
| 180 | isOver: monitor.isOver(), |
| 181 | canDrop: monitor.canDrop(), |
| 182 | }), |
| 183 | drop: (dragItem) => { |
| 184 | // Get the dropped element from dragItem |
| 185 | const droppedElement = dragItem.element; |
| 186 | if (!droppedElement) { |
| 187 | toast.error("Could not get dropped element"); |
| 188 | return; |
| 189 | } |
| 190 | |
| 191 | // Convert image slide to normal slide with image as background |
| 192 | // and add the dropped element to the content |
| 193 | const updatedSlides = slides.map((slide) => { |
| 194 | if (slide.id === slideId) { |
| 195 | // Create new content array with the dropped element |
| 196 | const newContent = Array.isArray(droppedElement) |
| 197 | ? [...droppedElement] |
| 198 | : [droppedElement]; |
| 199 | |
| 200 | return { |
| 201 | ...slide, |
| 202 | isImageSlide: false, |
| 203 | layoutType: "background" as const, |
| 204 | // Keep the rootImage so it becomes the background |
| 205 | rootImage: slide.rootImage, |
| 206 | // Add the dropped element to content |
| 207 | content: newContent as typeof slide.content, |
| 208 | }; |
| 209 | } |
| 210 | return slide; |
| 211 | }); |
| 212 | setSlides(updatedSlides); |
| 213 | toast.success("Element added to slide"); |
| 214 | }, |
| 215 | }); |
| 216 | |
| 217 | const isDropActive = isOver && canDrop; |
| 218 | |
| 219 | return ( |
| 220 | <div |
| 221 | ref={dropRef as unknown as React.Ref<HTMLDivElement>} |
| 222 | className="relative aspect-video w-full" |
| 223 | > |
| 224 | <ContextMenu> |
| 225 | <ContextMenuTrigger asChild> |
| 226 | <div |
| 227 | className={cn( |
| 228 | "flex size-full cursor-pointer items-center justify-center", |
| 229 | "relative overflow-hidden", |
| 230 | )} |
| 231 | onDoubleClick={() => { |
| 232 | setCurrentSlide(slideId); |
| 233 | const mode: ImageEditorMode = |
| 234 | image.imageSource === "search" |
| 235 | ? "search" |
| 236 | : image.imageSource === "gif" |
| 237 | ? "gif" |
| 238 | : "generate"; |
| 239 | if (mode === "search") { |
| 240 | setImageSearchState({ |
| 241 | mode: image.stockImageProvider ?? stockImageProvider, |
| 242 | }); |
| 243 | } |
| 244 | openImageEditor(mode); |
| 245 | }} |
| 246 | > |
| 247 | {isGenerating ? ( |
| 248 | <div className="absolute inset-0 z-10 flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4 text-center"> |
| 249 | <Spinner className="size-8" /> |
| 250 | <div className="space-y-1"> |
| 251 | <p className="text-sm font-medium text-foreground"> |
| 252 | Generating image |
| 253 | </p> |
| 254 | <p className="text-xs text-muted-foreground"> |
| 255 | This can take a moment. |
| 256 | </p> |
| 257 | </div> |
| 258 | </div> |
| 259 | ) : computedImageUrl ? ( |
| 260 | <Image |
| 261 | unoptimized |
| 262 | width={400} |
| 263 | height={300} |
| 264 | src={computedImageUrl} |
| 265 | alt={image.query} |
| 266 | className="size-full" |
| 267 | style={{ |
| 268 | objectFit: image.cropSettings?.objectFit ?? "cover", |
| 269 | objectPosition: image.cropSettings?.objectPosition |
| 270 | ? `${image.cropSettings.objectPosition.x}% ${image.cropSettings.objectPosition.y}%` |
| 271 | : "center", |
| 272 | }} |
| 273 | /> |
| 274 | ) : ( |
| 275 | <div className="flex items-center justify-center text-muted-foreground"> |
| 276 | <span> |
| 277 | {computedGen?.status === "error" |
| 278 | ? "Image not found" |
| 279 | : "No image"} |
| 280 | </span> |
| 281 | </div> |
| 282 | )} |
| 283 | {/* Drop indicator overlay */} |
| 284 | {isDropActive && ( |
| 285 | <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-dashed border-primary bg-primary/20"> |
| 286 | <div className="rounded-md bg-background/90 px-4 py-2 shadow-lg"> |
| 287 | <span className="text-sm font-medium text-primary"> |
| 288 | Drop to add element |
| 289 | </span> |
| 290 | </div> |
| 291 | </div> |
| 292 | )} |
| 293 | </div> |
| 294 | </ContextMenuTrigger> |
| 295 | <ContextMenuContent className="w-64"> |
| 296 | <ContextMenuItem onClick={() => handleAction("copy")}> |
| 297 | <Copy className="mr-2 size-4" /> |
| 298 | Copy |
| 299 | </ContextMenuItem> |
| 300 | <ContextMenuItem onClick={() => handleAction("copyAddress")}> |
| 301 | <Link2 className="mr-2 size-4" /> |
| 302 | Copy image address |
| 303 | </ContextMenuItem> |
| 304 | <ContextMenuItem onClick={() => handleAction("openNewTab")}> |
| 305 | <ExternalLink className="mr-2 size-4" /> |
| 306 | Open image in new tab |
| 307 | </ContextMenuItem> |
| 308 | <ContextMenuItem onClick={() => handleAction("download")}> |
| 309 | <Download className="mr-2 size-4" /> |
| 310 | Download image |
| 311 | </ContextMenuItem> |
| 312 | <ContextMenuSeparator /> |
| 313 | <ContextMenuItem onClick={() => handleAction("replace")}> |
| 314 | <Edit className="mr-2 size-4" /> |
| 315 | Replace image… |
| 316 | </ContextMenuItem> |
| 317 | <ContextMenuItem onClick={() => handleAction("fit")}> |
| 318 | <Maximize2 className="mr-2 size-4" /> |
| 319 | {image.cropSettings?.objectFit === "contain" |
| 320 | ? "Cover Image" |
| 321 | : "Fit Image"} |
| 322 | </ContextMenuItem> |
| 323 | <ContextMenuSeparator /> |
| 324 | <ContextMenuItem onClick={() => handleAction("convertToSlide")}> |
| 325 | <FileText className="mr-2 size-4" /> |
| 326 | Convert to slide |
| 327 | </ContextMenuItem> |
| 328 | <ContextMenuSeparator /> |
| 329 | <ContextMenuItem |
| 330 | onClick={() => handleAction("removeSlide")} |
| 331 | className="text-red-500 focus:bg-red-50 focus:text-red-500" |
| 332 | > |
| 333 | <Trash2 className="mr-2 size-4" /> |
| 334 | Remove slide |
| 335 | </ContextMenuItem> |
| 336 | </ContextMenuContent> |
| 337 | </ContextMenu> |
| 338 | </div> |
| 339 | ); |
| 340 | } |
| 341 |