/** * Custom Image Node for TipTap * * Provides a selectable, editable image with: * - Click to select * - Visual selection indicator * - Quick inline alt text editing * - Full detail panel for advanced settings * - Delete/replace options */ import { Button, Input } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Trash, Pencil, X, Check, SlidersHorizontal } from "@phosphor-icons/react"; import type { NodeViewProps } from "@tiptap/react"; import { Node, mergeAttributes } from "@tiptap/react"; import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; import * as React from "react"; import { cn } from "../../lib/utils"; import type { ImageAttributes } from "./ImageDetailPanel"; // Extend the Commands interface to include setImage declare module "@tiptap/react" { interface Commands { image: { setImage: (options: { src: string; alt?: string; title?: string; caption?: string; mediaId?: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; width?: number; height?: number; /** LQIP blurhash placeholder */ blurhash?: string; /** LQIP dominant-color placeholder */ dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; }) => ReturnType; }; } } // React component for the image node view function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }: NodeViewProps) { const { t } = useLingui(); const [isEditingAlt, setIsEditingAlt] = React.useState(false); const [altText, setAltText] = React.useState(node.attrs.alt || ""); /** Whether this node currently has its sidebar panel open */ const sidebarOpenRef = React.useRef(false); const handleSaveAlt = () => { updateAttributes({ alt: altText }); setIsEditingAlt(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSaveAlt(); } else if (e.key === "Escape") { setAltText(node.attrs.alt || ""); setIsEditingAlt(false); } }; // Sync local alt text state when node attributes change React.useEffect(() => { setAltText(node.attrs.alt || ""); }, [node.attrs.alt]); const getImageAttrs = (): ImageAttributes => ({ src: node.attrs.src, alt: node.attrs.alt, title: node.attrs.title, caption: node.attrs.caption, mediaId: node.attrs.mediaId, width: node.attrs.width, height: node.attrs.height, blurhash: node.attrs.blurhash, dominantColor: node.attrs.dominantColor, displayWidth: node.attrs.displayWidth, displayHeight: node.attrs.displayHeight, alignment: node.attrs.alignment, }); const openSidebar = () => { const storage = (editor.storage as unknown as Record>).image; const onOpen = storage?.onOpenBlockSidebar as | ((panel: { type: "image"; attrs: ImageAttributes; onUpdate: (attrs: Partial) => void; onReplace: (attrs: ImageAttributes) => void; onDelete: () => void; onClose: () => void; }) => void) | null; if (onOpen) { sidebarOpenRef.current = true; onOpen({ type: "image", attrs: getImageAttrs(), onUpdate: (attrs: Partial) => updateAttributes(attrs), onReplace: (attrs: ImageAttributes) => updateAttributes(attrs), onDelete: () => deleteNode(), onClose: () => { sidebarOpenRef.current = false; }, }); } }; const closeSidebar = () => { if (!sidebarOpenRef.current) return; const storage = (editor.storage as unknown as Record>).image; const onClose = storage?.onCloseBlockSidebar as (() => void) | null; if (onClose) { onClose(); sidebarOpenRef.current = false; } }; const toggleSidebar = () => { if (sidebarOpenRef.current) { closeSidebar(); } else { openSidebar(); } }; // Close sidebar when this node is deselected React.useEffect(() => { if (!selected) { closeSidebar(); } }, [selected]); const alignment = node.attrs.alignment as | "left" | "center" | "right" | "wide" | "full" | undefined; // Mirror the published layout so the editor is WYSIWYG: left/right // float (text wraps), center/wide/full size the block. const alignmentStyle: React.CSSProperties = alignment === "left" ? { float: "left", width: "fit-content", maxWidth: "50%", marginInlineEnd: "1.5rem" } : alignment === "right" ? { float: "right", width: "fit-content", maxWidth: "50%", marginInlineStart: "1.5rem" } : alignment === "center" ? { width: "fit-content", marginInline: "auto" } : alignment === "wide" || alignment === "full" ? { width: "100%" } : {}; return (
{node.attrs.alt {/* Selection overlay with actions */} {selected && (
)} {/* Quick alt text editor (inline) */} {isEditingAlt && (
setAltText(e.target.value)} onKeyDown={handleKeyDown} placeholder={t`Describe the image...`} className="flex-1 h-8 text-sm" autoFocus />
)} {/* Caption display (shows caption if set, falls back to alt) */} {!isEditingAlt && (node.attrs.caption || node.attrs.alt) && (
{node.attrs.caption || node.attrs.alt}
)}
); } // Custom Image extension with React NodeView export const ImageExtension = Node.create({ name: "image", addOptions() { return { inline: false, allowBase64: false, HTMLAttributes: {}, }; }, addStorage() { return { /** Callback set by PortableTextEditor to open image settings in the content sidebar */ onOpenBlockSidebar: null as | ((panel: { type: "image"; attrs: import("./ImageDetailPanel").ImageAttributes; onUpdate: (attrs: Partial) => void; onReplace: (attrs: import("./ImageDetailPanel").ImageAttributes) => void; onDelete: () => void; onClose: () => void; }) => void) | null, /** Callback set by PortableTextEditor to close the sidebar */ onCloseBlockSidebar: null as (() => void) | null, }; }, inline() { return this.options.inline; }, group() { return this.options.inline ? "inline" : "block"; }, draggable: true, addAttributes() { return { src: { default: null, }, alt: { default: null, }, title: { default: null, }, caption: { default: null, }, mediaId: { default: null, }, /** Provider ID for external media (e.g., "cloudflare-images") */ provider: { default: null, }, width: { default: null, }, height: { default: null, }, blurhash: { default: null, }, dominantColor: { default: null, }, displayWidth: { default: null, }, displayHeight: { default: null, }, alignment: { default: null, }, }; }, parseHTML() { return [ { tag: "img[src]", }, ]; }, renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, addNodeView() { return ReactNodeViewRenderer(ImageNodeView); }, addCommands() { return { setImage: (options: { src: string; alt?: string; title?: string; caption?: string; mediaId?: string; provider?: string; width?: number; height?: number; blurhash?: string; dominantColor?: string; displayWidth?: number; displayHeight?: number; alignment?: "left" | "center" | "right" | "wide" | "full"; }) => // eslint-disable-next-line @typescript-eslint/no-explicit-any ({ commands }: any) => { return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, });