/** * Image Detail Panel for Editor * * A slide-out panel for editing image properties in the rich text editor. * Shows preview and allows editing alt text, caption, and link settings. */ import { Button, Input, InputArea, Label, LinkButton } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { X, ArrowSquareOut, Ruler, SlidersHorizontal, ImageSquare, LinkSimple, LinkBreak, } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../../lib/api"; import { useStableCallback } from "../../lib/hooks"; import { ConfirmDialog } from "../ConfirmDialog"; import { MediaPickerModal } from "../MediaPickerModal"; export interface ImageAttributes { src: string; alt?: string; title?: string; caption?: string; mediaId?: string; /** Original image width */ width?: number; /** Original image height */ height?: number; /** LQIP blurhash placeholder */ blurhash?: string; /** LQIP dominant-color placeholder */ dominantColor?: string; /** Display width for this instance (defaults to original) */ displayWidth?: number; /** Display height for this instance (defaults to original) */ displayHeight?: number; /** Alignment for this image instance (e.g. from a WordPress import) */ alignment?: "left" | "center" | "right" | "wide" | "full"; } export interface ImageDetailPanelProps { attributes: ImageAttributes; onUpdate: (attrs: Partial) => void; onReplace: (attrs: ImageAttributes) => void; onDelete: () => void; onClose: () => void; /** When true, renders inline within the sidebar column instead of as a fixed overlay */ inline?: boolean; } /** * Panel for editing image properties in the editor. * Renders as a fixed slide-out overlay by default, or inline within * the content sidebar when `inline` is true. */ export function ImageDetailPanel({ attributes, onUpdate, onReplace, onDelete, onClose, inline = false, }: ImageDetailPanelProps) { const { t } = useLingui(); // Form state const [alt, setAlt] = React.useState(attributes.alt ?? ""); const [caption, setCaption] = React.useState(attributes.caption ?? ""); const [title, setTitle] = React.useState(attributes.title ?? ""); const [showMediaPicker, setShowMediaPicker] = React.useState(false); // Dimension state - default to display dimensions, fall back to original const [displayWidth, setDisplayWidth] = React.useState( attributes.displayWidth ?? attributes.width, ); const [displayHeight, setDisplayHeight] = React.useState( attributes.displayHeight ?? attributes.height, ); const [lockAspectRatio, setLockAspectRatio] = React.useState(true); const [alignment, setAlignment] = React.useState( attributes.alignment, ); // Calculate aspect ratio from original dimensions const aspectRatio = attributes.width && attributes.height ? attributes.width / attributes.height : undefined; const handleWidthChange = (value: string) => { const newWidth = value ? parseInt(value, 10) : undefined; setDisplayWidth(newWidth); if (lockAspectRatio && aspectRatio && newWidth) { setDisplayHeight(Math.round(newWidth / aspectRatio)); } }; const handleHeightChange = (value: string) => { const newHeight = value ? parseInt(value, 10) : undefined; setDisplayHeight(newHeight); if (lockAspectRatio && aspectRatio && newHeight) { setDisplayWidth(Math.round(newHeight * aspectRatio)); } }; const handleResetDimensions = () => { setDisplayWidth(attributes.width); setDisplayHeight(attributes.height); }; const handleMediaSelect = (item: MediaItem) => { onReplace({ src: item.url, alt: item.alt || item.filename, mediaId: item.id, width: item.width, height: item.height, blurhash: item.blurhash, dominantColor: item.dominantColor, // Clear caption/title since it's a new image caption: undefined, title: undefined, }); setShowMediaPicker(false); onClose(); }; // Track if form has unsaved changes const hasChanges = React.useMemo(() => { const originalDisplayWidth = attributes.displayWidth ?? attributes.width; const originalDisplayHeight = attributes.displayHeight ?? attributes.height; return ( alt !== (attributes.alt ?? "") || caption !== (attributes.caption ?? "") || title !== (attributes.title ?? "") || displayWidth !== originalDisplayWidth || displayHeight !== originalDisplayHeight || alignment !== attributes.alignment ); }, [attributes, alt, caption, title, displayWidth, displayHeight, alignment]); const handleSave = () => { onUpdate({ alt: alt || undefined, caption: caption || undefined, title: title || undefined, displayWidth, displayHeight, alignment, }); onClose(); }; const alignmentOptions: { value: ImageAttributes["alignment"]; label: string }[] = [ { value: undefined, label: t`None` }, { value: "left", label: t`Left` }, { value: "center", label: t`Center` }, { value: "right", label: t`Right` }, { value: "wide", label: t`Wide` }, { value: "full", label: t`Full` }, ]; const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const handleDelete = () => { setShowDeleteConfirm(true); }; const stableOnClose = useStableCallback(onClose); const stableHandleSave = useStableCallback(handleSave); // Handle keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { stableOnClose(); } if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); stableHandleSave(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [stableOnClose, stableHandleSave]); const dialogs = ( <> setShowDeleteConfirm(false)} title={t`Remove Image?`} description={t`Remove this image from the document?`} confirmLabel={t`Remove`} pendingLabel={t`Removing...`} isPending={false} error={null} onConfirm={() => { onDelete(); onClose(); }} /> ); if (inline) { return (
{/* Header */}

