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:
Matthias
2026-03-27 20:33:20 +01:00
parent 6e38e2d270
commit bc3bbf9d69
14 changed files with 1059 additions and 189 deletions

View File

@@ -97,6 +97,7 @@ export default function AiImageNode({
const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === id);
let referenceStorageId: Id<"_storage"> | undefined;
let referenceImageUrl: string | undefined;
for (const edge of incomingEdges) {
const src = getNode(edge.source);
if (src?.type === "image") {
@@ -106,6 +107,10 @@ export default function AiImageNode({
break;
}
}
if (src?.type === "asset") {
const srcData = src.data as { previewUrl?: string; url?: string };
referenceImageUrl = srcData.url ?? srcData.previewUrl;
}
}
const modelId = nodeData.model ?? DEFAULT_MODEL_ID;
@@ -117,6 +122,7 @@ export default function AiImageNode({
nodeId: id as Id<"nodes">,
prompt,
referenceStorageId,
referenceImageUrl,
model: modelId,
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
}),

View File

@@ -0,0 +1,245 @@
"use client";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type MouseEvent,
} from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { ExternalLink, ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
import {
AssetBrowserPanel,
type AssetBrowserSessionState,
} from "@/components/canvas/asset-browser-panel";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { computeMediaNodeSize, resolveMediaAspectRatio } from "@/lib/canvas-utils";
type AssetNodeData = {
assetId?: number;
assetType?: "photo" | "vector" | "icon";
title?: string;
previewUrl?: string;
intrinsicWidth?: number;
intrinsicHeight?: number;
url?: string;
sourceUrl?: string;
license?: "freemium" | "premium";
authorName?: string;
orientation?: string;
canvasId?: string;
_status?: string;
_statusMessage?: string;
};
export type AssetNodeType = Node<AssetNodeData, "asset">;
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
const [panelOpen, setPanelOpen] = useState(false);
const [handleTop, setHandleTop] = useState<number | undefined>(undefined);
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
term: "",
assetType: "photo",
results: [],
page: 1,
totalPages: 1,
});
const resizeNode = useMutation(api.nodes.resize);
const contentRef = useRef<HTMLDivElement | null>(null);
const mediaRef = useRef<HTMLDivElement | null>(null);
const hasAsset = typeof data.assetId === "number";
const previewUrl = data.url ?? data.previewUrl;
const aspectRatio = resolveMediaAspectRatio(
data.intrinsicWidth,
data.intrinsicHeight,
data.orientation,
);
useEffect(() => {
if (!hasAsset) return;
const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: data.intrinsicWidth,
intrinsicHeight: data.intrinsicHeight,
orientation: data.orientation,
});
if (width === targetSize.width && height === targetSize.height) {
return;
}
void resizeNode({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
}, [
data.intrinsicHeight,
data.intrinsicWidth,
data.orientation,
hasAsset,
height,
id,
resizeNode,
width,
]);
useLayoutEffect(() => {
if (!hasAsset || !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, hasAsset]);
const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
};
return (
<BaseNodeWrapper
nodeType="asset"
selected={selected}
status={data._status}
statusMessage={data._statusMessage}
className="overflow-hidden"
>
<Handle
type="target"
position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!"
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
/>
<div ref={contentRef} className="w-full">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Asset
</span>
<Button
size="sm"
variant={hasAsset ? "ghost" : "default"}
className="h-6 px-2 text-xs"
onClick={() => setPanelOpen(true)}
type="button"
>
{hasAsset ? "Change" : "Browse Assets"}
</Button>
</div>
{hasAsset && previewUrl ? (
<div className="flex flex-col gap-0">
<div
ref={mediaRef}
className="relative overflow-hidden bg-muted/30"
style={{ aspectRatio }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={data.title ?? "Asset preview"}
className="h-full w-full object-contain"
draggable={false}
/>
<Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]">
{data.assetType ?? "asset"}
</Badge>
{data.license ? (
<Badge
variant={data.license === "freemium" ? "outline" : "destructive"}
className="absolute top-2 right-2 h-4 py-0 text-[10px]"
>
{data.license}
</Badge>
) : null}
</div>
<div className="flex flex-col gap-1 px-3 py-2">
<p className="truncate text-xs font-medium" title={data.title ?? "Untitled"}>
{data.title ?? "Untitled"}
</p>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] text-muted-foreground">
by {data.authorName ?? "Freepik"}
</span>
{data.sourceUrl ? (
<a
href={data.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
onClick={stopNodeClickPropagation}
>
freepik.com
<ExternalLink className="h-2.5 w-2.5" />
</a>
) : null}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3 px-4 py-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-xs font-medium">No asset selected</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Browse millions of Freepik resources
</p>
</div>
</div>
)}
</div>
{panelOpen && data.canvasId ? (
<AssetBrowserPanel
nodeId={id}
canvasId={data.canvasId}
initialState={browserState}
onStateChange={setBrowserState}
onClose={() => setPanelOpen(false)}
/>
) : null}
<Handle
type="source"
position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!"
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
/>
</BaseNodeWrapper>
);
}

View File

@@ -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>
);

View File

@@ -182,6 +182,7 @@ export default function PromptNode({
const incomingEdges = currentEdges.filter((e) => e.target === id);
let connectedTextPrompt: string | undefined;
let referenceStorageId: Id<"_storage"> | undefined;
let referenceImageUrl: string | undefined;
for (const edge of incomingEdges) {
const sourceNode = getNode(edge.source);
@@ -197,6 +198,10 @@ export default function PromptNode({
referenceStorageId = srcData.storageId as Id<"_storage">;
}
}
if (sourceNode?.type === "asset") {
const srcData = sourceNode.data as { previewUrl?: string; url?: string };
referenceImageUrl = srcData.url ?? srcData.previewUrl;
}
}
const promptToUse = (connectedTextPrompt ?? prompt).trim();
@@ -240,6 +245,7 @@ export default function PromptNode({
nodeId: aiNodeId,
prompt: promptToUse,
referenceStorageId,
referenceImageUrl,
model: DEFAULT_MODEL_ID,
aspectRatio,
}),