返回 presentation-ai
PresentationImageEditorPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / PresentationImageEditorPanel.tsx
1 "use client";
2
3 import { useQueryClient } from "@tanstack/react-query";
4 import {
5 Clapperboard,
6 ImageIcon,
7 Images,
8 Loader2,
9 Scissors,
10 Search,
11 Sparkles,
12 Upload,
13 X,
14 } from "lucide-react";
15 import Image from "next/image";
16 import { useRef, useState } from "react";
17 import { toast } from "sonner";
18
19 import { CropModal } from "@/components/notebook/presentation/editor/custom-elements/image-editor/CropModal";
20 import { GeneratedImagesGrid } from "@/components/notebook/presentation/editor/custom-elements/image-editor/GeneratedImagesGrid";
21 import { UploadedImagesGrid } from "@/components/notebook/presentation/editor/custom-elements/image-editor/UploadedImagesGrid";
22 import { getPresentationImageCropStyles } from "@/components/notebook/presentation/editor/custom-elements/presentation-image-layout";
23 import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop";
24 import { type ImageCropSettings } from "@/components/notebook/presentation/utils/types";
25 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
26 import { SharedGenerateControls } from "@/components/presentation/shared/SharedGenerateControls";
27 import { SharedGifSearchControls } from "@/components/presentation/shared/SharedGifSearchControls";
28 import { SharedImageSearchControls } from "@/components/presentation/shared/SharedImageSearchControls";
29 import { Button } from "@/components/ui/button";
30 import {
31 Select,
32 SelectContent,
33 SelectItem,
34 SelectTrigger,
35 SelectValue,
36 } from "@/components/ui/select";
37 import { Separator } from "@/components/ui/separator";
38 import { getPresentationTitleSearchQuery } from "@/lib/presentation/title-search-query";
39 import {
40 usePresentationState,
41 type ImageEditorMode,
42 type PresentationStockImageProvider,
43 } from "@/states/presentation-state";
44
45 // Define tab options with icons (excluding embed since this is for presentation images)
46 const TAB_OPTIONS: {
47 value: ImageEditorMode;
48 label: string;
49 icon: React.ReactNode;
50 }[] = [
51 {
52 value: "generate",
53 label: "AI Generate",
54 icon: <ImageIcon className="size-4" />,
55 },
56 {
57 value: "your-images",
58 label: "Uploaded Images",
59 icon: <Images className="size-4" />,
60 },
61 {
62 value: "generated-images",
63 label: "Generated Images",
64 icon: <Sparkles className="size-4" />,
65 },
66 { value: "search", label: "Search", icon: <Search className="size-4" /> },
67 { value: "gif", label: "GIFs", icon: <Clapperboard className="size-4" /> },
68 ];
69
70 export function PresentationImageEditorPanel() {
71 const presentationImageEditorInitialMode = usePresentationState(
72 (s) => s.presentationImageEditorInitialMode,
73 );
74 const closePresentationImageEditor = usePresentationState(
75 (s) => s.closePresentationImageEditor,
76 );
77 const boundUpdateElement = usePresentationState((s) => s.boundUpdateElement);
78 const presentationImageEditorElement = usePresentationState(
79 (s) => s.presentationImageEditorElement,
80 );
81 const presentationImageEditorFrame = usePresentationState(
82 (s) => s.presentationImageEditorFrame,
83 );
84 const currentPresentationTitle = usePresentationState(
85 (s) => s.currentPresentationTitle,
86 );
87 const setPaletteDropTarget = usePresentationState(
88 (s) => s.setPaletteDropTarget,
89 );
90 const initialMode = presentationImageEditorInitialMode ?? "generate";
91 const initialSearchQuery = getPresentationTitleSearchQuery(
92 currentPresentationTitle,
93 );
94
95 const [modeState, setModeState] = useState<{
96 mode: ImageEditorMode;
97 sourceMode: ImageEditorMode;
98 }>(() => ({ mode: initialMode, sourceMode: initialMode }));
99 const currentMode =
100 modeState.sourceMode === initialMode ? modeState.mode : initialMode;
101 const editorElementKey = getImageEditorElementKey(
102 presentationImageEditorElement,
103 );
104 const [currentElementState, setCurrentElementState] = useState<{
105 element: Record<string, unknown> | null;
106 key: string;
107 }>(() => ({
108 element: presentationImageEditorElement,
109 key: editorElementKey,
110 }));
111 const currentElement =
112 currentElementState.key === editorElementKey
113 ? currentElementState.element
114 : presentationImageEditorElement;
115 const [isCropModalOpen, setIsCropModalOpen] = useState(false);
116 const fileInputRef = useRef<HTMLInputElement>(null);
117 const queryClient = useQueryClient();
118 const currentImageUrl =
119 typeof currentElement?.url === "string" ? currentElement.url : undefined;
120 const currentImageQuery =
121 typeof currentElement?.query === "string"
122 ? currentElement.query
123 : typeof currentElement?.prompt === "string"
124 ? currentElement.prompt
125 : "";
126 const currentCropSettings = getImageCropSettings(
127 currentElement?.cropSettings,
128 );
129 const previewFrame = getImagePreviewFrame(presentationImageEditorFrame);
130
131 // Upload file hook
132 const { uploadFile, isUploading, progress } = useUploadFile({
133 onUploadComplete: (file) => {
134 if (!file.ufsUrl) {
135 toast.error("Uploaded image URL was not returned");
136 return;
137 }
138
139 if (boundUpdateElement) {
140 setPaletteDropTarget(null);
141 boundUpdateElement({
142 url: file.ufsUrl,
143 query: "",
144 imageSource: "upload",
145 [PALETTE_DROP_MUTABLE_KEY]: false,
146 });
147 setCurrentElementState(({ element }) => ({
148 key: editorElementKey,
149 element: {
150 ...(element ?? {}),
151 url: file.ufsUrl,
152 query: "",
153 prompt: "",
154 imageSource: "upload",
155 },
156 }));
157 }
158 void queryClient.invalidateQueries({
159 queryKey: ["presentation-uploaded-images"],
160 });
161 },
162 onUploadError: (error) => {
163 toast.error("Failed to upload image");
164 console.error(error);
165 },
166 });
167
168 const handleUploadClick = () => {
169 fileInputRef.current?.click();
170 };
171
172 const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
173 const file = event.target.files?.[0];
174 if (file) {
175 void uploadFile(file);
176 }
177 // Reset input
178 if (fileInputRef.current) {
179 fileInputRef.current.value = "";
180 }
181 };
182
183 const handleImageSelect = (
184 url: string,
185 prompt?: string,
186 imageSource?: "generate" | "search" | "gif" | "upload",
187 stockImageProvider?: PresentationStockImageProvider,
188 ) => {
189 if (boundUpdateElement) {
190 setPaletteDropTarget(null);
191 boundUpdateElement({
192 url,
193 query: prompt ?? "",
194 imageSource,
195 ...(stockImageProvider ? { stockImageProvider } : {}),
196 [PALETTE_DROP_MUTABLE_KEY]: false,
197 });
198 setCurrentElementState(({ element }) => ({
199 key: editorElementKey,
200 element: {
201 ...(element ?? {}),
202 url,
203 query: prompt ?? "",
204 prompt: prompt ?? "",
205 imageSource,
206 ...(stockImageProvider ? { stockImageProvider } : {}),
207 },
208 }));
209 }
210 };
211
212 const handleCropSave = (settings: ImageCropSettings) => {
213 if (!boundUpdateElement) return;
214
215 setPaletteDropTarget(null);
216 boundUpdateElement({
217 cropSettings: settings,
218 [PALETTE_DROP_MUTABLE_KEY]: false,
219 });
220 setCurrentElementState(({ element }) => ({
221 key: editorElementKey,
222 element: {
223 ...(element ?? {}),
224 cropSettings: settings,
225 },
226 }));
227 setIsCropModalOpen(false);
228 };
229
230 const handleModeChange = (value: string) => {
231 setModeState({ mode: value as ImageEditorMode, sourceMode: initialMode });
232 };
233
234 // Get current tab info
235 const currentTab = TAB_OPTIONS.find((t) => t.value === currentMode);
236
237 if (!presentationImageEditorInitialMode) {
238 return null;
239 }
240
241 // Render content based on current mode
242 const renderCurrentImagePreview = () => {
243 if (!currentImageUrl) {
244 return null;
245 }
246
247 return (
248 <div className="space-y-3">
249 <div
250 className="relative mx-auto flex items-center justify-center overflow-hidden rounded-md border bg-muted shadow"
251 style={{
252 aspectRatio: `${previewFrame.width} / ${previewFrame.height}`,
253 maxWidth: previewFrame.width,
254 width: "100%",
255 }}
256 >
257 <Image
258 unoptimized
259 width={400}
260 height={300}
261 src={currentImageUrl}
262 alt={currentImageQuery}
263 className="size-full"
264 style={getPresentationImageCropStyles(currentCropSettings)}
265 />
266 </div>
267 <div className="flex justify-center">
268 <Button
269 variant="outline"
270 size="sm"
271 className="gap-2"
272 onClick={() => setIsCropModalOpen(true)}
273 >
274 <Scissors className="size-4" />
275 Crop
276 </Button>
277 </div>
278 </div>
279 );
280 };
281
282 const renderContent = () => {
283 switch (currentMode) {
284 case "generate":
285 return (
286 <div className="flex h-full flex-col">
287 <div className="flex-none space-y-1 px-6 py-4">
288 <h3 className="leading-none font-medium">Generate Image</h3>
289 <p className="text-sm text-muted-foreground">
290 Create unique images using AI.
291 </p>
292 </div>
293 <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 pb-6">
294 {renderCurrentImagePreview()}
295 <SharedGenerateControls
296 onImageSelect={(url, prompt) =>
297 handleImageSelect(url, prompt, "generate")
298 }
299 initialPrompt={currentImageQuery}
300 />
301 </div>
302 </div>
303 );
304 case "your-images":
305 return (
306 <div className="flex h-full flex-col">
307 <div className="flex flex-none items-start justify-between gap-3 px-6 py-4">
308 <div className="min-w-0 space-y-1">
309 <h3 className="leading-none font-medium">Uploaded Images</h3>
310 <p className="text-sm text-muted-foreground">
311 Select from your uploaded images.
312 </p>
313 </div>
314 <Button
315 variant="outline"
316 size="sm"
317 onClick={handleUploadClick}
318 disabled={isUploading}
319 className="shrink-0 gap-2"
320 >
321 {isUploading ? (
322 <>
323 <Loader2 className="size-4 animate-spin" />
324 {Math.trunc(progress)}%
325 </>
326 ) : (
327 <>
328 <Upload className="size-4" />
329 Upload
330 </>
331 )}
332 </Button>
333 </div>
334 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
335 <UploadedImagesGrid
336 onImageSelect={(image) =>
337 handleImageSelect(image.url, image.name, "upload")
338 }
339 />
340 </div>
341 </div>
342 );
343 case "generated-images":
344 return (
345 <div className="flex h-full flex-col">
346 <div className="flex-none space-y-1 px-6 py-4">
347 <h3 className="leading-none font-medium">Generated Images</h3>
348 <p className="text-sm text-muted-foreground">
349 Select from your AI-generated images.
350 </p>
351 </div>
352 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
353 <GeneratedImagesGrid
354 onImageSelect={(image) =>
355 handleImageSelect(image.url, image.prompt, "generate")
356 }
357 />
358 </div>
359 </div>
360 );
361 case "search":
362 return (
363 <div className="flex h-full flex-col">
364 <div className="flex-none space-y-1 px-6 py-4">
365 <h3 className="leading-none font-medium">Search Images</h3>
366 <p className="text-sm text-muted-foreground">
367 Find images from Unsplash, Pixabay, or live web results.
368 </p>
369 </div>
370 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
371 <SharedImageSearchControls
372 onImageSelect={(url, provider) =>
373 handleImageSelect(url, undefined, "search", provider)
374 }
375 initialQuery={initialSearchQuery}
376 initialQueryKey={currentPresentationTitle ?? undefined}
377 disableTrendingFallback={true}
378 className="h-full"
379 />
380 </div>
381 </div>
382 );
383 case "gif":
384 return (
385 <div className="flex h-full flex-col">
386 <div className="flex-none space-y-1 px-6 py-4">
387 <h3 className="leading-none font-medium">Search GIFs</h3>
388 <p className="text-sm text-muted-foreground">
389 Find animated GIFs from Giphy.
390 </p>
391 </div>
392 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
393 <SharedGifSearchControls
394 onGifSelect={(url) => handleImageSelect(url, undefined, "gif")}
395 className="h-full"
396 />
397 </div>
398 </div>
399 );
400 default:
401 return null;
402 }
403 };
404
405 return (
406 <div className="flex size-full flex-col border-l bg-background">
407 {/* Header */}
408 <div className="flex items-center justify-between border-b px-4 py-2">
409 <h2 className="text-sm font-semibold">Image Editor</h2>
410 <Button
411 variant="ghost"
412 size="icon"
413 onClick={closePresentationImageEditor}
414 className="size-8 rounded-full p-0"
415 >
416 <X className="size-5" />
417 </Button>
418 </div>
419
420 {/* Hidden file input */}
421 <input
422 aria-label="presentation image editor panel control"
423 ref={fileInputRef}
424 type="file"
425 accept="image/*"
426 className="hidden"
427 onChange={handleFileChange}
428 />
429
430 {/* Mode Selector Dropdown */}
431 <div className="px-6 py-3">
432 <Select value={currentMode} onValueChange={handleModeChange}>
433 <SelectTrigger className="h-11 w-full rounded-xl">
434 <SelectValue>
435 {currentTab && (
436 <div className="flex items-center gap-2">
437 {currentTab.icon}
438 <span>{currentTab.label}</span>
439 </div>
440 )}
441 </SelectValue>
442 </SelectTrigger>
443 <SelectContent className="rounded-xl">
444 {TAB_OPTIONS.map((tab) => (
445 <SelectItem
446 key={tab.value}
447 value={tab.value}
448 className="cursor-pointer rounded-lg"
449 >
450 <div className="flex items-center gap-2">
451 {tab.icon}
452 <span>{tab.label}</span>
453 </div>
454 </SelectItem>
455 ))}
456 </SelectContent>
457 </Select>
458 </div>
459
460 <Separator />
461
462 {/* Content Area */}
463 <div className="min-h-0 flex-1 overflow-hidden">{renderContent()}</div>
464
465 {currentImageUrl ? (
466 <CropModal
467 open={isCropModalOpen}
468 onOpenChange={setIsCropModalOpen}
469 imageUrl={currentImageUrl}
470 initialCropSettings={currentCropSettings}
471 onSave={handleCropSave}
472 imageDimensions={{
473 height: previewFrame.height,
474 scale: 1,
475 width: previewFrame.width,
476 }}
477 />
478 ) : null}
479 </div>
480 );
481 }
482
483 function getImagePreviewFrame(
484 value: { height: number; width: number } | null,
485 ): { height: number; width: number } {
486 if (
487 value &&
488 Number.isFinite(value.height) &&
489 Number.isFinite(value.width) &&
490 value.height > 0 &&
491 value.width > 0
492 ) {
493 return value;
494 }
495
496 return { height: 450, width: 800 };
497 }
498
499 function getImageEditorElementKey(
500 element: Record<string, unknown> | null,
501 ): string {
502 if (typeof element?.id === "string") {
503 return element.id;
504 }
505
506 return "";
507 }
508
509 function getImageCropSettings(value: unknown): ImageCropSettings {
510 if (
511 typeof value === "object" &&
512 value !== null &&
513 "objectPosition" in value
514 ) {
515 const cropSettings = value as Partial<ImageCropSettings>;
516 const objectPosition = cropSettings.objectPosition;
517
518 return {
519 objectFit: cropSettings.objectFit ?? "cover",
520 objectPosition: {
521 x: objectPosition?.x ?? 50,
522 y: objectPosition?.y ?? 50,
523 },
524 zoom: cropSettings.zoom ?? 1,
525 };
526 }
527
528 return {
529 objectFit: "cover",
530 objectPosition: { x: 50, y: 50 },
531 zoom: 1,
532 };
533 }
534
534 lines Plain Text