/** * @fileoverview Custom Image Dialog Component for @writenex/astro * * This component provides a custom dialog for inserting and editing images * in the MDXEditor. It replaces the default MDXEditor image dialog with * a styled version that matches the Writenex design system. * Includes focus trap for accessibility compliance. * * ## Features: * - Tab interface for switching between upload and URL modes * - Drag-and-drop ready file upload zone * - Alt text and title fields for accessibility * - URL validation with error feedback * - Works with MDXEditor's image plugin system * - Focus trap for keyboard accessibility * * @module @writenex/astro/client/components/Editor/ImageDialog */ import { closeImageDialog$, imageDialogState$, insertImage$, saveImage$, useCellValue, usePublisher, } from "@mdxeditor/editor"; import { Image as ImageIcon, Link as LinkIcon, Upload, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useFocusTrap } from "../../hooks/useFocusTrap"; import "./ImageDialog.css"; /** * Validates if a string is a valid URL */ function isValidUrl(url: string): boolean { if (!url || url.trim() === "") return false; try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } /** * Custom Image Dialog component for MDXEditor. * * This component is passed to MDXEditor's imagePlugin as a custom dialog. * It handles both inserting new images and editing existing ones. * * @component * @example * ```tsx * // Used in MDXEditor plugin configuration * imagePlugin({ * imageUploadHandler: handleUpload, * imagePreviewHandler: handlePreview, * ImageDialog: ImageDialog, * }) * ``` */ export function ImageDialog(): React.ReactElement { const insertImage = usePublisher(insertImage$); const saveImage = usePublisher(saveImage$); const closeImageDialog = usePublisher(closeImageDialog$); const state = useCellValue(imageDialogState$); const [mode, setMode] = useState<"upload" | "url">("upload"); const [src, setSrc] = useState(""); const [file, setFile] = useState(null); const [altText, setAltText] = useState(""); const [title, setTitle] = useState(""); const [prevType, setPrevType] = useState(state.type); const [isUrlValid, setIsUrlValid] = useState(true); const fileInputRef = useRef(null); const triggerRef = useRef(null); // Store the trigger element when dialog opens useEffect(() => { if (state.type !== "inactive") { triggerRef.current = document.activeElement as HTMLElement; } }, [state.type]); // Focus trap for accessibility const { containerRef } = useFocusTrap({ enabled: state.type !== "inactive", onEscape: closeImageDialog, returnFocusTo: triggerRef.current, }); // Reset or populate form when state changes if (state.type !== prevType) { setPrevType(state.type); if (state.type === "editing") { setMode(state.initialValues.src ? "url" : "upload"); setSrc(state.initialValues.src || ""); setAltText(state.initialValues.altText || ""); setTitle(state.initialValues.title || ""); setFile(null); setIsUrlValid(true); } else if (state.type === "new") { setMode("upload"); setSrc(""); setAltText(""); setTitle(""); setFile(null); setIsUrlValid(true); } } // Focus first input when dialog opens (useFocusTrap handles escape key) useEffect(() => { if (state.type !== "inactive" && containerRef.current) { const firstInput = containerRef.current.querySelector("input, button"); if (firstInput instanceof HTMLElement) { setTimeout(() => firstInput.focus(), 50); } } }, [state.type, containerRef]); const handleSrcChange = useCallback( (e: React.ChangeEvent) => { const newSrc = e.target.value; setSrc(newSrc); setIsUrlValid(newSrc === "" || isValidUrl(newSrc)); }, [] ); const handleSave = useCallback(() => { if (mode === "url" && !isValidUrl(src)) { setIsUrlValid(false); return; } if (state.type === "editing") { const payload: { altText: string; title: string; file?: FileList; src?: string; } = { altText, title, }; if (mode === "upload" && file) { const dt = new DataTransfer(); dt.items.add(file); payload.file = dt.files; } else if (mode === "url" && src) { payload.src = src; } saveImage(payload); } else { if (mode === "upload" && file) { insertImage({ file, altText, title }); } else if (mode === "url" && src) { insertImage({ src, altText, title }); } } closeImageDialog(); }, [ mode, src, file, altText, title, state.type, insertImage, saveImage, closeImageDialog, ]); const handleFileChange = useCallback( (e: React.ChangeEvent) => { if (e.target.files?.[0]) { setFile(e.target.files[0]); } }, [] ); const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { closeImageDialog(); } }, [closeImageDialog] ); const isOpen = state.type !== "inactive"; const canSave = mode === "upload" ? !!file : !!src && isUrlValid; if (!isOpen) return <>; return (
{/* Header */}

{state.type === "editing" ? "Edit Image" : "Insert Image"}

{/* Tabs */}
{/* Content */}
{mode === "upload" ? (
fileInputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { fileInputRef.current?.click(); } }} > {file ? (

{file.name}

{(file.size / 1024).toFixed(1)} KB

) : ( <>

Click to select an image

)}
) : (
{!isUrlValid && ( )}
)}
setAltText(e.target.value)} placeholder="Description for accessibility" />
setTitle(e.target.value)} placeholder="Hover text" />
{/* Footer */}
); }