返回 presentation-ai
ImageSlide.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK, type ElementDragItemNode } from "@platejs/dnd";
4 import {
5 Copy,
6 Download,
7 Edit,
8 ExternalLink,
9 FileText,
10 Link2,
11 Maximize2,
12 Trash2,
13 } from "lucide-react";
14 import Image from "next/image";
15 import { useDrop } from "react-dnd";
16 import { toast } from "sonner";
17
18 import {
19 ContextMenu,
20 ContextMenuContent,
21 ContextMenuItem,
22 ContextMenuSeparator,
23 ContextMenuTrigger,
24 } from "@/components/ui/context-menu";
25 import { Spinner } from "@/components/ui/spinner";
26 import { cn } from "@/lib/utils";
27 import {
28 usePresentationState,
29 type ImageEditorMode,
30 } from "@/states/presentation-state";
31 import { type RootImage } from "../../../utils/parser";
32
33 interface ImageSlideProps {
34 image: RootImage;
35 slideId: string;
36 }
37
38 export default function ImageSlide({ image, slideId }: ImageSlideProps) {
39 const slides = usePresentationState((s) => s.slides);
40 const setSlides = usePresentationState((s) => s.setSlides);
41 const setCurrentSlide = usePresentationState((s) => s.setCurrentSlideId);
42 const openImageEditor = usePresentationState((s) => s.openImageEditor);
43 const stockImageProvider = usePresentationState((s) => s.stockImageProvider);
44 const setImageSearchState = usePresentationState(
45 (s) => s.setImageSearchState,
46 );
47 const rootImageGeneration = usePresentationState(
48 (s) => s.rootImageGeneration,
49 );
50
51 const rawComputedGen = rootImageGeneration[slideId];
52 const imageQuery = image.query.trim();
53 const computedGen =
54 rawComputedGen &&
55 (!imageQuery || rawComputedGen.query.trim() === imageQuery)
56 ? rawComputedGen
57 : undefined;
58 const computedImageUrl = computedGen?.url ?? image.url;
59 const isGenerating =
60 image.isQueryStreaming ||
61 computedGen?.status === "queued" ||
62 computedGen?.status === "generating";
63
64 const handleAction = (action: string) => {
65 switch (action) {
66 case "copy":
67 if (computedImageUrl) {
68 fetch(computedImageUrl)
69 .then((response) => response.blob())
70 .then((blob) => {
71 const item = new ClipboardItem({ [blob.type]: blob });
72 navigator.clipboard.write([item]);
73 toast("Image copied to clipboard");
74 })
75 .catch((err) => {
76 console.error("Failed to copy image:", err);
77 toast("Failed to copy image");
78 });
79 }
80 break;
81 case "copyAddress":
82 if (computedImageUrl) {
83 navigator.clipboard.writeText(computedImageUrl);
84 toast("Image address copied to clipboard");
85 }
86 break;
87 case "openNewTab":
88 if (computedImageUrl) {
89 window.open(computedImageUrl, "_blank");
90 }
91 break;
92 case "download":
93 if (computedImageUrl) {
94 const link = document.createElement("a");
95 link.href = computedImageUrl;
96 link.download = "downloaded-image";
97 link.click();
98 }
99 break;
100 case "replace":
101 setCurrentSlide(slideId);
102 const mode: ImageEditorMode =
103 image.imageSource === "search"
104 ? "search"
105 : image.imageSource === "gif"
106 ? "gif"
107 : "generate";
108 if (mode === "search") {
109 setImageSearchState({
110 mode: image.stockImageProvider ?? stockImageProvider,
111 });
112 }
113 openImageEditor(mode);
114 break;
115 case "fit":
116 updateCropSettings({
117 ...image.cropSettings,
118 objectFit:
119 image.cropSettings?.objectFit === "contain" ? "cover" : "contain",
120 objectPosition: image.cropSettings?.objectPosition ?? {
121 x: 50,
122 y: 50,
123 },
124 });
125 break;
126 case "convertToSlide":
127 // Convert this image slide back to a regular slide
128 const updatedSlides = slides.map((slide) => {
129 if (slide.id === slideId) {
130 return {
131 ...slide,
132 isImageSlide: false,
133 layoutType: "left" as const, // Set a default layout
134 content:
135 slide.content.length > 0
136 ? slide.content
137 : [{ type: "h1", children: [{ text: "" }] }],
138 };
139 }
140 return slide;
141 });
142 setSlides(updatedSlides);
143 toast("Converted to slide");
144 break;
145 case "removeSlide":
146 // Remove this slide entirely
147 const filteredSlides = slides.filter((slide) => slide.id !== slideId);
148 setSlides(filteredSlides);
149 toast("Slide removed");
150 break;
151 default:
152 console.log(`Action: ${action}`);
153 }
154 };
155
156 const updateCropSettings = (newCropSettings: typeof image.cropSettings) => {
157 const updatedSlides = slides.map((slide) => {
158 if (slide.id === slideId && slide.rootImage) {
159 return {
160 ...slide,
161 rootImage: {
162 ...slide.rootImage,
163 cropSettings: newCropSettings,
164 },
165 };
166 }
167 return slide;
168 });
169 setSlides(updatedSlides);
170 };
171
172 // Drop zone for accepting draggable elements
173 const [{ isOver, canDrop }, dropRef] = useDrop<
174 ElementDragItemNode,
175 void,
176 { isOver: boolean; canDrop: boolean }
177 >({
178 accept: DRAG_ITEM_BLOCK,
179 collect: (monitor) => ({
180 isOver: monitor.isOver(),
181 canDrop: monitor.canDrop(),
182 }),
183 drop: (dragItem) => {
184 // Get the dropped element from dragItem
185 const droppedElement = dragItem.element;
186 if (!droppedElement) {
187 toast.error("Could not get dropped element");
188 return;
189 }
190
191 // Convert image slide to normal slide with image as background
192 // and add the dropped element to the content
193 const updatedSlides = slides.map((slide) => {
194 if (slide.id === slideId) {
195 // Create new content array with the dropped element
196 const newContent = Array.isArray(droppedElement)
197 ? [...droppedElement]
198 : [droppedElement];
199
200 return {
201 ...slide,
202 isImageSlide: false,
203 layoutType: "background" as const,
204 // Keep the rootImage so it becomes the background
205 rootImage: slide.rootImage,
206 // Add the dropped element to content
207 content: newContent as typeof slide.content,
208 };
209 }
210 return slide;
211 });
212 setSlides(updatedSlides);
213 toast.success("Element added to slide");
214 },
215 });
216
217 const isDropActive = isOver && canDrop;
218
219 return (
220 <div
221 ref={dropRef as unknown as React.Ref<HTMLDivElement>}
222 className="relative aspect-video w-full"
223 >
224 <ContextMenu>
225 <ContextMenuTrigger asChild>
226 <div
227 className={cn(
228 "flex size-full cursor-pointer items-center justify-center",
229 "relative overflow-hidden",
230 )}
231 onDoubleClick={() => {
232 setCurrentSlide(slideId);
233 const mode: ImageEditorMode =
234 image.imageSource === "search"
235 ? "search"
236 : image.imageSource === "gif"
237 ? "gif"
238 : "generate";
239 if (mode === "search") {
240 setImageSearchState({
241 mode: image.stockImageProvider ?? stockImageProvider,
242 });
243 }
244 openImageEditor(mode);
245 }}
246 >
247 {isGenerating ? (
248 <div className="absolute inset-0 z-10 flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4 text-center">
249 <Spinner className="size-8" />
250 <div className="space-y-1">
251 <p className="text-sm font-medium text-foreground">
252 Generating image
253 </p>
254 <p className="text-xs text-muted-foreground">
255 This can take a moment.
256 </p>
257 </div>
258 </div>
259 ) : computedImageUrl ? (
260 <Image
261 unoptimized
262 width={400}
263 height={300}
264 src={computedImageUrl}
265 alt={image.query}
266 className="size-full"
267 style={{
268 objectFit: image.cropSettings?.objectFit ?? "cover",
269 objectPosition: image.cropSettings?.objectPosition
270 ? `${image.cropSettings.objectPosition.x}% ${image.cropSettings.objectPosition.y}%`
271 : "center",
272 }}
273 />
274 ) : (
275 <div className="flex items-center justify-center text-muted-foreground">
276 <span>
277 {computedGen?.status === "error"
278 ? "Image not found"
279 : "No image"}
280 </span>
281 </div>
282 )}
283 {/* Drop indicator overlay */}
284 {isDropActive && (
285 <div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-dashed border-primary bg-primary/20">
286 <div className="rounded-md bg-background/90 px-4 py-2 shadow-lg">
287 <span className="text-sm font-medium text-primary">
288 Drop to add element
289 </span>
290 </div>
291 </div>
292 )}
293 </div>
294 </ContextMenuTrigger>
295 <ContextMenuContent className="w-64">
296 <ContextMenuItem onClick={() => handleAction("copy")}>
297 <Copy className="mr-2 size-4" />
298 Copy
299 </ContextMenuItem>
300 <ContextMenuItem onClick={() => handleAction("copyAddress")}>
301 <Link2 className="mr-2 size-4" />
302 Copy image address
303 </ContextMenuItem>
304 <ContextMenuItem onClick={() => handleAction("openNewTab")}>
305 <ExternalLink className="mr-2 size-4" />
306 Open image in new tab
307 </ContextMenuItem>
308 <ContextMenuItem onClick={() => handleAction("download")}>
309 <Download className="mr-2 size-4" />
310 Download image
311 </ContextMenuItem>
312 <ContextMenuSeparator />
313 <ContextMenuItem onClick={() => handleAction("replace")}>
314 <Edit className="mr-2 size-4" />
315 Replace image…
316 </ContextMenuItem>
317 <ContextMenuItem onClick={() => handleAction("fit")}>
318 <Maximize2 className="mr-2 size-4" />
319 {image.cropSettings?.objectFit === "contain"
320 ? "Cover Image"
321 : "Fit Image"}
322 </ContextMenuItem>
323 <ContextMenuSeparator />
324 <ContextMenuItem onClick={() => handleAction("convertToSlide")}>
325 <FileText className="mr-2 size-4" />
326 Convert to slide
327 </ContextMenuItem>
328 <ContextMenuSeparator />
329 <ContextMenuItem
330 onClick={() => handleAction("removeSlide")}
331 className="text-red-500 focus:bg-red-50 focus:text-red-500"
332 >
333 <Trash2 className="mr-2 size-4" />
334 Remove slide
335 </ContextMenuItem>
336 </ContextMenuContent>
337 </ContextMenu>
338 </div>
339 );
340 }
341
341 lines Plain Text