| 1 | "use client"; |
| 2 | |
| 3 | import { ImageIcon, Loader2 } from "lucide-react"; |
| 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | |
| 6 | import { |
| 7 | type PlateSlide, |
| 8 | type RootImage, |
| 9 | } from "@/components/notebook/presentation/utils/parser"; |
| 10 | import { getSlidesToUpdate } from "@/hooks/presentation/agentTools"; |
| 11 | import { getRootImageGenerationTarget } from "@/lib/presentation/image-generation"; |
| 12 | import { usePresentationState } from "@/states/presentation-state"; |
| 13 | import { PresentationReplaceImageResult } from "./ReplaceImage"; |
| 14 | |
| 15 | type Scope = "all" | undefined; |
| 16 | |
| 17 | function cloneRootImage( |
| 18 | rootImage: RootImage | undefined, |
| 19 | ): RootImage | undefined { |
| 20 | if (!rootImage) { |
| 21 | return undefined; |
| 22 | } |
| 23 | |
| 24 | return { |
| 25 | ...rootImage, |
| 26 | cropSettings: rootImage.cropSettings |
| 27 | ? { |
| 28 | ...rootImage.cropSettings, |
| 29 | objectPosition: rootImage.cropSettings.objectPosition |
| 30 | ? { ...rootImage.cropSettings.objectPosition } |
| 31 | : rootImage.cropSettings.objectPosition, |
| 32 | } |
| 33 | : undefined, |
| 34 | size: rootImage.size ? { ...rootImage.size } : undefined, |
| 35 | chartOptions: rootImage.chartOptions |
| 36 | ? { ...rootImage.chartOptions } |
| 37 | : undefined, |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | function cloneSlideWithRootImage( |
| 42 | slide: PlateSlide, |
| 43 | rootImage: RootImage | undefined, |
| 44 | ): PlateSlide { |
| 45 | return { |
| 46 | ...slide, |
| 47 | rootImage: cloneRootImage(rootImage), |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | function buildDirectUrlReplacementSlide( |
| 52 | slide: PlateSlide, |
| 53 | imageUrl: string, |
| 54 | ): PlateSlide { |
| 55 | return cloneSlideWithRootImage(slide, { |
| 56 | ...(cloneRootImage(slide.rootImage) ?? { query: "" }), |
| 57 | url: imageUrl, |
| 58 | embedType: undefined, |
| 59 | }); |
| 60 | } |
| 61 | |
| 62 | function syncRootImageGeneration(slidesToApply: PlateSlide[]): void { |
| 63 | usePresentationState.setState((state) => { |
| 64 | const nextGeneration = { ...state.rootImageGeneration }; |
| 65 | |
| 66 | for (const slide of slidesToApply) { |
| 67 | if (!slide.rootImage?.url) { |
| 68 | delete nextGeneration[slide.id]; |
| 69 | continue; |
| 70 | } |
| 71 | |
| 72 | nextGeneration[slide.id] = { |
| 73 | query: slide.rootImage.query, |
| 74 | source: |
| 75 | slide.rootImage.imageSource === "gif" |
| 76 | ? "gif" |
| 77 | : slide.rootImage.imageSource === "search" |
| 78 | ? "stock" |
| 79 | : "ai", |
| 80 | status: "success", |
| 81 | target: getRootImageGenerationTarget(slide.id), |
| 82 | url: slide.rootImage.url, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | return { |
| 87 | rootImageGeneration: nextGeneration, |
| 88 | }; |
| 89 | }); |
| 90 | } |
| 91 | |
| 92 | function ImagePreviewCard({ |
| 93 | slideNumber, |
| 94 | slide, |
| 95 | }: { |
| 96 | slideNumber: number | null; |
| 97 | slide: PlateSlide; |
| 98 | }) { |
| 99 | const imageUrl = slide.rootImage?.url; |
| 100 | const imageLabel = slide.rootImage?.query?.trim(); |
| 101 | |
| 102 | return ( |
| 103 | <div className="group relative overflow-hidden rounded-md border border-muted bg-card"> |
| 104 | <div className="absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-linear-to-b from-background/95 via-background/80 to-transparent px-3 py-2 text-xs text-muted-foreground"> |
| 105 | <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1.5 font-medium"> |
| 106 | {slideNumber ?? "-"} |
| 107 | </span> |
| 108 | <span className="truncate font-medium text-foreground/80"> |
| 109 | {imageLabel || (imageUrl ? "Image updated" : "No image")} |
| 110 | </span> |
| 111 | </div> |
| 112 | |
| 113 | <div className="aspect-video bg-muted/30"> |
| 114 | {imageUrl ? ( |
| 115 | // biome-ignore lint/performance/noImgElement: Previewing dynamic slide images in chat. |
| 116 | <img |
| 117 | src={imageUrl} |
| 118 | alt={imageLabel || `Slide ${slideNumber ?? ""} image`} |
| 119 | className="h-full w-full object-cover" |
| 120 | /> |
| 121 | ) : ( |
| 122 | <div className="flex h-full flex-col items-center justify-center gap-2 bg-muted/20 text-muted-foreground"> |
| 123 | <ImageIcon className="h-5 w-5" /> |
| 124 | <span className="text-xs">No image</span> |
| 125 | </div> |
| 126 | )} |
| 127 | </div> |
| 128 | |
| 129 | <div className="pointer-events-none absolute inset-0 rounded-md ring-0 ring-primary/60 transition-all group-hover:ring" /> |
| 130 | </div> |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | export function PresentationReplaceImageCompare({ |
| 135 | originalSlides, |
| 136 | scope, |
| 137 | slideIds, |
| 138 | imageUrl, |
| 139 | imagePrompt, |
| 140 | imageSource: _imageSource, |
| 141 | message, |
| 142 | stockImageProvider: _stockImageProvider, |
| 143 | }: { |
| 144 | originalSlides: PlateSlide[]; |
| 145 | scope?: Scope; |
| 146 | slideIds?: string[]; |
| 147 | imageUrl?: string; |
| 148 | imagePrompt?: string; |
| 149 | imageSource?: "ai" | "stock" | "gif"; |
| 150 | message?: string; |
| 151 | stockImageProvider?: "unsplash" | "pixabay" | "google"; |
| 152 | }) { |
| 153 | const currentSlides = usePresentationState((state) => state.slides); |
| 154 | const rootImageGeneration = usePresentationState( |
| 155 | (state) => state.rootImageGeneration, |
| 156 | ); |
| 157 | |
| 158 | const targetSlideIds = useMemo( |
| 159 | () => getSlidesToUpdate(scope, slideIds), |
| 160 | [scope, slideIds], |
| 161 | ); |
| 162 | |
| 163 | const targetSlideIdSet = useMemo( |
| 164 | () => new Set(targetSlideIds ?? []), |
| 165 | [targetSlideIds], |
| 166 | ); |
| 167 | |
| 168 | const slideNumberById = useMemo(() => { |
| 169 | return new Map( |
| 170 | originalSlides.map((slide, index) => [slide.id, index + 1] as const), |
| 171 | ); |
| 172 | }, [originalSlides]); |
| 173 | |
| 174 | const originalTargetSlides = useMemo(() => { |
| 175 | return originalSlides |
| 176 | .filter((slide) => targetSlideIdSet.has(slide.id)) |
| 177 | .map((slide) => cloneSlideWithRootImage(slide, slide.rootImage)); |
| 178 | }, [originalSlides, targetSlideIdSet]); |
| 179 | |
| 180 | const immediateModifiedSlides = useMemo(() => { |
| 181 | if (!imageUrl || originalTargetSlides.length === 0) { |
| 182 | return []; |
| 183 | } |
| 184 | |
| 185 | return originalTargetSlides.map((slide) => |
| 186 | buildDirectUrlReplacementSlide(slide, imageUrl), |
| 187 | ); |
| 188 | }, [imageUrl, originalTargetSlides]); |
| 189 | |
| 190 | const promptResolvedSlides = useMemo(() => { |
| 191 | if (!imagePrompt || originalTargetSlides.length === 0) { |
| 192 | return []; |
| 193 | } |
| 194 | |
| 195 | const resolvedSlides: PlateSlide[] = []; |
| 196 | |
| 197 | for (const originalSlide of originalTargetSlides) { |
| 198 | const currentSlide = currentSlides.find( |
| 199 | (slide) => slide.id === originalSlide.id, |
| 200 | ); |
| 201 | if (!currentSlide) { |
| 202 | return []; |
| 203 | } |
| 204 | |
| 205 | const generationState = rootImageGeneration[originalSlide.id]; |
| 206 | const resolvedUrl = |
| 207 | currentSlide.rootImage?.url ?? |
| 208 | (generationState?.query === imagePrompt |
| 209 | ? generationState.url |
| 210 | : undefined); |
| 211 | const resolvedQuery = |
| 212 | currentSlide.rootImage?.query || |
| 213 | (generationState?.query === imagePrompt |
| 214 | ? generationState.query |
| 215 | : undefined); |
| 216 | |
| 217 | if (!resolvedUrl || resolvedQuery !== imagePrompt) { |
| 218 | return []; |
| 219 | } |
| 220 | |
| 221 | resolvedSlides.push( |
| 222 | cloneSlideWithRootImage(originalSlide, { |
| 223 | ...(cloneRootImage(currentSlide.rootImage) ?? |
| 224 | cloneRootImage(originalSlide.rootImage) ?? { query: imagePrompt }), |
| 225 | query: imagePrompt, |
| 226 | url: resolvedUrl, |
| 227 | embedType: undefined, |
| 228 | }), |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | return resolvedSlides; |
| 233 | }, [currentSlides, imagePrompt, originalTargetSlides, rootImageGeneration]); |
| 234 | |
| 235 | const [modifiedSlides, setModifiedSlides] = useState<PlateSlide[]>( |
| 236 | immediateModifiedSlides, |
| 237 | ); |
| 238 | |
| 239 | useEffect(() => { |
| 240 | if (immediateModifiedSlides.length > 0) { |
| 241 | setModifiedSlides(immediateModifiedSlides); |
| 242 | } |
| 243 | }, [immediateModifiedSlides]); |
| 244 | |
| 245 | useEffect(() => { |
| 246 | if ( |
| 247 | modifiedSlides.length === originalTargetSlides.length && |
| 248 | modifiedSlides.length > 0 |
| 249 | ) { |
| 250 | return; |
| 251 | } |
| 252 | |
| 253 | if (promptResolvedSlides.length === originalTargetSlides.length) { |
| 254 | setModifiedSlides(promptResolvedSlides); |
| 255 | } |
| 256 | }, [ |
| 257 | modifiedSlides.length, |
| 258 | originalTargetSlides.length, |
| 259 | promptResolvedSlides, |
| 260 | ]); |
| 261 | |
| 262 | const applySlides = (slidesToApply: PlateSlide[]) => { |
| 263 | const slidesById = new Map(slidesToApply.map((slide) => [slide.id, slide])); |
| 264 | const { slides, setSlides } = usePresentationState.getState(); |
| 265 | |
| 266 | setSlides( |
| 267 | slides.map((slide) => { |
| 268 | const replacement = slidesById.get(slide.id); |
| 269 | return replacement ?? slide; |
| 270 | }), |
| 271 | ); |
| 272 | syncRootImageGeneration(slidesToApply); |
| 273 | }; |
| 274 | |
| 275 | if (originalTargetSlides.length === 0) { |
| 276 | return <PresentationReplaceImageResult message={message} />; |
| 277 | } |
| 278 | |
| 279 | const previewReady = |
| 280 | modifiedSlides.length === originalTargetSlides.length && |
| 281 | modifiedSlides.length > 0; |
| 282 | |
| 283 | if (!previewReady) { |
| 284 | return ( |
| 285 | <div className="w-full rounded-lg border bg-card p-3"> |
| 286 | <div className="flex items-center gap-2"> |
| 287 | <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> |
| 288 | <span className="text-sm text-muted-foreground"> |
| 289 | Preparing updated image preview... |
| 290 | </span> |
| 291 | </div> |
| 292 | </div> |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | return ( |
| 297 | <div className="w-full max-w-95"> |
| 298 | <div className="grid gap-3 md:grid-cols-2"> |
| 299 | <div |
| 300 | role="button" |
| 301 | tabIndex={0} |
| 302 | onClick={() => applySlides(originalTargetSlides)} |
| 303 | onKeyDown={(event) => { |
| 304 | if (event.key === "Enter" || event.key === " ") { |
| 305 | event.preventDefault(); |
| 306 | applySlides(originalTargetSlides); |
| 307 | } |
| 308 | }} |
| 309 | className="group cursor-pointer text-left outline-hidden" |
| 310 | > |
| 311 | <div className="mb-2 flex items-center justify-between gap-2"> |
| 312 | <h3 className="text-lg font-bold">Original</h3> |
| 313 | <span className="text-xs text-muted-foreground"> |
| 314 | {originalTargetSlides.length} slide |
| 315 | {originalTargetSlides.length === 1 ? "" : "s"} |
| 316 | </span> |
| 317 | </div> |
| 318 | <div className="space-y-2 rounded-md group-hover:outline group-hover:outline-primary"> |
| 319 | {originalTargetSlides.map((slide) => ( |
| 320 | <ImagePreviewCard |
| 321 | key={`original-${slide.id}`} |
| 322 | slide={slide} |
| 323 | slideNumber={slideNumberById.get(slide.id) ?? null} |
| 324 | /> |
| 325 | ))} |
| 326 | </div> |
| 327 | </div> |
| 328 | |
| 329 | <div |
| 330 | role="button" |
| 331 | tabIndex={0} |
| 332 | onClick={() => applySlides(modifiedSlides)} |
| 333 | onKeyDown={(event) => { |
| 334 | if (event.key === "Enter" || event.key === " ") { |
| 335 | event.preventDefault(); |
| 336 | applySlides(modifiedSlides); |
| 337 | } |
| 338 | }} |
| 339 | className="group cursor-pointer text-left outline-hidden" |
| 340 | > |
| 341 | <div className="mb-2 flex items-center justify-between gap-2"> |
| 342 | <h3 className="text-lg font-bold">Modified</h3> |
| 343 | <span className="text-xs text-muted-foreground"> |
| 344 | {modifiedSlides.length} slide |
| 345 | {modifiedSlides.length === 1 ? "" : "s"} |
| 346 | </span> |
| 347 | </div> |
| 348 | <div className="space-y-2 rounded-md group-hover:outline group-hover:outline-primary"> |
| 349 | {modifiedSlides.map((slide) => ( |
| 350 | <ImagePreviewCard |
| 351 | key={`modified-${slide.id}`} |
| 352 | slide={slide} |
| 353 | slideNumber={slideNumberById.get(slide.id) ?? null} |
| 354 | /> |
| 355 | ))} |
| 356 | </div> |
| 357 | </div> |
| 358 | </div> |
| 359 | </div> |
| 360 | ); |
| 361 | } |
| 362 |