| 1 | "use client"; |
| 2 | |
| 3 | import { Image, useMediaState } from "@platejs/media/react"; |
| 4 | import { ResizableProvider, ResizeHandle } from "@platejs/resizable"; |
| 5 | import { type TImageElement } from "platejs"; |
| 6 | import { |
| 7 | PlateElement, |
| 8 | useEditorRef, |
| 9 | useReadOnly, |
| 10 | withHOC, |
| 11 | type PlateElementProps, |
| 12 | } from "platejs/react"; |
| 13 | import { useEffect, useMemo, useRef } from "react"; |
| 14 | |
| 15 | import { |
| 16 | mediaResizeHandleVariants, |
| 17 | Resizable, |
| 18 | } from "@/components/plate/ui/resize-handle"; |
| 19 | import { Spinner } from "@/components/ui/spinner"; |
| 20 | import { |
| 21 | getElementImageGenerationTarget, |
| 22 | getPresentationImageGenerationKey, |
| 23 | resolvePresentationImageGenerationSource, |
| 24 | } from "@/lib/presentation/image-generation"; |
| 25 | import { cn } from "@/lib/utils"; |
| 26 | import { |
| 27 | usePresentationState, |
| 28 | type ImageEditorMode, |
| 29 | type PresentationStockImageProvider, |
| 30 | } from "@/states/presentation-state"; |
| 31 | import { type ImageCropSettings } from "../../utils/types"; |
| 32 | import { useDraggable } from "../dnd/hooks/useDraggable"; |
| 33 | import { getPresentationImageCropStyles } from "./presentation-image-layout"; |
| 34 | import { PresentationImagePlaceholder } from "./presentation-image-placeholder"; |
| 35 | |
| 36 | type PresentationImageNode = TImageElement & { |
| 37 | id?: string; |
| 38 | query?: string; |
| 39 | cropSettings?: ImageCropSettings; |
| 40 | imageSource?: "generate" | "search" | "gif" | "upload"; |
| 41 | stockImageProvider?: PresentationStockImageProvider; |
| 42 | imageGenerationStatus?: "failed"; |
| 43 | }; |
| 44 | |
| 45 | export interface PresentationImageElementProps extends PlateElementProps<PresentationImageNode> { |
| 46 | nodeProps?: Record<string, unknown>; |
| 47 | } |
| 48 | |
| 49 | export const PresentationImageElement = withHOC( |
| 50 | ResizableProvider, |
| 51 | function PresentationImageElement({ |
| 52 | children, |
| 53 | className, |
| 54 | nodeProps, |
| 55 | ref, |
| 56 | ...props |
| 57 | }: PresentationImageElementProps) { |
| 58 | const { align = "center", focused, readOnly, selected } = useMediaState(); |
| 59 | const { isDragging, handleRef } = useDraggable({ |
| 60 | element: props.element, |
| 61 | }); |
| 62 | const imageRef = useRef<HTMLDivElement | null>(null); |
| 63 | const editor = useEditorRef(); |
| 64 | const slideId = String(props.editor.id ?? ""); |
| 65 | |
| 66 | const imageSource = usePresentationState((s) => s.imageSource); |
| 67 | const imageModel = usePresentationState((s) => s.imageModel); |
| 68 | const stockImageProvider = usePresentationState( |
| 69 | (s) => s.stockImageProvider, |
| 70 | ); |
| 71 | const setImageSearchState = usePresentationState( |
| 72 | (s) => s.setImageSearchState, |
| 73 | ); |
| 74 | const openPresentationImageEditor = usePresentationState( |
| 75 | (s) => s.openPresentationImageEditor, |
| 76 | ); |
| 77 | const presentationImageEditorInitialMode = usePresentationState( |
| 78 | (s) => s.presentationImageEditorInitialMode, |
| 79 | ); |
| 80 | const startPresentationImageGeneration = usePresentationState( |
| 81 | (s) => s.startPresentationImageGeneration, |
| 82 | ); |
| 83 | const rootImageGeneration = usePresentationState( |
| 84 | (s) => s.rootImageGeneration, |
| 85 | ); |
| 86 | |
| 87 | const isReadOnly = useReadOnly(); |
| 88 | const generationTarget = useMemo( |
| 89 | () => |
| 90 | slideId && props.element.id |
| 91 | ? getElementImageGenerationTarget(slideId, props.element.id) |
| 92 | : null, |
| 93 | [props.element.id, slideId], |
| 94 | ); |
| 95 | const generationKey = generationTarget |
| 96 | ? getPresentationImageGenerationKey(generationTarget) |
| 97 | : null; |
| 98 | const computedGen = generationKey |
| 99 | ? rootImageGeneration[generationKey] |
| 100 | : undefined; |
| 101 | const computedImageUrl = |
| 102 | computedGen?.status === "success" && computedGen.url |
| 103 | ? computedGen.url |
| 104 | : props.element.url; |
| 105 | const isGenerating = |
| 106 | computedGen?.status === "queued" || computedGen?.status === "generating"; |
| 107 | const hasGenerationFailed = |
| 108 | computedGen?.status === "error" || |
| 109 | props.element.imageGenerationStatus === "failed"; |
| 110 | |
| 111 | const cropSettings: ImageCropSettings = props.element.cropSettings || { |
| 112 | objectFit: "cover", |
| 113 | objectPosition: { x: 50, y: 50 }, |
| 114 | zoom: 1, |
| 115 | }; |
| 116 | |
| 117 | const handleOpenEditor = (mode: ImageEditorMode) => { |
| 118 | if (isReadOnly) return; |
| 119 | if (props.element.id) { |
| 120 | const boundUpdateElement = (updateProps: Record<string, unknown>) => { |
| 121 | editor.tf.setNodes(updateProps as Partial<TImageElement>, { |
| 122 | at: [], |
| 123 | match: (n) => n.id === props.element.id, |
| 124 | }); |
| 125 | }; |
| 126 | |
| 127 | if (mode === "search") { |
| 128 | setImageSearchState({ |
| 129 | mode: props.element.stockImageProvider ?? stockImageProvider, |
| 130 | }); |
| 131 | } |
| 132 | |
| 133 | openPresentationImageEditor( |
| 134 | mode, |
| 135 | boundUpdateElement, |
| 136 | { |
| 137 | ...props.element, |
| 138 | url: computedImageUrl, |
| 139 | }, |
| 140 | getPresentationImageElementFrame(props.element), |
| 141 | ); |
| 142 | } |
| 143 | }; |
| 144 | |
| 145 | useEffect(() => { |
| 146 | if ( |
| 147 | !generationTarget || |
| 148 | !props.element.query || |
| 149 | props.element.url || |
| 150 | computedImageUrl || |
| 151 | hasGenerationFailed |
| 152 | ) { |
| 153 | return; |
| 154 | } |
| 155 | |
| 156 | if (computedGen?.query === props.element.query) { |
| 157 | return; |
| 158 | } |
| 159 | |
| 160 | const source = resolvePresentationImageGenerationSource({ |
| 161 | globalImageSource: imageSource, |
| 162 | imageSource: props.element.imageSource, |
| 163 | }); |
| 164 | |
| 165 | startPresentationImageGeneration(generationTarget, props.element.query, { |
| 166 | imageModel, |
| 167 | source, |
| 168 | ...(source === "stock" |
| 169 | ? { |
| 170 | stockImageProvider: |
| 171 | props.element.stockImageProvider ?? stockImageProvider, |
| 172 | } |
| 173 | : {}), |
| 174 | }); |
| 175 | }, [ |
| 176 | computedGen?.query, |
| 177 | computedImageUrl, |
| 178 | generationTarget, |
| 179 | hasGenerationFailed, |
| 180 | imageModel, |
| 181 | imageSource, |
| 182 | props.element.imageSource, |
| 183 | props.element.query, |
| 184 | props.element.stockImageProvider, |
| 185 | props.element.url, |
| 186 | startPresentationImageGeneration, |
| 187 | stockImageProvider, |
| 188 | ]); |
| 189 | |
| 190 | const imageStyles = getPresentationImageCropStyles(cropSettings); |
| 191 | |
| 192 | if (isReadOnly) { |
| 193 | return ( |
| 194 | <PlateElement ref={ref} className={cn(className)} {...props}> |
| 195 | <div ref={imageRef}> |
| 196 | <Resizable |
| 197 | align={align} |
| 198 | options={{ |
| 199 | align, |
| 200 | readOnly, |
| 201 | }} |
| 202 | > |
| 203 | {computedImageUrl ? ( |
| 204 | <div className="my-4 text-center"> |
| 205 | <Image |
| 206 | ref={handleRef} |
| 207 | className={cn("h-auto max-w-full")} |
| 208 | alt={props.element.query ?? ""} |
| 209 | src={computedImageUrl} |
| 210 | loading="lazy" |
| 211 | decoding="async" |
| 212 | style={{ |
| 213 | ...imageStyles, |
| 214 | borderRadius: "var(--presentation-border-radius, 0.5rem)", |
| 215 | boxShadow: |
| 216 | "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))", |
| 217 | }} |
| 218 | {...nodeProps} |
| 219 | /> |
| 220 | </div> |
| 221 | ) : ( |
| 222 | <PresentationImagePlaceholder |
| 223 | className="pointer-events-auto h-full w-full rounded-[inherit]" |
| 224 | element={props.element} |
| 225 | imageNotFound={hasGenerationFailed} |
| 226 | /> |
| 227 | )} |
| 228 | {children} |
| 229 | </Resizable> |
| 230 | </div> |
| 231 | </PlateElement> |
| 232 | ); |
| 233 | } |
| 234 | |
| 235 | return ( |
| 236 | <PlateElement ref={ref} className={cn(className)} {...props}> |
| 237 | <div ref={imageRef}> |
| 238 | <Resizable |
| 239 | align={align} |
| 240 | options={{ |
| 241 | align, |
| 242 | readOnly, |
| 243 | }} |
| 244 | className={cn("flex", !props.element.width && "w-full")} |
| 245 | > |
| 246 | <ResizeHandle |
| 247 | className={mediaResizeHandleVariants({ direction: "left" })} |
| 248 | options={{ direction: "left" }} |
| 249 | /> |
| 250 | {isGenerating && !computedImageUrl ? ( |
| 251 | <div className="relative min-h-50 w-full"> |
| 252 | <div className="absolute inset-0 flex items-center justify-center rounded-sm bg-muted"> |
| 253 | <div className="flex flex-col items-center gap-2"> |
| 254 | <Spinner className="h-6 w-6" /> |
| 255 | <span className="text-sm text-muted-foreground"> |
| 256 | Generating image... |
| 257 | </span> |
| 258 | </div> |
| 259 | </div> |
| 260 | </div> |
| 261 | ) : !computedImageUrl ? ( |
| 262 | <div |
| 263 | ref={handleRef} |
| 264 | className={cn( |
| 265 | "my-4 aspect-video w-full", |
| 266 | focused && selected && "ring-2 ring-ring ring-offset-2", |
| 267 | )} |
| 268 | style={{ |
| 269 | borderRadius: "var(--presentation-border-radius, 0.5rem)", |
| 270 | }} |
| 271 | {...nodeProps} |
| 272 | > |
| 273 | <PresentationImagePlaceholder |
| 274 | className="pointer-events-auto h-full w-full rounded-[inherit]" |
| 275 | element={props.element} |
| 276 | imageNotFound={hasGenerationFailed} |
| 277 | /> |
| 278 | </div> |
| 279 | ) : ( |
| 280 | <div className="my-4 flex-1 text-center"> |
| 281 | <Image |
| 282 | ref={handleRef} |
| 283 | className={cn( |
| 284 | "h-auto w-full", |
| 285 | "cursor-pointer", |
| 286 | focused && selected && "ring-2 ring-ring ring-offset-2", |
| 287 | isDragging && "opacity-50", |
| 288 | )} |
| 289 | alt={props.element.query ?? ""} |
| 290 | src={computedImageUrl} |
| 291 | loading="lazy" |
| 292 | decoding="async" |
| 293 | onClick={(event) => { |
| 294 | if (!presentationImageEditorInitialMode) { |
| 295 | return; |
| 296 | } |
| 297 | |
| 298 | event.preventDefault(); |
| 299 | event.stopPropagation(); |
| 300 | handleOpenEditor(presentationImageEditorInitialMode); |
| 301 | }} |
| 302 | onDoubleClick={() => { |
| 303 | const mode: ImageEditorMode = |
| 304 | props.element.imageSource === "search" |
| 305 | ? "search" |
| 306 | : props.element.imageSource === "gif" |
| 307 | ? "gif" |
| 308 | : "generate"; |
| 309 | handleOpenEditor(mode); |
| 310 | }} |
| 311 | style={{ |
| 312 | ...imageStyles, |
| 313 | borderRadius: "var(--presentation-border-radius, 0.5rem)", |
| 314 | boxShadow: |
| 315 | "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))", |
| 316 | }} |
| 317 | onError={(e) => { |
| 318 | console.error( |
| 319 | "Presentation image failed to load:", |
| 320 | e, |
| 321 | computedImageUrl, |
| 322 | ); |
| 323 | }} |
| 324 | {...nodeProps} |
| 325 | /> |
| 326 | </div> |
| 327 | )} |
| 328 | <ResizeHandle |
| 329 | className={mediaResizeHandleVariants({ |
| 330 | direction: "right", |
| 331 | })} |
| 332 | options={{ direction: "right" }} |
| 333 | /> |
| 334 | {children} |
| 335 | </Resizable> |
| 336 | </div> |
| 337 | </PlateElement> |
| 338 | ); |
| 339 | }, |
| 340 | ); |
| 341 | |
| 342 | function getPresentationImageElementFrame(element: PresentationImageNode): |
| 343 | | { |
| 344 | height: number; |
| 345 | width: number; |
| 346 | } |
| 347 | | undefined { |
| 348 | const width = typeof element.width === "number" ? element.width : undefined; |
| 349 | const height = |
| 350 | typeof element.height === "number" ? element.height : undefined; |
| 351 | |
| 352 | if (width && height) { |
| 353 | return { height, width }; |
| 354 | } |
| 355 | |
| 356 | return undefined; |
| 357 | } |
| 358 |