import * as React from "react" import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react" import type { ElementDimensions } from "../hooks/use-drag-resize" import { useDragResize } from "../hooks/use-drag-resize" import { ResizeHandle } from "./resize-handle" import { cn } from "@/lib/utils" import { Controlled as ControlledZoom } from "react-medium-image-zoom" import { ActionButton, ActionWrapper, ImageActions } from "./image-actions" import { useImageActions } from "../hooks/use-image-actions" import { InfoCircledIcon, TrashIcon } from "@radix-ui/react-icons" import { ImageOverlay } from "./image-overlay" import { blobUrlToBase64 } from "@shared/editor/lib/utils" import { Spinner } from "@shared/components/spinner" const MAX_HEIGHT = 600 const MIN_HEIGHT = 120 const MIN_WIDTH = 120 interface ImageState { src: string isServerUploading: boolean imageLoaded: boolean isZoomed: boolean error: boolean naturalSize: ElementDimensions } export const ImageViewBlock: React.FC = ({ editor, node, selected, updateAttributes, }) => { const { src: initialSrc, width: initialWidth, height: initialHeight, } = node.attrs const [imageState, setImageState] = React.useState({ src: initialSrc, isServerUploading: false, imageLoaded: false, isZoomed: false, error: false, naturalSize: { width: initialWidth, height: initialHeight }, }) const containerRef = React.useRef(null) const [activeResizeHandle, setActiveResizeHandle] = React.useState< "left" | "right" | null >(null) const onDimensionsChange = React.useCallback( ({ width, height }: ElementDimensions) => { updateAttributes({ width, height }) }, [updateAttributes], ) const aspectRatio = imageState.naturalSize.width / imageState.naturalSize.height const maxWidth = MAX_HEIGHT * aspectRatio const containerMaxWidth = containerRef.current ? parseFloat( getComputedStyle(containerRef.current).getPropertyValue( "--editor-width", ), ) : Infinity const { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg } = useImageActions({ editor, node, src: imageState.src, onViewClick: (isZoomed) => setImageState((prev) => ({ ...prev, isZoomed })), }) const { currentWidth, currentHeight, updateDimensions, initiateResize, isResizing, } = useDragResize({ initialWidth: initialWidth ?? imageState.naturalSize.width, initialHeight: initialHeight ?? imageState.naturalSize.height, contentWidth: imageState.naturalSize.width, contentHeight: imageState.naturalSize.height, gridInterval: 0.1, onDimensionsChange, minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, maxWidth: containerMaxWidth > 0 ? containerMaxWidth : maxWidth, }) const shouldMerge = React.useMemo(() => currentWidth <= 180, [currentWidth]) const handleImageLoad = React.useCallback( (ev: React.SyntheticEvent) => { const img = ev.target as HTMLImageElement const newNaturalSize = { width: img.naturalWidth, height: img.naturalHeight, } setImageState((prev) => ({ ...prev, naturalSize: newNaturalSize, imageLoaded: true, })) updateAttributes({ width: img.width || newNaturalSize.width, height: img.height || newNaturalSize.height, alt: img.alt, title: img.title, }) if (!initialWidth) { updateDimensions((state) => ({ ...state, width: newNaturalSize.width })) } }, [initialWidth, updateAttributes, updateDimensions], ) const handleImageError = React.useCallback(() => { setImageState((prev) => ({ ...prev, error: true, imageLoaded: true })) }, []) const handleResizeStart = React.useCallback( (direction: "left" | "right") => (event: React.PointerEvent) => { setActiveResizeHandle(direction) initiateResize(direction)(event) }, [initiateResize], ) const handleResizeEnd = React.useCallback(() => { setActiveResizeHandle(null) }, []) React.useEffect(() => { if (!isResizing) { handleResizeEnd() } }, [isResizing, handleResizeEnd]) React.useEffect(() => { const handleImage = async () => { const imageExtension = editor.options.extensions.find( (ext) => ext.name === "image", ) const { uploadFn } = imageExtension?.options ?? {} if (initialSrc.startsWith("blob:")) { if (!uploadFn) { try { const base64 = await blobUrlToBase64(initialSrc) setImageState((prev) => ({ ...prev, src: base64 })) updateAttributes({ src: base64 }) } catch { setImageState((prev) => ({ ...prev, error: true })) } } else { try { setImageState((prev) => ({ ...prev, isServerUploading: true })) const url = await uploadFn(initialSrc, editor) setImageState((prev) => ({ ...prev, src: url, isServerUploading: false, })) updateAttributes({ src: url }) } catch { setImageState((prev) => ({ ...prev, error: true, isServerUploading: false, })) } } URL.revokeObjectURL(initialSrc) } } handleImage() }, [editor, initialSrc, updateAttributes]) return (
{!imageState.imageLoaded && !imageState.error && (
)} {imageState.error && (

Failed to load image

)} setImageState((prev) => ({ ...prev, isZoomed: false })) } > {node.attrs.alt
{imageState.isServerUploading && } {editor.isEditable && imageState.imageLoaded && !imageState.error && !imageState.isServerUploading && ( <> )}
{imageState.error && ( } tooltip="Remove image" onClick={onRemoveImg} /> )} {!isResizing && !imageState.error && !imageState.isServerUploading && ( )}
) }