返回 presentation-ai
presentation-image-editor.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / presentation-image-editor.tsx
1 "use client";
2 import { Button } from "@/components/ui/button";
3 import { ScrollArea } from "@/components/ui/scroll-area";
4 import { Separator } from "@/components/ui/separator";
5 import {
6 Sheet,
7 SheetClose,
8 SheetContent,
9 SheetHeader,
10 SheetTitle,
11 } from "@/components/ui/sheet";
12 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
13 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
14 import { usePresentationState } from "@/states/presentation-state";
15 import { Globe, ImageIcon, Images, Search, X } from "lucide-react";
16 import { type TElement } from "platejs";
17 import { useEditorRef } from "platejs/react";
18 import { useEffect, useState } from "react";
19 import { type RootImage as RootImageType } from "../../utils/parser";
20 import { type ImageCropSettings } from "../../utils/types";
21 import { ErrorDisplay, GenerateControls } from "./image-editor";
22 import { CropModal } from "./image-editor/CropModal";
23 import { EmbedControls } from "./image-editor/EmbedControls";
24 import { ImageSearchControls } from "./image-editor/ImageSearchControls";
25 import { useImageDimensions } from "./image-editor/useImageDimensions";
26 import { YourImagesControls } from "./image-editor/YourImagesControls";
27
28 export interface PresentationImageEditorProps {
29 open: boolean;
30 onOpenChange: (open: boolean) => void;
31 element: TElement & RootImageType;
32 layoutType: string;
33 slideId?: string;
34 isRootImage?: boolean;
35 initialMode?: EditorMode;
36 }
37
38 export type EditorMode = "generate" | "your-images" | "embed" | "search";
39
40 export const PresentationImageEditor = ({
41 open,
42 onOpenChange,
43 element,
44 layoutType,
45 slideId,
46 isRootImage = false,
47 initialMode,
48 }: PresentationImageEditorProps) => {
49 const editor = useEditorRef();
50 const { saveImmediately } = useDebouncedSave();
51 // Initialize mode based on element state
52 const [currentMode, setCurrentMode] = useState<EditorMode>(
53 initialMode ?? (element.embedType ? "embed" : "generate"),
54 );
55
56 // Keep mode in sync when opening with an explicit initialMode
57 useEffect(() => {
58 if (open && initialMode) {
59 setCurrentMode(initialMode);
60 }
61 }, [open, initialMode]);
62
63 const setSlides = usePresentationState((s) => s.setSlides);
64 const slides = usePresentationState((s) => s.slides);
65
66 const [isCropModalOpen, setIsCropModalOpen] = useState(false);
67
68 // Use the custom hook for dimension calculations
69 const imageDimensions = useImageDimensions({
70 element,
71 slideId,
72 layoutType,
73 });
74
75 const handleEmbedChange = (embedType: string, url: string) => {
76 if (isRootImage) {
77 setSlides(
78 slides.map((slide) =>
79 slide.id === slideId
80 ? {
81 ...slide,
82 rootImage: {
83 ...slide.rootImage!,
84 embedType,
85 url,
86 },
87 }
88 : slide,
89 ),
90 );
91 void saveImmediately();
92 } else {
93 editor.tf.setNodes({
94 ...element,
95 embedType,
96 url,
97 });
98 void saveImmediately();
99 }
100 };
101
102 const handleClearEmbed = () => {
103 if (isRootImage) {
104 setSlides(
105 slides.map((slide) =>
106 slide.id === slideId
107 ? {
108 ...slide,
109 rootImage: {
110 ...slide.rootImage!,
111 embedType: undefined,
112 url: undefined,
113 },
114 }
115 : slide,
116 ),
117 );
118 void saveImmediately();
119 } else {
120 editor.tf.setNodes({
121 ...element,
122 embedType: undefined,
123 url: undefined,
124 });
125 void saveImmediately();
126 }
127 setCurrentMode("generate");
128 };
129
130 const handleCropSave = (settings: ImageCropSettings) => {
131 if (isRootImage) {
132 setSlides(
133 slides.map((slide) =>
134 slide.id === slideId
135 ? {
136 ...slide,
137 rootImage: {
138 ...slide.rootImage!,
139 cropSettings: settings,
140 },
141 }
142 : slide,
143 ),
144 );
145 void saveImmediately();
146 } else {
147 editor.tf.setNodes({
148 ...element,
149 cropSettings: settings,
150 });
151 void saveImmediately();
152 }
153 setIsCropModalOpen(false);
154 };
155
156 const handleTabChange = (value: string) => {
157 const newMode = value as EditorMode;
158 setCurrentMode(newMode);
159 };
160
161 return (
162 <Sheet open={open} onOpenChange={onOpenChange} modal={false}>
163 <SheetContent
164 onInteractOutside={(e) => e.preventDefault()}
165 overlay={false}
166 className="max-w-auto! static! top-0 bottom-0 grid max-h-full w-96! max-w-md grid-rows-[auto_1fr] gap-0 overflow-hidden p-0 px-0 pb-4"
167 container={
168 typeof window === "undefined"
169 ? undefined
170 : typeof document !== "undefined"
171 ? (document.querySelector<HTMLElement>(
172 ".presentation-sheets-portal",
173 ) ?? undefined)
174 : undefined
175 }
176 >
177 {/* Header */}
178 <SheetHeader className="flex flex-row items-center justify-between space-y-0 border-b px-4 py-2">
179 <SheetTitle>Image Studio</SheetTitle>
180
181 <SheetClose asChild>
182 <Button variant="ghost" className="size-8! rounded-full p-0">
183 <X className="size-5!" />
184 </Button>
185 </SheetClose>
186 </SheetHeader>
187
188 {/* Main Content */}
189 <Tabs
190 value={currentMode}
191 onValueChange={handleTabChange}
192 className="grid min-h-0 grid-rows-[auto_auto_1fr] bg-background"
193 >
194 <div className="px-6 py-2">
195 <TabsList className="grid w-full grid-cols-4">
196 <TabsTrigger value="generate" className="text-xs">
197 <ImageIcon className="mr-2 h-3.5 w-3.5" />
198 Gen
199 </TabsTrigger>
200 <TabsTrigger value="your-images" className="text-xs">
201 <Images className="mr-2 h-3.5 w-3.5" />
202 Yours
203 </TabsTrigger>
204 <TabsTrigger value="search" className="text-xs">
205 <Search className="mr-2 h-3.5 w-3.5" />
206 Search
207 </TabsTrigger>
208 <TabsTrigger value="embed" className="text-xs">
209 <Globe className="mr-2 h-3.5 w-3.5" />
210 Embed
211 </TabsTrigger>
212 </TabsList>
213 </div>
214
215 <Separator />
216
217 <div className="min-h-0 overflow-hidden">
218 <ErrorDisplay error={undefined} localError={null} />
219
220 <TabsContent
221 value="generate"
222 className="mt-0 flex h-full flex-col data-[state=inactive]:hidden"
223 >
224 <div className="flex-none space-y-1 px-6 py-4">
225 <h3 className="leading-none font-medium">Generate Image</h3>
226 <p className="text-sm text-muted-foreground">
227 Create unique images using AI.
228 </p>
229 </div>
230 <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
231 <GenerateControls
232 element={element}
233 slideId={slideId}
234 isRootImage={isRootImage}
235 onOpenCrop={() => setIsCropModalOpen(true)}
236 imageDimensions={imageDimensions}
237 />
238 </div>
239 </TabsContent>
240
241 <TabsContent
242 value="your-images"
243 className="mt-0 flex h-full flex-col data-[state=inactive]:hidden"
244 >
245 <div className="flex-none space-y-1 px-6 py-4">
246 <h3 className="leading-none font-medium">Your Images</h3>
247 <p className="text-sm text-muted-foreground">
248 Select from your generated images.
249 </p>
250 </div>
251 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
252 <YourImagesControls
253 element={element}
254 slideId={slideId}
255 isRootImage={isRootImage}
256 />
257 </div>
258 </TabsContent>
259
260 <TabsContent
261 value="search"
262 className="mt-0 flex h-full flex-col data-[state=inactive]:hidden"
263 >
264 <div className="flex-none space-y-1 px-6 py-4">
265 <h3 className="leading-none font-medium">Search Images</h3>
266 <p className="text-sm text-muted-foreground">
267 Find images from Unsplash or Pixabay.
268 </p>
269 </div>
270 <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6">
271 <ImageSearchControls
272 element={element}
273 slideId={slideId}
274 isRootImage={isRootImage}
275 />
276 </div>
277 </TabsContent>
278
279 <TabsContent
280 value="embed"
281 className="mt-0 h-full data-[state=inactive]:hidden"
282 >
283 <ScrollArea className="h-full">
284 <div className="space-y-6 p-6">
285 <div className="space-y-1">
286 <h3 className="leading-none font-medium">Embed Content</h3>
287 <p className="text-sm text-muted-foreground">
288 Embed videos, tweets, or other content.
289 </p>
290 </div>
291 <EmbedControls
292 embedType={element.embedType}
293 embedUrl={element.url}
294 onEmbedChange={handleEmbedChange}
295 onClearEmbed={handleClearEmbed}
296 imageDimensions={imageDimensions}
297 />
298 </div>
299 </ScrollArea>
300 </TabsContent>
301 </div>
302 </Tabs>
303
304 {element.url && (
305 <CropModal
306 open={isCropModalOpen}
307 onOpenChange={setIsCropModalOpen}
308 imageUrl={element.url}
309 initialCropSettings={{
310 objectFit: element.cropSettings?.objectFit ?? "cover",
311 objectPosition: {
312 x: element.cropSettings?.objectPosition.x ?? 50,
313 y: element.cropSettings?.objectPosition.y ?? 50,
314 },
315 zoom: element.cropSettings?.zoom ?? 1,
316 }}
317 onSave={handleCropSave}
318 imageDimensions={imageDimensions}
319 />
320 )}
321 </SheetContent>
322 </Sheet>
323 );
324 };
325
325 lines Plain Text