import { useRef, useCallback, useState, useEffect, useMemo } from "react"; import { NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; import { ImageIcon, X } from "lucide-react"; import { Skeleton } from "@/client/components/ui/skeleton"; import { ImageElement, ImageErrorState, ImageLoadingSkeleton } from "@/shared/editor/presentation/image"; import { useEditorAffordance } from "../../editor-affordance-context"; import { useEditorRuntime } from "../../editor-runtime-context"; import { showImageInsertPanel } from "../../controllers/image/insert-panel"; import { prepareBlockDragPreview } from "../../lib/block-drag-preview"; import { createImageNodeTarget, getLocalImagePreview, resolveShareUrl } from "../../lib/media-actions"; import "@/styles/editor/image-node.css"; const FALLBACK_ASPECT_RATIO = 16 / 9; export function ImageNodeView({ node, selected, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) { const { workspaceId, pageId, shareToken } = useEditorRuntime(); const { canInsertImages } = useEditorAffordance(); const { src, alt = "", title, align = "left", width, naturalWidth, naturalHeight, pendingInsertId, brightness, } = node.attrs; const imgRef = useRef(null); const dragRef = useRef(null); const abortRef = useRef(null); const [liveWidth, setLiveWidth] = useState(null); const resolvedSrc = resolveShareUrl(typeof src === "string" ? src : "", shareToken); const [loadState, setLoadState] = useState<{ src: string; status: "loading" | "loaded" | "errored" }>(() => ({ src: resolvedSrc, status: "loading", })); const editable = editor.isEditable; const loadStatus = loadState.src === resolvedSrc ? loadState.status : "loading"; const finishDrag = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; dragRef.current = null; setLiveWidth(null); }, []); useEffect(() => () => finishDrag(), [finishDrag]); useEffect(() => { if (!editable) return; const handleDocumentDragStart = (event: DragEvent) => { if (!event.dataTransfer || liveWidth !== null) return; const imageDom = imgRef.current; const dragSource = imageDom?.closest(".react-renderer.node-image"); const target = event.target; if (!(dragSource instanceof HTMLElement) || !(target instanceof Node) || !dragSource.contains(target)) return; const pos = getPos(); if (typeof pos !== "number") return; prepareBlockDragPreview(editor, pos, event.dataTransfer); }; document.addEventListener("dragstart", handleDocumentDragStart, true); return () => document.removeEventListener("dragstart", handleDocumentDragStart, true); }, [editable, editor, getPos, liveWidth]); const handleResizeStart = useCallback( (side: "left" | "right", e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); const img = imgRef.current; if (!img || img.offsetWidth <= 0) return; (e.target as HTMLElement).setPointerCapture(e.pointerId); finishDrag(); const ac = new AbortController(); abortRef.current = ac; const startX = e.clientX; const startWidth = img.offsetWidth; const minWidth = Math.min(40, startWidth); const dir = side === "right" ? 1 : -1; document.addEventListener( "pointermove", (ev: PointerEvent) => { const newWidth = Math.max(minWidth, startWidth + dir * (ev.clientX - startX)); dragRef.current = newWidth; setLiveWidth(newWidth); }, { signal: ac.signal }, ); document.addEventListener( "pointerup", () => { if (dragRef.current !== null) { updateAttributes({ width: Math.round(dragRef.current) }); } finishDrag(); }, { signal: ac.signal }, ); }, [updateAttributes, finishDrag], ); const displayWidth = liveWidth ?? (typeof width === "number" && width > 0 ? width : undefined); const alignClass = align === "center" ? "tiptap-image-node--align-center" : align === "right" ? "tiptap-image-node--align-right" : "tiptap-image-node--align-left"; const uploadCtx = useMemo(() => ({ workspaceId, pageId, shareToken }), [pageId, shareToken, workspaceId]); const openImagePanel = useCallback(() => { if (!editor.isEditable || !canInsertImages) return; const pos = getPos(); if (typeof pos !== "number") return; showImageInsertPanel(editor, { uploadContext: uploadCtx, target: createImageNodeTarget(editor, pos), }); }, [canInsertImages, editor, getPos, uploadCtx]); const aspectRatio = typeof naturalWidth === "number" && typeof naturalHeight === "number" && naturalWidth > 0 && naturalHeight > 0 ? naturalWidth / naturalHeight : FALLBACK_ASPECT_RATIO; const naturalDisplayWidth = typeof naturalWidth === "number" && naturalWidth > 0 ? naturalWidth : undefined; const frameWidth = displayWidth ?? naturalDisplayWidth; const frameStyle: React.CSSProperties = { aspectRatio: String(aspectRatio), width: frameWidth ? `${frameWidth}px` : "100%", }; const pendingPreviewUrl = typeof pendingInsertId === "string" ? getLocalImagePreview(pendingInsertId) : null; if (!resolvedSrc) { if (pendingPreviewUrl) { return (
Uploading…
); } return (
{editor.isEditable && canInsertImages ? "Add an image" : "Image"} {editor.isEditable && ( )}
); } const isLoading = loadStatus === "loading"; const isErrored = loadStatus === "errored"; const containerStyle: React.CSSProperties | undefined = isLoading || isErrored ? frameStyle : displayWidth ? { width: `${displayWidth}px` } : undefined; const containerClass = [ "tiptap-image-container", selected ? "is-selected" : "", isLoading ? "is-loading" : "", isErrored ? "is-errored" : "", ] .filter(Boolean) .join(" "); return (
setLoadState({ src: resolvedSrc, status: "loaded" })} onError={() => setLoadState({ src: resolvedSrc, status: "errored" })} /> {isLoading && } {isErrored && } {editable && !isLoading && !isErrored && ( <>
handleResizeStart("left", e)} />
handleResizeStart("right", e)} /> )} {alt && !isLoading && !isErrored &&
{alt}
}
); }