"use client"; import { useState, useCallback, useEffect, useRef, type ChangeEvent, type DragEvent, } from "react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { useTranslations } from "next-intl"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; import { toast } from "@/lib/toast"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useMutation } from "convex/react"; const ALLOWED_IMAGE_TYPES = new Set([ "image/png", "image/jpeg", "image/webp", ]); const MAX_IMAGE_BYTES = 10 * 1024 * 1024; const OPTIMISTIC_NODE_PREFIX = "optimistic_"; type ImageNodeData = { storageId?: string; url?: string; filename?: string; mimeType?: string; width?: number; height?: number; _status?: string; _statusMessage?: string; }; export type ImageNode = Node; async function getImageDimensions(file: File): Promise<{ width: number; height: number }> { return await new Promise((resolve, reject) => { const objectUrl = URL.createObjectURL(file); const image = new window.Image(); image.onload = () => { const width = image.naturalWidth; const height = image.naturalHeight; URL.revokeObjectURL(objectUrl); if (!width || !height) { reject(new Error("Could not read image dimensions")); return; } resolve({ width, height }); }; image.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error("Could not decode image")); }; image.src = objectUrl; }); } export default function ImageNode({ id, data, selected, width, height, }: NodeProps) { const t = useTranslations('toasts'); const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const hasAutoSizedRef = useRef(false); useEffect(() => { if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) { return; } if (typeof data.width !== "number" || typeof data.height !== "number") { return; } if (hasAutoSizedRef.current) return; const targetSize = computeMediaNodeSize("image", { intrinsicWidth: data.width, intrinsicHeight: data.height, }); const currentWidth = typeof width === "number" ? width : 0; const currentHeight = typeof height === "number" ? height : 0; const hasMeasuredSize = currentWidth > 0 && currentHeight > 0; if (!hasMeasuredSize) { return; } const isAtTargetSize = currentWidth === targetSize.width && currentHeight === targetSize.height; const isAtDefaultSeedSize = currentWidth === 280 && currentHeight === 200; const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize; if (!shouldRunInitialAutoSize) { hasAutoSizedRef.current = true; return; } hasAutoSizedRef.current = true; void queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, }); }, [data.height, data.width, height, id, queueNodeResize, width]); const uploadFile = useCallback( async (file: File) => { if (!ALLOWED_IMAGE_TYPES.has(file.type)) { toast.error( t('canvas.uploadFailed'), t('canvas.uploadFormatError', { format: file.type || file.name.split(".").pop() || "—" }), ); return; } if (file.size > MAX_IMAGE_BYTES) { toast.error( t('canvas.uploadFailed'), t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }), ); return; } if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstützt", "Bild-Uploads benötigen eine aktive Verbindung.", ); return; } setIsUploading(true); try { let dimensions: { width: number; height: number } | undefined; try { dimensions = await getImageDimensions(file); } catch { dimensions = undefined; } const uploadUrl = await generateUploadUrl(); const result = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": file.type }, body: file, }); if (!result.ok) { throw new Error("Upload failed"); } const { storageId } = (await result.json()) as { storageId: string }; await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { storageId, filename: file.name, mimeType: file.type, ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), }, }); if (dimensions) { const targetSize = computeMediaNodeSize("image", { intrinsicWidth: dimensions.width, intrinsicHeight: dimensions.height, }); await queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, }); } toast.success(t('canvas.imageUploaded')); } catch (err) { console.error("Upload failed:", err); toast.error( t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined, ); } finally { setIsUploading(false); } }, [generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline] ); const handleClick = useCallback(() => { if (!data.url && !isUploading) { fileInputRef.current?.click(); } }, [data.url, isUploading]); const handleFileChange = useCallback( (e: ChangeEvent) => { const file = e.target.files?.[0]; if (file) uploadFile(file); }, [uploadFile] ); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) { setIsDragOver(true); e.dataTransfer.dropEffect = "copy"; } }, []); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []); const handleDrop = useCallback( (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith("image/")) { uploadFile(file); } }, [uploadFile] ); const handleReplace = useCallback(() => { fileInputRef.current?.click(); }, []); const showFilename = Boolean(data.filename && data.url); return (
🖼️ Bild
{data.url && ( )}
{isUploading ? (
Wird hochgeladen...
) : data.url ? ( // eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node {data.filename ) : (
📁 Klicken oder hierhin ziehen PNG, JPG, WebP
)}
{showFilename ? (

{data.filename}

) : null}
); }