| 1 | "use client"; |
| 2 | |
| 3 | import Image from "next/image"; |
| 4 | import { type TElement } from "platejs"; |
| 5 | import { useEditorRef } from "platejs/react"; |
| 6 | |
| 7 | import { type Image as GeneratedImage } from "@/app/_actions/apps/image-studio/fetch"; |
| 8 | import { SharedGenerateControls } from "@/components/presentation/shared/SharedGenerateControls"; |
| 9 | import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave"; |
| 10 | import { usePresentationState } from "@/states/presentation-state"; |
| 11 | import { type RootImage as RootImageType } from "../../../utils/parser"; |
| 12 | import { ActionButtons } from "./ActionButtons"; |
| 13 | import { ImagePreview } from "./ImagePreview"; |
| 14 | import { type ImageDimensions } from "./useImageDimensions"; |
| 15 | |
| 16 | interface GenerateControlsProps { |
| 17 | element: TElement & RootImageType; |
| 18 | slideId?: string; |
| 19 | isRootImage: boolean; |
| 20 | onImageSelect?: (url: string, prompt: string) => void; |
| 21 | onImagesGenerated?: (images: GeneratedImage[]) => void; |
| 22 | onOpenCrop?: () => void; |
| 23 | imageDimensions?: ImageDimensions; |
| 24 | } |
| 25 | |
| 26 | export function GenerateControls({ |
| 27 | element, |
| 28 | slideId, |
| 29 | isRootImage, |
| 30 | onImageSelect, |
| 31 | onImagesGenerated, |
| 32 | onOpenCrop, |
| 33 | imageDimensions, |
| 34 | }: GenerateControlsProps) { |
| 35 | const editor = useEditorRef(slideId); |
| 36 | const { saveImmediately } = useDebouncedSave(); |
| 37 | |
| 38 | const handleImageSelect = (url: string, prompt: string) => { |
| 39 | const { setSlides, clearRootImageGeneration } = |
| 40 | usePresentationState.getState(); |
| 41 | if (isRootImage) { |
| 42 | if (slideId) clearRootImageGeneration(slideId); |
| 43 | setSlides((slides) => |
| 44 | slides.map((slide) => |
| 45 | slide.id === slideId |
| 46 | ? { |
| 47 | ...slide, |
| 48 | rootImage: { |
| 49 | ...slide.rootImage!, |
| 50 | url: url, |
| 51 | query: prompt, |
| 52 | imageSource: "generate", |
| 53 | embedType: undefined, |
| 54 | chartType: undefined, |
| 55 | chartData: undefined, |
| 56 | }, |
| 57 | } |
| 58 | : slide, |
| 59 | ), |
| 60 | ); |
| 61 | void saveImmediately(); |
| 62 | } else { |
| 63 | editor.tf.setNodes( |
| 64 | { url: url, query: prompt, embedType: undefined }, |
| 65 | { at: editor.api.findPath(element) }, |
| 66 | ); |
| 67 | void saveImmediately(); |
| 68 | } |
| 69 | }; |
| 70 | |
| 71 | const selectImage = onImageSelect ?? handleImageSelect; |
| 72 | |
| 73 | // Calculate thumbnail scale to fit in 90x90 box |
| 74 | const thumbnailDimensions = imageDimensions |
| 75 | ? { |
| 76 | ...imageDimensions, |
| 77 | scale: Math.min( |
| 78 | 360 / imageDimensions.width, |
| 79 | 360 / imageDimensions.height, |
| 80 | ), |
| 81 | } |
| 82 | : { width: 360, height: 360, scale: 1 }; |
| 83 | |
| 84 | const handleCropSettingsChange = (settings: typeof element.cropSettings) => { |
| 85 | const { setSlides } = usePresentationState.getState(); |
| 86 | if (isRootImage) { |
| 87 | setSlides((slides) => |
| 88 | slides.map((slide) => |
| 89 | slide.id === slideId |
| 90 | ? { |
| 91 | ...slide, |
| 92 | rootImage: { |
| 93 | ...slide.rootImage!, |
| 94 | cropSettings: settings, |
| 95 | }, |
| 96 | } |
| 97 | : slide, |
| 98 | ), |
| 99 | ); |
| 100 | void saveImmediately(); |
| 101 | } else { |
| 102 | editor.tf.setNodes( |
| 103 | { cropSettings: settings }, |
| 104 | { at: editor.api.findPath(element) }, |
| 105 | ); |
| 106 | void saveImmediately(); |
| 107 | } |
| 108 | }; |
| 109 | |
| 110 | return ( |
| 111 | <div className="flex flex-col gap-4"> |
| 112 | {/* Mini Preview */} |
| 113 | |
| 114 | <div className="flex flex-col gap-2"> |
| 115 | <div className="flex justify-center"> |
| 116 | <div className="relative flex size-90 items-center justify-center overflow-hidden rounded-md border bg-muted shadow"> |
| 117 | {imageDimensions ? ( |
| 118 | <ImagePreview |
| 119 | element={element} |
| 120 | currentMode="generate" |
| 121 | localCropSettings={{ |
| 122 | objectFit: element.cropSettings?.objectFit ?? "cover", |
| 123 | objectPosition: { |
| 124 | x: element.cropSettings?.objectPosition.x ?? 50, |
| 125 | y: element.cropSettings?.objectPosition.y ?? 50, |
| 126 | }, |
| 127 | zoom: element.cropSettings?.zoom ?? 1, |
| 128 | }} |
| 129 | imageDimensions={thumbnailDimensions} |
| 130 | onCropSettingsChange={() => {}} |
| 131 | hideControls={true} |
| 132 | /> |
| 133 | ) : element.url ? ( |
| 134 | <Image |
| 135 | unoptimized |
| 136 | width={400} |
| 137 | height={300} |
| 138 | src={element.url} |
| 139 | alt="Preview" |
| 140 | className="h-full w-full object-cover" |
| 141 | /> |
| 142 | ) : ( |
| 143 | <div className="h-full w-full bg-muted" /> |
| 144 | )} |
| 145 | </div> |
| 146 | </div> |
| 147 | |
| 148 | {/* Action Buttons - Always Visible */} |
| 149 | <div className="flex justify-center"> |
| 150 | <ActionButtons |
| 151 | element={element} |
| 152 | slideId={slideId} |
| 153 | isRootImage={isRootImage} |
| 154 | imageUrl={element.url} |
| 155 | onOpenCrop={onOpenCrop} |
| 156 | cropSettings={{ |
| 157 | objectFit: element.cropSettings?.objectFit ?? "cover", |
| 158 | objectPosition: { |
| 159 | x: element.cropSettings?.objectPosition.x ?? 50, |
| 160 | y: element.cropSettings?.objectPosition.y ?? 50, |
| 161 | }, |
| 162 | zoom: element.cropSettings?.zoom ?? 1, |
| 163 | }} |
| 164 | onCropSettingsChange={handleCropSettingsChange} |
| 165 | showInOverlay={true} |
| 166 | /> |
| 167 | </div> |
| 168 | </div> |
| 169 | |
| 170 | <SharedGenerateControls |
| 171 | onImageSelect={selectImage} |
| 172 | initialPrompt={element.query ?? ""} |
| 173 | onImagesGenerated={onImagesGenerated} |
| 174 | /> |
| 175 | </div> |
| 176 | ); |
| 177 | } |
| 178 |