{t`Image Settings`}

{/* Preview */}
{attributes.alt
{/* Original dimensions */} {(attributes.width || attributes.height) && (
{t`Original:`} {attributes.width} × {attributes.height}
)}
{/* Display Size — shown for any image; migrated images may lack original dims */} {attributes.src && (
{attributes.width && attributes.height && ( )}
handleWidthChange(e.target.value)} />
{aspectRatio && ( )}
handleHeightChange(e.target.value)} />

{t`Set a custom display size for this image instance.`}

)} {/* Alignment */} {attributes.src && (
{alignmentOptions.map((opt) => ( ))}
)} {/* Editable Fields */}
setAlt(e.target.value)} placeholder={t`Describe this image for accessibility`} description={t`Required for accessibility. Describes the image for screen readers.`} /> setCaption(e.target.value)} placeholder={t`Optional caption displayed below the image`} description={t`Displayed below the image as a visible caption.`} rows={2} /> setTitle(e.target.value)} placeholder={t`Optional tooltip on hover`} description={t`Shown when hovering over the image.`} /> {/* Source URL - only show for external images (no mediaId) */} {!attributes.mediaId && attributes.src && (
)}
{/* Actions */}
{dialogs}
); } return (
{/* Header */}

{t`Image Settings`}

{/* Content */}
{/* Preview */}
{attributes.alt
{/* Image Info - original dimensions */} {(attributes.width || attributes.height) && (
{t`Original:`} {attributes.width} × {attributes.height}
)} {/* Display Size — shown for any image; migrated images may lack original dims */} {attributes.src && (
{attributes.width && attributes.height && ( )}
handleWidthChange(e.target.value)} />
{aspectRatio && ( )}
handleHeightChange(e.target.value)} />

{t`Set a custom display size for this image instance.`}

)} {/* Alignment */} {attributes.src && (
{alignmentOptions.map((opt) => ( ))}
)} {/* Editable Fields */}
setAlt(e.target.value)} placeholder={t`Describe this image for accessibility`} description={t`Required for accessibility. Describes the image for screen readers.`} /> setCaption(e.target.value)} placeholder={t`Optional caption displayed below the image`} description={t`Displayed below the image as a visible caption.`} rows={2} /> setTitle(e.target.value)} placeholder={t`Optional tooltip on hover`} description={t`Shown when hovering over the image.`} /> {/* Source URL - only show for external images (no mediaId) */} {!attributes.mediaId && attributes.src && (
)}
{/* Footer */}
{dialogs}
); } export default ImageDetailPanel;