- Added client mount state to the dashboard to prevent premature interactions before the component is fully loaded. - Updated button disabling logic to ensure it reflects the component's readiness and user session state. - Introduced zIndex handling in canvas placement context for better node layering. - Enhanced asset and image nodes with improved resizing logic to maintain aspect ratios during adjustments. - Refactored node components to streamline rendering and improve performance during dynamic updates.
333 lines
9.5 KiB
TypeScript
333 lines
9.5 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useState,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
type ChangeEvent,
|
||
type DragEvent,
|
||
} from "react";
|
||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||
import { useMutation } from "convex/react";
|
||
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 { msg } from "@/lib/toast-messages";
|
||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||
|
||
const ALLOWED_IMAGE_TYPES = new Set([
|
||
"image/png",
|
||
"image/jpeg",
|
||
"image/webp",
|
||
]);
|
||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||
|
||
type ImageNodeData = {
|
||
storageId?: string;
|
||
url?: string;
|
||
filename?: string;
|
||
mimeType?: string;
|
||
width?: number;
|
||
height?: number;
|
||
_status?: string;
|
||
_statusMessage?: string;
|
||
};
|
||
|
||
export type ImageNode = Node<ImageNodeData, "image">;
|
||
|
||
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<ImageNode>) {
|
||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||
const updateData = useMutation(api.nodes.updateData);
|
||
const resizeNode = useMutation(api.nodes.resize);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const hasAutoSizedRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
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 resizeNode({
|
||
nodeId: id as Id<"nodes">,
|
||
width: targetSize.width,
|
||
height: targetSize.height,
|
||
});
|
||
}, [data.height, data.width, height, id, resizeNode, width]);
|
||
|
||
const uploadFile = useCallback(
|
||
async (file: File) => {
|
||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||
const { title, desc } = msg.canvas.uploadFormatError(
|
||
file.type || file.name.split(".").pop() || "—",
|
||
);
|
||
toast.error(title, desc);
|
||
return;
|
||
}
|
||
if (file.size > MAX_IMAGE_BYTES) {
|
||
const { title, desc } = msg.canvas.uploadSizeError(
|
||
Math.round(MAX_IMAGE_BYTES / (1024 * 1024)),
|
||
);
|
||
toast.error(title, desc);
|
||
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 updateData({
|
||
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 resizeNode({
|
||
nodeId: id as Id<"nodes">,
|
||
width: targetSize.width,
|
||
height: targetSize.height,
|
||
});
|
||
}
|
||
|
||
toast.success(msg.canvas.imageUploaded.title);
|
||
} catch (err) {
|
||
console.error("Upload failed:", err);
|
||
toast.error(
|
||
msg.canvas.uploadFailed.title,
|
||
err instanceof Error ? err.message : undefined,
|
||
);
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
},
|
||
[id, generateUploadUrl, resizeNode, updateData]
|
||
);
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (!data.url && !isUploading) {
|
||
fileInputRef.current?.click();
|
||
}
|
||
}, [data.url, isUploading]);
|
||
|
||
const handleFileChange = useCallback(
|
||
(e: ChangeEvent<HTMLInputElement>) => {
|
||
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 (
|
||
<BaseNodeWrapper
|
||
nodeType="image"
|
||
selected={selected}
|
||
status={data._status}
|
||
>
|
||
<Handle
|
||
type="target"
|
||
position={Position.Left}
|
||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||
/>
|
||
|
||
<div
|
||
className={`grid h-full min-h-0 w-full grid-cols-1 gap-y-1 p-2 ${
|
||
showFilename
|
||
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
||
: "grid-rows-[auto_minmax(0,1fr)]"
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||
{data.url && (
|
||
<button
|
||
onClick={handleReplace}
|
||
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||
>
|
||
Ersetzen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
|
||
{isUploading ? (
|
||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||
<span className="text-xs text-muted-foreground">Wird hochgeladen...</span>
|
||
</div>
|
||
</div>
|
||
) : data.url ? (
|
||
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
|
||
<img
|
||
src={data.url}
|
||
alt={data.filename ?? "Bild"}
|
||
className="h-full w-full object-cover object-center"
|
||
draggable={false}
|
||
/>
|
||
) : (
|
||
<div
|
||
onClick={handleClick}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
className={`
|
||
nodrag flex h-full w-full cursor-pointer flex-col items-center justify-center
|
||
border-2 border-dashed text-sm transition-colors
|
||
${
|
||
isDragOver
|
||
? "border-primary bg-primary/5 text-primary"
|
||
: "text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||
}
|
||
`}
|
||
>
|
||
<span className="mb-1 text-lg">📁</span>
|
||
<span>Klicken oder hierhin ziehen</span>
|
||
<span className="mt-0.5 text-xs">PNG, JPG, WebP</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showFilename ? (
|
||
<p className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
/>
|
||
|
||
<Handle
|
||
type="source"
|
||
position={Position.Right}
|
||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||
/>
|
||
</BaseNodeWrapper>
|
||
);
|
||
}
|