返回 presentation-ai
root-image.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK } from "@platejs/dnd";
4 import {
5 BarChart3,
6 Copy,
7 Download,
8 Edit,
9 ExternalLink,
10 ImageIcon,
11 ImageOff,
12 Layout,
13 LayoutPanelLeft,
14 Link,
15 Link2,
16 Maximize2,
17 Scissors,
18 Trash2,
19 } from "lucide-react";
20 import { nanoid } from "nanoid";
21 import { KEYS, type TElement } from "platejs";
22 import { useEditorReadOnly } from "platejs/react";
23 import { useMemo, useState } from "react";
24 import { useDrop } from "react-dnd";
25 import { toast } from "sonner";
26
27 import { MediaEmbedPlaceholder } from "@/components/plate/ui/media-embed-placeholder";
28 import { Button } from "@/components/ui/button";
29 import {
30 ContextMenu,
31 ContextMenuContent,
32 ContextMenuItem,
33 ContextMenuSeparator,
34 ContextMenuSub,
35 ContextMenuSubContent,
36 ContextMenuSubTrigger,
37 ContextMenuTrigger,
38 } from "@/components/ui/context-menu";
39 import {
40 Popover,
41 PopoverContent,
42 PopoverTrigger,
43 } from "@/components/ui/popover";
44 import { Resizable } from "@/components/ui/resizable";
45 import { Spinner } from "@/components/ui/spinner";
46 import {
47 BASE_HEIGHT,
48 BASE_WIDTH_PERCENTAGE,
49 MAX_HEIGHT,
50 MAX_WIDTH_PERCENTAGE,
51 MIN_HEIGHT,
52 MIN_WIDTH_PERCENTAGE,
53 useRootImageActions,
54 } from "@/hooks/presentation/useRootImageActions";
55 import { useSlideOperations } from "@/hooks/presentation/useSlideOperations";
56 import { cn } from "@/lib/utils";
57 import {
58 usePresentationState,
59 type ImageEditorMode,
60 } from "@/states/presentation-state";
61 import { type RootImage as RootImageType } from "../../utils/parser";
62 import { type ImageCropSettings } from "../../utils/types";
63 import { isChartType } from "../lib";
64 import {
65 getPaletteDragItemKey,
66 getPaletteDragSource,
67 getPaletteMutableSignature,
68 } from "../utils/paletteDrop";
69 import { ChartRenderer } from "./charts/ChartRenderer";
70 import { EmbedRenderer } from "./embeds/EmbedRenderer";
71 import { CropModal } from "./image-editor/CropModal";
72 import { useImageDimensions } from "./image-editor/useImageDimensions";
73 import ImagePlaceholder from "./image-placeholder";
74 import { InfographicEmbedPlaceholder } from "./infographic-embed-placeholder";
75
76 export interface RootImageProps {
77 image: RootImageType;
78 layoutType?: string;
79 slideId: string;
80 heightValue?: string | number;
81 maxHeightPx?: number;
82 heightPx?: number;
83 }
84
85 export default function RootImage({
86 image,
87 layoutType,
88 slideId,
89 heightValue,
90 maxHeightPx,
91 heightPx,
92 }: RootImageProps) {
93 const isSideLayout = layoutType === "left" || layoutType === "right";
94 const resolvedLayoutType = layoutType ?? image.layoutType ?? "vertical";
95 // State for showing delete popover
96 const [showDeletePopover, setShowDeletePopover] = useState(false);
97 const [isCropModalOpen, setIsCropModalOpen] = useState(false);
98 const slides = usePresentationState((s) => s.slides);
99 const updateSlide = usePresentationState((s) => s.updateSlide);
100 const setPaletteDropTarget = usePresentationState(
101 (s) => s.setPaletteDropTarget,
102 );
103 const setCurrentSlide = usePresentationState((s) => s.setCurrentSlideId);
104 const openImageEditor = usePresentationState((s) => s.openImageEditor);
105 const openInfographicGenerationEditor = usePresentationState(
106 (s) => s.openInfographicGenerationEditor,
107 );
108 const stockImageProvider = usePresentationState((s) => s.stockImageProvider);
109 const setImageSearchState = usePresentationState(
110 (s) => s.setImageSearchState,
111 );
112 // Check if editor is in read-only mode
113 const readOnly = useEditorReadOnly();
114
115 const {
116 computedGen,
117 computedImageUrl,
118 imageStyles,
119 sizeStyle,
120 isDragging,
121 handleRef,
122 removeRootImageFromSlide,
123 onResize,
124 onResizeStop,
125 updateCropSettings,
126 dragId,
127 } = useRootImageActions(slideId, { image, layoutType, maxHeightPx });
128 const imageDimensions = useImageDimensions({
129 element: image,
130 slideId,
131 layoutType: resolvedLayoutType,
132 });
133 const isRootImageGenerating =
134 image.isQueryStreaming ||
135 computedGen?.status === "queued" ||
136 computedGen?.status === "generating";
137 const shouldShowGenerationPlaceholder =
138 isRootImageGenerating &&
139 !computedImageUrl &&
140 !image.embedType &&
141 !image.chartType;
142
143 const appliedSizeStyle: React.CSSProperties = useMemo(() => {
144 if (typeof heightPx === "number" && heightPx > 0) {
145 return {
146 ...sizeStyle,
147 height: heightPx,
148 };
149 }
150
151 if (typeof maxHeightPx === "number" && maxHeightPx > 0) {
152 return {
153 ...sizeStyle,
154 maxHeight: `${maxHeightPx}px`,
155 };
156 }
157 return sizeStyle;
158 }, [heightPx, maxHeightPx, sizeStyle]);
159
160 const resolvedMaxHeight =
161 typeof maxHeightPx === "number" && maxHeightPx > 0
162 ? `${maxHeightPx}px`
163 : undefined;
164 const resolvedMaxHeightNumber =
165 typeof maxHeightPx === "number" && maxHeightPx > 0
166 ? maxHeightPx
167 : undefined;
168 const verticalMinHeight =
169 resolvedMaxHeightNumber === undefined
170 ? MIN_HEIGHT
171 : Math.min(MIN_HEIGHT, resolvedMaxHeightNumber);
172
173 // Ensure popover closes when delete action is invoked
174 const handleDeleteClick = (e: React.MouseEvent) => {
175 e.stopPropagation();
176 removeRootImageFromSlide();
177 setShowDeletePopover(false);
178 };
179
180 // Double-click handler for the image
181 const handleImageDoubleClick = (e: React.MouseEvent) => {
182 e.stopPropagation();
183 if (!readOnly) {
184 setCurrentSlide(slideId);
185 const openChartEditor = usePresentationState.getState().openChartEditor;
186 if (image.chartType) {
187 openChartEditor();
188 } else if (image.embedType === "infographic") {
189 openInfographicGenerationEditor();
190 } else if (image.embedType) {
191 openImageEditor("embed");
192 } else {
193 const mode: ImageEditorMode =
194 image.imageSource === "search"
195 ? "search"
196 : image.imageSource === "gif"
197 ? "gif"
198 : "generate";
199 if (mode === "search") {
200 setImageSearchState({
201 mode: image.stockImageProvider ?? stockImageProvider,
202 });
203 }
204 openImageEditor(mode);
205 }
206 }
207 };
208
209 const removeImage = () => {
210 const { clearRootImageGeneration } = usePresentationState.getState();
211
212 updateSlide(slideId, {
213 rootImage: {
214 ...image, // Preserve generic image properties if needed, or better, fetch fresh from slide if image prop is stale
215 // Actually best to just spread current image and update fields
216 url: undefined,
217 embedType: undefined,
218 imageSource: undefined,
219 chartType: undefined,
220 chartData: undefined,
221 chartOptions: undefined,
222 } as RootImageType,
223 });
224 if (slideId) {
225 clearRootImageGeneration(slideId);
226 }
227 };
228
229 const { addSlide } = useSlideOperations();
230
231 const handleAction = (action: string) => {
232 switch (action) {
233 case "copy":
234 if (computedImageUrl) {
235 fetch(computedImageUrl)
236 .then((response) => response.blob())
237 .then((blob) => {
238 const item = new ClipboardItem({ [blob.type]: blob });
239 navigator.clipboard.write([item]);
240 toast("Image copied to clipboard");
241 })
242 .catch((err) => {
243 console.error("Failed to copy image:", err);
244 toast("Failed to copy image");
245 });
246 }
247 break;
248 case "copyAddress":
249 if (computedImageUrl) {
250 navigator.clipboard.writeText(computedImageUrl);
251 toast("Image address copied to clipboard");
252 }
253 break;
254 case "openNewTab":
255 if (computedImageUrl) {
256 window.open(computedImageUrl, "_blank");
257 }
258 break;
259 case "download":
260 if (computedImageUrl) {
261 const link = document.createElement("a");
262 link.href = computedImageUrl;
263 link.download = "downloaded-image";
264 link.click();
265 }
266 break;
267 case "replace":
268 setCurrentSlide(slideId);
269 const mode: ImageEditorMode =
270 image.imageSource === "search"
271 ? "search"
272 : image.imageSource === "gif"
273 ? "gif"
274 : "generate";
275 if (mode === "search") {
276 setImageSearchState({
277 mode: image.stockImageProvider ?? stockImageProvider,
278 });
279 }
280 openImageEditor(mode);
281 break;
282 case "fit":
283 updateCropSettings({
284 ...image.cropSettings,
285 objectFit:
286 image.cropSettings?.objectFit === "contain" ? "cover" : "contain",
287 objectPosition: image.cropSettings?.objectPosition ?? {
288 x: 50,
289 y: 50,
290 },
291 });
292 break;
293 case "crop":
294 if (computedImageUrl) {
295 setIsCropModalOpen(true);
296 }
297 break;
298 case "card":
299 // Create a new slide with the same image
300 addSlide("after", slideId, {
301 id: nanoid(),
302 content: [], // Empty content for image slide
303 rootImage: {
304 ...image,
305 layoutType: "none", // Reset layout for the new slide
306 },
307 layoutType: "none",
308 isImageSlide: true,
309 alignment: "center",
310 });
311 toast("Created new image card");
312 break;
313 case "background":
314 updateSlideLayout("background");
315 break;
316 case "removeImage":
317 removeImage();
318 break;
319 case "removeLayout":
320 // Remove layout unconditionally - no URL matching needed
321 const { clearRootImageGeneration } = usePresentationState.getState();
322
323 // We need to fetch the current slide to safely omit rootImage
324 // Since updateSlide does a shallow merge, we can't easily "delete" a key with it if the key is optional
325 // However, rootImage is optional on PlateSlide.
326 // updateSlide(slideId, { rootImage: undefined }); // This should work if updateSlide handles undefined correctly
327
328 // Let's check usePresentationState for updateSlide implementation:
329 // updateSlide: (slideId, updates, type) => { set((state) => ({ slides: state.slides.map((slide) => slide.id === slideId ? { ...slide, ...updates } : slide) })); ... }
330 // Yes, standard spread. passing rootImage: undefined should work if the type allows it.
331 // But to be safe and cleaner let's stick to existing pattern but optimize it slightly or just use updateSlide.
332 // The previous code did: const { rootImage: _rootImage, ...rest } = slide...
333
334 const currentSlide = slides.find((s) => s.id === slideId);
335 if (currentSlide) {
336 // We can't use updateSlide to *remove* a key effectively if we want to change the object structure unless we pass the whole object sans key
337 // But here we can just set it to undefined if the type allows optional.
338 // PlateSlide has `rootImage?: RootImage`.
339 updateSlide(slideId, { rootImage: undefined });
340 }
341
342 clearRootImageGeneration(slideId);
343 break;
344 default:
345 console.log(`Action: ${action}`);
346 }
347 };
348
349 const updateSlideLayout = (
350 newLayout: "vertical" | "left" | "right" | "background",
351 ) => {
352 const nextSize =
353 newLayout === "vertical"
354 ? {
355 ...image.size,
356 h: image.size?.h ?? BASE_HEIGHT,
357 }
358 : newLayout === "left" || newLayout === "right"
359 ? {
360 ...image.size,
361 w: image.size?.w ?? BASE_WIDTH_PERCENTAGE,
362 }
363 : image.size;
364
365 updateSlide(slideId, {
366 layoutType: newLayout,
367 rootImage: {
368 ...image,
369 size: nextSize,
370 } as RootImageType,
371 });
372 };
373
374 // Drop handler for charts, images, and embeds - works on entire root image area
375 const [
376 { isOver: isChartOver, canDrop: canDropChart, isImageDrop, isEmbedDrop },
377 dropRef,
378 ] = useDrop<
379 {
380 element?: TElement;
381 itemKey?: string;
382 sourcePanel?: "elements" | "charts";
383 },
384 { droppedInLayoutZone: boolean },
385 {
386 isOver: boolean;
387 canDrop: boolean;
388 isImageDrop: boolean;
389 isEmbedDrop: boolean;
390 }
391 >(
392 () => ({
393 accept: DRAG_ITEM_BLOCK,
394 canDrop: (item: { element?: TElement }) => {
395 // Accept chart, image, or media embed elements when not in read-only mode
396 if (!item.element || readOnly) return false;
397
398 const isChart = isChartType(item.element.type);
399 const isImage = item.element.type === "img";
400 const isEmbed = item.element.type === KEYS.mediaEmbed;
401 return isChart || isImage || isEmbed;
402 },
403 drop: (item: {
404 element?: TElement;
405 itemKey?: string;
406 sourcePanel?: "elements" | "charts";
407 }) => {
408 if (!item.element || readOnly) return;
409
410 // Self-drops are valid no-ops so releasing over the original slot
411 // does not fall through to the editor-level drag-end behavior.
412 if (item.element.id === dragId) return { droppedInLayoutZone: true };
413
414 const isChart = isChartType(item.element.type);
415 const isImage = item.element.type === "img";
416 const isEmbed = item.element.type === KEYS.mediaEmbed;
417
418 if (isChart) {
419 // Handle chart drop
420 const chartType = item.element.type;
421 const chartData = (item.element as unknown as { data?: unknown })
422 .data;
423 const chartOptions = {
424 variant: (item.element as unknown as { variant?: string }).variant,
425 disableAnimation: true,
426 };
427
428 // Update the slide's rootImage with the chart data
429 // Update the slide's rootImage with the chart data
430 const nextRootImage = {
431 ...image,
432 chartType,
433 chartData,
434 chartOptions,
435 paletteDropMutable: true,
436 // Clear any existing image/embed data
437 url: undefined,
438 embedType: undefined,
439 imageSource: undefined,
440 } as RootImageType;
441
442 updateSlide(slideId, {
443 rootImage: nextRootImage,
444 });
445
446 if (getPaletteDragSource(item) === "charts") {
447 setPaletteDropTarget({
448 editorId: slideId,
449 elementId: slideId,
450 itemKey: getPaletteDragItemKey(item),
451 source: "charts",
452 targetKind: "rootImage",
453 mutableSignature: getPaletteMutableSignature(nextRootImage),
454 });
455 }
456
457 toast.success("Chart added to slide");
458 } else if (isImage) {
459 // Handle image drop - preserve chart data but show image
460 const imageUrl = (item.element as unknown as { url?: string }).url;
461 const imageQuery = (item.element as unknown as { query?: string })
462 .query;
463
464 // Update the slide's rootImage with the image data
465 // Keep chart data preserved but hidden since URL takes precedence
466 // Update the slide's rootImage with the image data
467 // Keep chart data preserved but hidden since URL takes precedence
468 updateSlide(slideId, {
469 rootImage: {
470 ...image,
471 url: imageUrl,
472 query: imageQuery ?? image.query ?? "", // Use existing query if new one is null
473 embedType: undefined,
474 imageSource: undefined,
475 } as RootImageType,
476 });
477
478 toast.success("Image added to slide");
479 } else if (isEmbed) {
480 // Handle media embed drop
481 const embedType = (item.element as unknown as { provider?: string })
482 .provider;
483
484 // Update the slide's rootImage with the embed type
485 // Update the slide's rootImage with the embed type
486 updateSlide(slideId, {
487 rootImage: {
488 ...image,
489 query: image.query ?? "",
490 embedType: embedType,
491 url: undefined, // Clear URL so user needs to enter one
492 // Clear chart data
493 chartType: undefined,
494 chartData: undefined,
495 chartOptions: undefined,
496 imageSource: undefined,
497 } as RootImageType,
498 });
499 if (embedType === "infographic") {
500 setCurrentSlide(slideId);
501 openInfographicGenerationEditor();
502 toast.success("Infographic embed ready");
503 } else {
504 toast.success("Embed type set - enter a URL to display");
505 }
506 }
507
508 return { droppedInLayoutZone: true };
509 },
510 collect: (monitor) => ({
511 isOver: monitor.isOver(),
512 canDrop: monitor.canDrop(),
513 isImageDrop: monitor.getItem()?.element?.type === "img",
514 isEmbedDrop: monitor.getItem()?.element?.type === KEYS.mediaEmbed,
515 }),
516 }),
517 [readOnly, slideId, image, updateSlide, setPaletteDropTarget, dragId],
518 );
519
520 return (
521 <>
522 <ContextMenu>
523 <ContextMenuTrigger asChild disabled={readOnly}>
524 <Resizable
525 enable={{
526 top: false,
527 right: !readOnly && layoutType === "left",
528 bottom: !readOnly && layoutType === "vertical",
529 left: !readOnly && layoutType === "right",
530 topRight: false,
531 bottomRight: false,
532 bottomLeft: false,
533 topLeft: false,
534 }}
535 size={appliedSizeStyle}
536 minWidth={
537 layoutType === "vertical" ? "100%" : `${MIN_WIDTH_PERCENTAGE}%`
538 }
539 maxWidth={
540 layoutType === "vertical" ? "100%" : `${MAX_WIDTH_PERCENTAGE}%`
541 }
542 minHeight={
543 layoutType !== "vertical"
544 ? (resolvedMaxHeight ?? "100%")
545 : heightValue !== undefined
546 ? "0px"
547 : `${verticalMinHeight}px`
548 }
549 maxHeight={
550 layoutType !== "vertical"
551 ? resolvedMaxHeight
552 : resolvedMaxHeight
553 ? `${Math.min(MAX_HEIGHT, parseInt(resolvedMaxHeight, 10))}px`
554 : `${MAX_HEIGHT}px`
555 }
556 className={cn(
557 "group/resizable relative shrink-0",
558 isSideLayout && "min-h-0 self-stretch",
559 )}
560 handleComponent={{
561 right:
562 !readOnly && layoutType === "left" ? (
563 <div
564 aria-label="resize-right"
565 className="h-full w-1 cursor-ew-resize rounded-sm bg-(--presentation-primary)/70 opacity-0 transition-opacity duration-150 group-hover/resizable:opacity-100"
566 />
567 ) : undefined,
568 left:
569 !readOnly && layoutType === "right" ? (
570 <div
571 aria-label="resize-left"
572 className="h-full w-1 cursor-ew-resize rounded-sm bg-(--presentation-primary)/70 opacity-0 transition-opacity duration-150 group-hover/resizable:opacity-100"
573 />
574 ) : undefined,
575 bottom:
576 !readOnly && layoutType === "vertical" ? (
577 <div
578 aria-label="resize-bottom"
579 className="h-1 w-full cursor-ns-resize rounded-sm bg-(--presentation-primary)/70 opacity-0 transition-opacity duration-150 group-hover/resizable:opacity-100"
580 />
581 ) : undefined,
582 }}
583 onResize={onResize}
584 onResizeStop={onResizeStop}
585 data-root-image={slideId}
586 >
587 <div
588 ref={(el) => {
589 if (el && !readOnly) dropRef(el);
590 }}
591 className={cn(
592 "overflow-hidden backdrop-blur-xs",
593 isSideLayout ? "absolute inset-0" : "relative h-full",
594 isDragging && "opacity-50",
595 isChartOver && canDropChart && "ring-2 ring-primary ring-inset",
596 )}
597 style={{
598 borderRadius: "var(--presentation-border-radius, 0.5rem)",
599 boxShadow:
600 "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))",
601 }}
602 >
603 {/* Chart/Image/Embed drop overlay */}
604 {isChartOver && canDropChart && (
605 <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-primary/20 backdrop-blur-sm">
606 {isImageDrop ? (
607 <>
608 <ImageIcon className="h-12 w-12 text-primary" />
609 <p className="mt-2 text-sm font-medium text-primary">
610 Drop image here
611 </p>
612 </>
613 ) : isEmbedDrop ? (
614 <>
615 <Link className="h-12 w-12 text-primary" />
616 <p className="mt-2 text-sm font-medium text-primary">
617 Drop embed here
618 </p>
619 </>
620 ) : (
621 <>
622 <BarChart3 className="h-12 w-12 text-primary" />
623 <p className="mt-2 text-sm font-medium text-primary">
624 Drop chart here
625 </p>
626 </>
627 )}
628 </div>
629 )}
630 <div
631 ref={handleRef}
632 className="h-full cursor-grab active:cursor-grabbing"
633 >
634 {shouldShowGenerationPlaceholder ? (
635 <div className="flex h-full flex-col items-center justify-center gap-3 bg-muted/30 p-4 text-center">
636 <Spinner className="size-8" />
637 <div className="space-y-1">
638 <p className="text-sm font-medium text-foreground">
639 Generating root image
640 </p>
641 <p className="text-xs text-muted-foreground">
642 This can take a moment.
643 </p>
644 </div>
645 </div>
646 ) : !computedImageUrl &&
647 !image.embedType &&
648 !image.chartType ? (
649 <ImagePlaceholder
650 isStatic={false}
651 className="h-full"
652 slideId={slideId}
653 imageNotFound={computedGen?.status === "error"}
654 onOpenEditor={(mode: ImageEditorMode) => {
655 openImageEditor(mode);
656 }}
657 />
658 ) : image.chartType && image.chartData ? (
659 <Popover
660 open={!readOnly && showDeletePopover}
661 onOpenChange={readOnly ? () => {} : setShowDeletePopover}
662 >
663 <PopoverTrigger asChild>
664 <div
665 className="relative h-full"
666 data-root-image={slideId}
667 tabIndex={0}
668 onDoubleClick={handleImageDoubleClick}
669 role="button"
670 aria-label="Chart area, double-click to edit chart"
671 >
672 <ChartRenderer
673 chartType={image.chartType}
674 chartData={image.chartData}
675 chartOptions={image.chartOptions}
676 className="h-full w-full"
677 />
678 </div>
679 </PopoverTrigger>
680
681 <PopoverContent
682 className="w-auto p-0"
683 side="top"
684 align="center"
685 >
686 <Button
687 onClick={handleImageDoubleClick}
688 variant="ghost"
689 size="sm"
690 className="h-8"
691 >
692 <Edit className="mr-2 h-4 w-4" />
693 Edit Chart
694 </Button>
695 <Button
696 variant="destructive"
697 size="sm"
698 className="h-8"
699 onClick={handleDeleteClick}
700 >
701 <Trash2 className="mr-2 h-4 w-4" />
702 Delete Chart
703 </Button>
704 </PopoverContent>
705 </Popover>
706 ) : image.embedType && !image.url ? (
707 image.embedType === "infographic" ? (
708 <InfographicEmbedPlaceholder
709 className="h-full"
710 onEdit={() => {
711 setCurrentSlide(slideId);
712 openInfographicGenerationEditor();
713 }}
714 />
715 ) : (
716 // Embed type set but no URL - show placeholder for user to enter URL
717 <MediaEmbedPlaceholder
718 embedType={image.embedType}
719 className="h-full"
720 onUrlSubmit={(url: string) => {
721 updateSlide(slideId, {
722 rootImage: {
723 ...image,
724 url: url,
725 } as RootImageType,
726 });
727 }}
728 />
729 )
730 ) : image.embedType && image.url ? (
731 <Popover
732 open={!readOnly && showDeletePopover}
733 onOpenChange={readOnly ? () => {} : setShowDeletePopover}
734 >
735 <PopoverTrigger asChild>
736 <div
737 className="relative h-full"
738 data-root-image={slideId}
739 tabIndex={0}
740 onDoubleClick={handleImageDoubleClick}
741 role="button"
742 aria-label="Media embed area, double-click to edit embed"
743 >
744 <EmbedRenderer
745 embedType={image.embedType}
746 url={image.url}
747 className="h-full w-full"
748 style={imageStyles}
749 />
750 </div>
751 </PopoverTrigger>
752
753 <PopoverContent
754 className="w-auto p-0"
755 side="top"
756 align="center"
757 >
758 <Button
759 onClick={handleImageDoubleClick}
760 variant="ghost"
761 size="sm"
762 className="h-8"
763 >
764 <Edit className="mr-2 h-4 w-4" />
765 Edit Embed
766 </Button>
767 <Button
768 variant="destructive"
769 size="sm"
770 className="h-8"
771 onClick={handleDeleteClick}
772 >
773 <Trash2 className="mr-2 h-4 w-4" />
774 Delete Embed
775 </Button>
776 </PopoverContent>
777 </Popover>
778 ) : (
779 <Popover
780 open={!readOnly && showDeletePopover}
781 onOpenChange={readOnly ? () => {} : setShowDeletePopover}
782 >
783 <PopoverTrigger asChild>
784 <div
785 className="relative h-full"
786 data-root-image={slideId}
787 tabIndex={0}
788 onDoubleClick={handleImageDoubleClick}
789 role="button"
790 aria-label="Image area, double-click to edit image"
791 >
792 {/** biome-ignore lint/performance/noImgElement: This is a valid use case */}
793 <img
794 src={computedImageUrl}
795 alt={image.query}
796 className="" // Removed h-full w-full to avoid conflicts with inline styles
797 style={{
798 ...imageStyles,
799 }} // All sizing and crop styles handled here
800 onError={(e) => {
801 console.error(
802 "Image failed to load:",
803 e,
804 computedImageUrl,
805 );
806 }}
807 />
808 </div>
809 </PopoverTrigger>
810
811 <PopoverContent
812 className="w-auto p-0"
813 side="top"
814 align="center"
815 >
816 <Button
817 onClick={handleImageDoubleClick}
818 variant="ghost"
819 size="sm"
820 className="h-8"
821 >
822 <Edit className="mr-2 h-4 w-4" />
823 Edit
824 </Button>
825 {!image.url && (
826 <Button
827 variant="destructive"
828 size="sm"
829 className="h-8"
830 onClick={handleDeleteClick}
831 >
832 <Trash2 className="mr-2 h-4 w-4" />
833 Delete Layout
834 </Button>
835 )}
836 {image.url && (
837 <Button
838 variant="destructive"
839 size="sm"
840 className="h-8"
841 onClick={removeImage}
842 >
843 <ImageOff className="mr-2 h-4 w-4" />
844 Delete Image
845 </Button>
846 )}
847 </PopoverContent>
848 </Popover>
849 )}
850 </div>
851 </div>
852 </Resizable>
853 </ContextMenuTrigger>
854 {!readOnly && (
855 <ContextMenuContent className="w-64">
856 <ContextMenuItem onClick={() => handleAction("copy")}>
857 <Copy className="mr-2 h-4 w-4" />
858 Copy
859 </ContextMenuItem>
860 <ContextMenuItem onClick={() => handleAction("copyAddress")}>
861 <Link2 className="mr-2 h-4 w-4" />
862 Copy image address
863 </ContextMenuItem>
864 <ContextMenuItem onClick={() => handleAction("openNewTab")}>
865 <ExternalLink className="mr-2 h-4 w-4" />
866 Open image in new tab
867 </ContextMenuItem>
868 <ContextMenuItem onClick={() => handleAction("download")}>
869 <Download className="mr-2 h-4 w-4" />
870 Download image
871 </ContextMenuItem>
872 <ContextMenuSeparator />
873 <ContextMenuItem onClick={() => handleAction("replace")}>
874 <Edit className="mr-2 h-4 w-4" />
875 Replace image...
876 </ContextMenuItem>
877 <ContextMenuItem onClick={() => handleAction("fit")}>
878 <Maximize2 className="mr-2 h-4 w-4" />
879 {image.cropSettings?.objectFit === "contain"
880 ? "Cover Image"
881 : "Fit Image"}
882 </ContextMenuItem>
883 {computedImageUrl && (
884 <ContextMenuItem onClick={() => handleAction("crop")}>
885 <Scissors className="mr-2 h-4 w-4" />
886 Crop Image
887 </ContextMenuItem>
888 )}
889 <ContextMenuSeparator />
890 <ContextMenuItem onClick={() => handleAction("card")}>
891 <LayoutPanelLeft className="mr-2 h-4 w-4" />
892 Turn into card
893 </ContextMenuItem>
894 <ContextMenuSub>
895 <ContextMenuSubTrigger>
896 <Layout className="mr-2 h-4 w-4" />
897 Change layout
898 </ContextMenuSubTrigger>
899 <ContextMenuSubContent className="w-48">
900 <ContextMenuItem onClick={() => updateSlideLayout("vertical")}>
901 <div className="mr-2 flex h-4 w-4 flex-col gap-px rounded-sm border border-foreground/50 bg-background p-px">
902 <div className="h-1.5 w-full rounded-[1px] bg-foreground/50" />
903 </div>
904 Top layout
905 </ContextMenuItem>
906 <ContextMenuItem onClick={() => updateSlideLayout("left")}>
907 <div className="mr-2 flex h-4 w-4 gap-px rounded-sm border border-foreground/50 bg-background p-px">
908 <div className="h-full w-1.5 rounded-[1px] bg-foreground/50" />
909 </div>
910 Left layout
911 </ContextMenuItem>
912 <ContextMenuItem onClick={() => updateSlideLayout("right")}>
913 <div className="mr-2 flex h-4 w-4 justify-end gap-px rounded-sm border border-foreground/50 bg-background p-px">
914 <div className="h-full w-1.5 rounded-[1px] bg-foreground/50" />
915 </div>
916 Right layout
917 </ContextMenuItem>
918 </ContextMenuSubContent>
919 </ContextMenuSub>
920 <ContextMenuItem onClick={() => handleAction("background")}>
921 <ImageIcon className="mr-2 h-4 w-4" />
922 Use as card background
923 </ContextMenuItem>
924 <ContextMenuSeparator />
925 <ContextMenuItem onClick={() => handleAction("removeImage")}>
926 <ImageIcon className="mr-2 h-4 w-4" />
927 Remove image
928 </ContextMenuItem>
929 <ContextMenuItem
930 onClick={() => handleAction("removeLayout")}
931 className="text-red-500 focus:bg-red-50 focus:text-red-500"
932 >
933 <Trash2 className="mr-2 h-4 w-4" />
934 Remove layout
935 </ContextMenuItem>
936 </ContextMenuContent>
937 )}
938 </ContextMenu>
939
940 {/* Crop Modal */}
941 {computedImageUrl && (
942 <CropModal
943 open={isCropModalOpen}
944 onOpenChange={setIsCropModalOpen}
945 imageUrl={computedImageUrl}
946 initialCropSettings={{
947 objectFit: image.cropSettings?.objectFit ?? "cover",
948 objectPosition: {
949 x: image.cropSettings?.objectPosition?.x ?? 50,
950 y: image.cropSettings?.objectPosition?.y ?? 50,
951 },
952 zoom: image.cropSettings?.zoom ?? 1,
953 }}
954 onSave={(settings: ImageCropSettings) => {
955 updateCropSettings(settings);
956 setIsCropModalOpen(false);
957 }}
958 imageDimensions={imageDimensions}
959 />
960 )}
961 </>
962 );
963 }
964
964 lines Plain Text