返回 presentation-ai
ImageEditorPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / ImageEditorPanel.tsx
1 "use client";
2
3 import {
4 Clapperboard,
5 Globe,
6 ImageIcon,
7 Images,
8 Search,
9 Sparkles,
10 X,
11 } from "lucide-react";
12 import { useEffect, useState } from "react";
13
14 import {
15 ErrorDisplay,
16 GenerateControls,
17 } from "@/components/notebook/presentation/editor/custom-elements/image-editor";
18 import { CropModal } from "@/components/notebook/presentation/editor/custom-elements/image-editor/CropModal";
19 import { EmbedControls } from "@/components/notebook/presentation/editor/custom-elements/image-editor/EmbedControls";
20 import { GeneratedImagesGrid } from "@/components/notebook/presentation/editor/custom-elements/image-editor/GeneratedImagesGrid";
21 import { GifSearchControls } from "@/components/notebook/presentation/editor/custom-elements/image-editor/GifSearchControls";
22 import { ImageSearchControls } from "@/components/notebook/presentation/editor/custom-elements/image-editor/ImageSearchControls";
23 import { useImageDimensions } from "@/components/notebook/presentation/editor/custom-elements/image-editor/useImageDimensions";
24 import { YourImagesControls } from "@/components/notebook/presentation/editor/custom-elements/image-editor/YourImagesControls";
25 import { type ImageCropSettings } from "@/components/notebook/presentation/utils/types";
26 import { Button } from "@/components/ui/button";
27 import { ScrollArea } from "@/components/ui/scroll-area";
28 import {
29 Select,
30 SelectContent,
31 SelectItem,
32 SelectTrigger,
33 SelectValue,
34 } from "@/components/ui/select";
35 import { Separator } from "@/components/ui/separator";
36 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
37 import { getPresentationTitleSearchQuery } from "@/lib/presentation/title-search-query";
38 import {
39 usePresentationState,
40 type ImageEditorMode,
41 } from "@/states/presentation-state";
42 import { ChartEditorControls } from "./ChartEditorControls";
43
44 // Define tab options with icons
45 const TAB_OPTIONS: {
46 value: ImageEditorMode;
47 label: string;
48 icon: React.ReactNode;
49 }[] = [
50 {
51 value: "generate",
52 label: "AI Generate",
53 icon: <ImageIcon className="size-4" />,
54 },
55 {
56 value: "your-images",
57 label: "Uploaded Images",
58 icon: <Images className="size-4" />,
59 },
60 {
61 value: "generated-images",
62 label: "Generated Images",
63 icon: <Sparkles className="size-4" />,
64 },
65 { value: "search", label: "Search", icon: <Search className="size-4" /> },
66 { value: "gif", label: "GIFs", icon: <Clapperboard className="size-4" /> },
67 { value: "embed", label: "Embed", icon: <Globe className="size-4" /> },
68 ];
69
70 export function ImageEditorPanel() {
71 const { saveImmediately } = useDebouncedSave();
72
73 const slides = usePresentationState((s) => s.slides);
74 const setSlides = usePresentationState((s) => s.setSlides);
75 const imageEditorInitialMode = usePresentationState(
76 (s) => s.imageEditorInitialMode,
77 );
78 const closeImageEditor = usePresentationState((s) => s.closeImageEditor);
79 const currentSlideId = usePresentationState((s) => s.currentSlideId);
80 const currentPresentationTitle = usePresentationState(
81 (s) => s.currentPresentationTitle,
82 );
83 const setPaletteDropTarget = usePresentationState(
84 (s) => s.setPaletteDropTarget,
85 );
86
87 // Get current slide data from state
88 const currentSlide = slides.find((s) => s.id === currentSlideId);
89 const slideId = currentSlide?.id ?? "";
90 const initialMode = imageEditorInitialMode ?? "generate";
91
92 const rootImage = currentSlide?.rootImage;
93 const layoutType = currentSlide?.layoutType ?? "left";
94 const initialSearchQuery = getPresentationTitleSearchQuery(
95 currentPresentationTitle,
96 );
97
98 // Create a synthetic element from rootImage for compatibility with existing controls
99 const element = {
100 type: "rootImage" as const,
101 children: [] as never[],
102 query: rootImage?.query ?? "",
103 url: rootImage?.url,
104 embedType: rootImage?.embedType,
105 cropSettings: rootImage?.cropSettings,
106 imageSource: rootImage?.imageSource,
107 size: rootImage?.size,
108 };
109
110 // Local state for current tab
111 const [currentMode, setCurrentMode] = useState<ImageEditorMode>(
112 initialMode ?? (element.embedType ? "embed" : "generate"),
113 );
114
115 // Sync mode when imageEditorInitialMode changes
116 useEffect(() => {
117 if (imageEditorInitialMode) {
118 setCurrentMode(imageEditorInitialMode);
119 }
120 }, [imageEditorInitialMode]);
121
122 const [isCropModalOpen, setIsCropModalOpen] = useState(false);
123
124 // Use the custom hook for dimension calculations
125 const imageDimensions = useImageDimensions({
126 element,
127 slideId,
128 layoutType,
129 });
130
131 const handleEmbedChange = (embedType: string, url: string) => {
132 setPaletteDropTarget(null);
133 setSlides((slides) =>
134 slides.map((slide) =>
135 slide.id === slideId
136 ? {
137 ...slide,
138 rootImage: {
139 ...slide.rootImage!,
140 // When embed type is "image", keep it as undefined to treat as regular root image
141 embedType: embedType === "image" ? undefined : embedType,
142 url,
143 chartType: undefined,
144 chartData: undefined,
145 paletteDropMutable: false,
146 },
147 }
148 : slide,
149 ),
150 );
151 void saveImmediately();
152 };
153
154 const handleClearEmbed = () => {
155 setPaletteDropTarget(null);
156 setSlides((slides) =>
157 slides.map((slide) =>
158 slide.id === slideId
159 ? {
160 ...slide,
161 rootImage: {
162 ...slide.rootImage!,
163 embedType: undefined,
164 url: undefined,
165 paletteDropMutable: false,
166 },
167 }
168 : slide,
169 ),
170 );
171 void saveImmediately();
172 setCurrentMode("generate");
173 };
174
175 const handleCropSave = (settings: ImageCropSettings) => {
176 setPaletteDropTarget(null);
177 setSlides((slides) =>
178 slides.map((slide) =>
179 slide.id === slideId
180 ? {
181 ...slide,
182 rootImage: {
183 ...slide.rootImage!,
184 cropSettings: settings,
185 paletteDropMutable: false,
186 },
187 }
188 : slide,
189 ),
190 );
191 void saveImmediately();
192 setIsCropModalOpen(false);
193 };
194
195 const handleGeneratedImageSelect = (url: string, prompt: string) => {
196 const { clearRootImageGeneration } = usePresentationState.getState();
197
198 setPaletteDropTarget(null);
199 if (slideId) {
200 clearRootImageGeneration(slideId);
201 }
202
203 setSlides((slides) =>
204 slides.map((slide) =>
205 slide.id === slideId
206 ? {
207 ...slide,
208 rootImage: {
209 ...slide.rootImage!,
210 url,
211 query: prompt,
212 embedType: undefined,
213 imageSource: "generate",
214 chartType: undefined,
215 chartData: undefined,
216 paletteDropMutable: false,
217 },
218 }
219 : slide,
220 ),
221 );
222 void saveImmediately();
223 };
224
225 const handleModeChange = (value: string) => {
226 setCurrentMode(value as ImageEditorMode);
227 };
228
229 // Get current tab info
230 const currentTab = TAB_OPTIONS.find((t) => t.value === currentMode);
231
232 if (!imageEditorInitialMode) {
233 return null;
234 }
235
236 // Render content based on current mode
237 const renderContent = () => {
238 switch (currentMode) {
239 case "generate":
240 return (
241 <div className="flex h-full flex-col">
242 <div className="flex-none space-y-1 px-6 py-4">
243 <h3 className="leading-none font-medium">Generate Image</h3>
244 <p className="text-sm text-muted-foreground">
245 Create unique images using AI.
246 </p>
247 </div>
248 <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
249 <GenerateControls
250 element={element}
251 slideId={slideId}
252 isRootImage={true}
253 onOpenCrop={() => setIsCropModalOpen(true)}
254 imageDimensions={imageDimensions}
255 />
256 </div>
257 </div>
258 );
259 case "your-images":
260 return (
261 <div className="flex h-full flex-col">
262 <div className="flex-none space-y-1 px-6 py-4">
263 <h3 className="leading-none font-medium">Uploaded Images</h3>
264 <p className="text-sm text-muted-foreground">
265 Select from your uploaded images.
266 </p>
267 </div>
268 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
269 <YourImagesControls
270 element={element}
271 slideId={slideId}
272 isRootImage={true}
273 />
274 </div>
275 </div>
276 );
277 case "generated-images":
278 return (
279 <div className="flex h-full flex-col">
280 <div className="flex-none space-y-1 px-6 py-4">
281 <h3 className="leading-none font-medium">Generated Images</h3>
282 <p className="text-sm text-muted-foreground">
283 Select from your AI-generated images.
284 </p>
285 </div>
286 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
287 <GeneratedImagesGrid
288 onImageSelect={(image) =>
289 handleGeneratedImageSelect(image.url, image.prompt)
290 }
291 />
292 </div>
293 </div>
294 );
295 case "search":
296 return (
297 <div className="flex h-full flex-col">
298 <div className="flex-none space-y-1 px-6 py-4">
299 <h3 className="leading-none font-medium">Search Images</h3>
300 <p className="text-sm text-muted-foreground">
301 Find images from Unsplash, Pixabay, or live web results.
302 </p>
303 </div>
304 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
305 <ImageSearchControls
306 element={element}
307 slideId={slideId}
308 isRootImage={true}
309 initialQuery={initialSearchQuery}
310 initialQueryKey={currentPresentationTitle ?? undefined}
311 />
312 </div>
313 </div>
314 );
315 case "gif":
316 return (
317 <div className="flex h-full flex-col">
318 <div className="flex-none space-y-1 px-6 py-4">
319 <h3 className="leading-none font-medium">Search GIFs</h3>
320 <p className="text-sm text-muted-foreground">
321 Find animated GIFs from Giphy.
322 </p>
323 </div>
324 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
325 <GifSearchControls
326 element={element}
327 slideId={slideId}
328 isRootImage={true}
329 />
330 </div>
331 </div>
332 );
333 case "embed":
334 return (
335 <ScrollArea className="h-full">
336 <div className="space-y-6 p-6">
337 <div className="space-y-1">
338 <h3 className="leading-none font-medium">Embed Content</h3>
339 <p className="text-sm text-muted-foreground">
340 Embed videos, tweets, or other content.
341 </p>
342 </div>
343 <EmbedControls
344 embedType={element.embedType}
345 embedUrl={element.url}
346 onEmbedChange={handleEmbedChange}
347 onClearEmbed={handleClearEmbed}
348 imageDimensions={imageDimensions}
349 element={element}
350 slideId={slideId}
351 isRootImage={true}
352 onOpenCrop={() => setIsCropModalOpen(true)}
353 cropSettings={{
354 objectFit: element.cropSettings?.objectFit ?? "cover",
355 objectPosition: {
356 x: element.cropSettings?.objectPosition.x ?? 50,
357 y: element.cropSettings?.objectPosition.y ?? 50,
358 },
359 zoom: element.cropSettings?.zoom ?? 1,
360 }}
361 onCropSettingsChange={handleCropSave}
362 />
363 </div>
364 </ScrollArea>
365 );
366 case "chart":
367 return (
368 <div className="flex h-full flex-col">
369 <div className="flex-none space-y-1 px-6 py-4">
370 <h3 className="leading-none font-medium">Edit Chart</h3>
371 <p className="text-sm text-muted-foreground">
372 Customize your chart data and appearance.
373 </p>
374 </div>
375 <div className="min-h-0 flex-1 overflow-hidden">
376 <ChartEditorControls slideId={slideId} />
377 </div>
378 </div>
379 );
380 default:
381 return null;
382 }
383 };
384
385 return (
386 <div className="flex size-full flex-col border-l bg-background">
387 {/* Header */}
388 <div className="flex items-center justify-between border-b px-4 py-2">
389 <h2 className="text-sm font-semibold">Image Studio</h2>
390 <Button
391 variant="ghost"
392 size="icon"
393 onClick={closeImageEditor}
394 className="size-8 rounded-full p-0"
395 >
396 <X className="size-5" />
397 </Button>
398 </div>
399
400 {/* Mode Selector Dropdown */}
401 <div className="px-6 py-3">
402 <Select value={currentMode} onValueChange={handleModeChange}>
403 <SelectTrigger className="h-11 w-full rounded-xl">
404 <SelectValue>
405 {currentTab && (
406 <div className="flex items-center gap-2">
407 {currentTab.icon}
408 <span>{currentTab.label}</span>
409 </div>
410 )}
411 </SelectValue>
412 </SelectTrigger>
413 <SelectContent className="rounded-xl">
414 {TAB_OPTIONS.map((tab) => (
415 <SelectItem
416 key={tab.value}
417 value={tab.value}
418 className="cursor-pointer rounded-lg"
419 >
420 <div className="flex items-center gap-2">
421 {tab.icon}
422 <span>{tab.label}</span>
423 </div>
424 </SelectItem>
425 ))}
426 </SelectContent>
427 </Select>
428 </div>
429
430 <Separator />
431
432 {/* Content Area */}
433 <div className="min-h-0 flex-1 overflow-hidden">
434 <ErrorDisplay error={undefined} localError={null} />
435 {renderContent()}
436 </div>
437
438 {element.url && (
439 <CropModal
440 open={isCropModalOpen}
441 onOpenChange={setIsCropModalOpen}
442 imageUrl={element.url}
443 initialCropSettings={{
444 objectFit: element.cropSettings?.objectFit ?? "cover",
445 objectPosition: {
446 x: element.cropSettings?.objectPosition.x ?? 50,
447 y: element.cropSettings?.objectPosition.y ?? 50,
448 },
449 zoom: element.cropSettings?.zoom ?? 1,
450 }}
451 onSave={handleCropSave}
452 imageDimensions={imageDimensions}
453 />
454 )}
455 </div>
456 );
457 }
458
458 lines Plain Text