返回 presentation-ai
ReplaceImageCompare.tsx
根目录 / src / components / presentation / agent / tools / ReplaceImageCompare.tsx
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
362 lines Plain Text