feat: enhance canvas functionality with new asset node type and improved image handling
- Introduced a new "asset" node type in the canvas sidebar for better resource management. - Updated image node components to support dynamic image dimensions and improved resizing logic. - Enhanced prompt and AI image nodes to utilize reference images from asset nodes, improving integration and functionality. - Refactored canvas utilities to accommodate new asset configurations and maintain consistent media handling.
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
@@ -11,10 +13,11 @@ 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 Image from "next/image";
|
||||
import NextImage from "next/image";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { computeMediaNodeSize, resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
"image/png",
|
||||
@@ -28,18 +31,113 @@ type ImageNodeData = {
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
_status?: string;
|
||||
_statusMessage?: string;
|
||||
};
|
||||
|
||||
export type ImageNode = Node<ImageNodeData, "image">;
|
||||
|
||||
export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>) {
|
||||
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 contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [handleTop, setHandleTop] = useState<number | undefined>(undefined);
|
||||
|
||||
const aspectRatio = resolveMediaAspectRatio(data.width, data.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSize = computeMediaNodeSize("image", {
|
||||
intrinsicWidth: data.width,
|
||||
intrinsicHeight: data.height,
|
||||
});
|
||||
|
||||
if (width === targetSize.width && height === targetSize.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
void resizeNode({
|
||||
nodeId: id as Id<"nodes">,
|
||||
width: targetSize.width,
|
||||
height: targetSize.height,
|
||||
});
|
||||
}, [data.height, data.width, height, id, resizeNode, width]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!contentRef.current || !mediaRef.current) return;
|
||||
|
||||
const contentEl = contentRef.current;
|
||||
const mediaEl = mediaRef.current;
|
||||
let frameId: number | undefined;
|
||||
|
||||
const updateHandleTop = () => {
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
const mediaRect = mediaEl.getBoundingClientRect();
|
||||
const nextTop = mediaRect.top - contentRect.top + mediaRect.height / 2;
|
||||
setHandleTop(nextTop);
|
||||
});
|
||||
};
|
||||
|
||||
updateHandleTop();
|
||||
|
||||
const observer = new ResizeObserver(updateHandleTop);
|
||||
observer.observe(contentEl);
|
||||
observer.observe(mediaEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [aspectRatio, data.filename, data.url, isDragOver, isUploading]);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -61,6 +159,13 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
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",
|
||||
@@ -80,8 +185,23 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
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);
|
||||
@@ -93,7 +213,7 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[id, generateUploadUrl, updateData]
|
||||
[id, generateUploadUrl, resizeNode, updateData]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -144,14 +264,20 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="image" selected={selected} status={data._status}>
|
||||
<BaseNodeWrapper
|
||||
nodeType="image"
|
||||
selected={selected}
|
||||
status={data._status}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
|
||||
<div className="p-2">
|
||||
<div ref={contentRef} className="p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
{data.url && (
|
||||
@@ -164,48 +290,48 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="flex h-36 w-56 items-center justify-center rounded-lg 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 ref={mediaRef} className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}>
|
||||
{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>
|
||||
</div>
|
||||
) : data.url ? (
|
||||
<div className="relative h-36 w-56 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
) : data.url ? (
|
||||
<NextImage
|
||||
src={data.url}
|
||||
alt={data.filename ?? "Bild"}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="224px"
|
||||
sizes="(max-width: 640px) 100vw, 260px"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
nodrag flex h-36 w-56 cursor-pointer flex-col items-center justify-center
|
||||
rounded-lg border-2 border-dashed text-sm transition-colors
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
nodrag flex w-full cursor-pointer flex-col items-center justify-center
|
||||
h-full 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>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{data.filename && data.url && (
|
||||
<p className="mt-1 max-w-[260px] truncate text-xs text-muted-foreground">
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{data.filename}
|
||||
</p>
|
||||
)}
|
||||
@@ -223,6 +349,7 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user