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:
380
components/canvas/asset-browser-panel.tsx
Normal file
380
components/canvas/asset-browser-panel.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAction, useMutation } from "convex/react";
|
||||
import { X, Search, Loader2, AlertCircle } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
|
||||
type AssetType = "photo" | "vector" | "icon";
|
||||
|
||||
interface FreepikResult {
|
||||
id: number;
|
||||
title: string;
|
||||
assetType: AssetType;
|
||||
previewUrl: string;
|
||||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
sourceUrl: string;
|
||||
license: "freemium" | "premium";
|
||||
authorName: string;
|
||||
orientation?: string;
|
||||
}
|
||||
|
||||
export interface AssetBrowserSessionState {
|
||||
term: string;
|
||||
assetType: AssetType;
|
||||
results: FreepikResult[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
canvasId: string;
|
||||
onClose: () => void;
|
||||
initialState?: AssetBrowserSessionState;
|
||||
onStateChange?: (state: AssetBrowserSessionState) => void;
|
||||
}
|
||||
|
||||
export function AssetBrowserPanel({
|
||||
nodeId,
|
||||
canvasId,
|
||||
onClose,
|
||||
initialState,
|
||||
onStateChange,
|
||||
}: Props) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [term, setTerm] = useState(initialState?.term ?? "");
|
||||
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
|
||||
const [assetType, setAssetType] = useState<AssetType>(initialState?.assetType ?? "photo");
|
||||
const [results, setResults] = useState<FreepikResult[]>(initialState?.results ?? []);
|
||||
const [page, setPage] = useState(initialState?.page ?? 1);
|
||||
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const searchFreepik = useAction(api.freepik.search);
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedTerm(term);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [term]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onStateChange) return;
|
||||
onStateChange({
|
||||
term,
|
||||
assetType,
|
||||
results,
|
||||
page,
|
||||
totalPages,
|
||||
});
|
||||
}, [assetType, onStateChange, page, results, term, totalPages]);
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (searchTerm: string, type: AssetType, requestedPage: number) => {
|
||||
const cleanedTerm = searchTerm.trim();
|
||||
if (!cleanedTerm) {
|
||||
setResults([]);
|
||||
setErrorMessage(null);
|
||||
setTotalPages(1);
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await searchFreepik({
|
||||
term: cleanedTerm,
|
||||
assetType: type,
|
||||
page: requestedPage,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
setResults(response.results);
|
||||
setTotalPages(response.totalPages);
|
||||
setPage(response.currentPage);
|
||||
} catch (error) {
|
||||
console.error("Freepik search error", error);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Freepik search failed",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[searchFreepik],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldSkipInitialSearchRef.current) {
|
||||
shouldSkipInitialSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
void runSearch(debouncedTerm, assetType, 1);
|
||||
}, [assetType, debouncedTerm, runSearch]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (asset: FreepikResult) => {
|
||||
await updateData({
|
||||
nodeId: nodeId as Id<"nodes">,
|
||||
data: {
|
||||
assetId: asset.id,
|
||||
assetType: asset.assetType,
|
||||
title: asset.title,
|
||||
previewUrl: asset.previewUrl,
|
||||
intrinsicWidth: asset.intrinsicWidth,
|
||||
intrinsicHeight: asset.intrinsicHeight,
|
||||
url: asset.previewUrl,
|
||||
sourceUrl: asset.sourceUrl,
|
||||
license: asset.license,
|
||||
authorName: asset.authorName,
|
||||
orientation: asset.orientation,
|
||||
canvasId,
|
||||
},
|
||||
});
|
||||
|
||||
const targetSize = computeMediaNodeSize("asset", {
|
||||
intrinsicWidth: asset.intrinsicWidth,
|
||||
intrinsicHeight: asset.intrinsicHeight,
|
||||
orientation: asset.orientation,
|
||||
});
|
||||
|
||||
await resizeNode({
|
||||
nodeId: nodeId as Id<"nodes">,
|
||||
width: targetSize.width,
|
||||
height: targetSize.height,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[canvasId, nodeId, onClose, resizeNode, updateData],
|
||||
);
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
if (isLoading || page <= 1) return;
|
||||
void runSearch(debouncedTerm, assetType, page - 1);
|
||||
}, [assetType, debouncedTerm, isLoading, page, runSearch]);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (isLoading || page >= totalPages) return;
|
||||
void runSearch(debouncedTerm, assetType, page + 1);
|
||||
}, [assetType, debouncedTerm, isLoading, page, runSearch, totalPages]);
|
||||
|
||||
const modal = useMemo(
|
||||
() => (
|
||||
<div
|
||||
className="nowheel nodrag nopan fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="nowheel nodrag nopan relative flex max-h-[80vh] w-[720px] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-sm font-semibold">Browse Freepik Assets</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Close asset browser"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-b px-5 py-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search photos, vectors, icons..."
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={assetType} onValueChange={(value) => setAssetType(value as AssetType)}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="photo" className="text-xs">
|
||||
Photos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vector" className="text-xs">
|
||||
Vectors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="icon" className="text-xs">
|
||||
Icons
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 16 }).map((_, index) => (
|
||||
<div key={index} className="aspect-square animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-foreground">Search failed</p>
|
||||
<p className="max-w-md text-xs">{errorMessage}</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
<Search className="h-8 w-8" />
|
||||
<p className="text-sm">
|
||||
{term.trim() ? "No results found" : "Type to search Freepik assets"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{results.map((asset) => (
|
||||
<button
|
||||
key={`${asset.assetType}-${asset.id}`}
|
||||
onClick={() => void handleSelect(asset)}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus:border-primary focus:outline-none"
|
||||
title={asset.title}
|
||||
type="button"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={asset.previewUrl}
|
||||
alt={asset.title}
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-x-1 top-1 flex items-start justify-between gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Badge variant="secondary" className="h-4 px-1.5 py-0 text-[10px]">
|
||||
{asset.assetType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={asset.license === "freemium" ? "outline" : "destructive"}
|
||||
className="h-4 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{asset.license}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
|
||||
{results.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={isLoading || page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={isLoading || page >= totalPages}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Next"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Assets by{" "}
|
||||
<a
|
||||
href="https://www.freepik.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline transition-colors hover:text-foreground"
|
||||
>
|
||||
Freepik
|
||||
</a>
|
||||
. Freemium assets require attribution.
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{results.length > 0 ? `${results.length} results on this page` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
assetType,
|
||||
errorMessage,
|
||||
handleNextPage,
|
||||
handlePreviousPage,
|
||||
handleSelect,
|
||||
isLoading,
|
||||
onClose,
|
||||
page,
|
||||
results,
|
||||
term,
|
||||
totalPages,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const nodeTemplates = [
|
||||
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" },
|
||||
{ type: "asset", label: "Asset", icon: "🛍️", category: "Quelle" },
|
||||
{ type: "text", label: "Text", icon: "📝", category: "Quelle" },
|
||||
{ type: "prompt", label: "Prompt", icon: "✨", category: "Quelle" },
|
||||
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" },
|
||||
|
||||
@@ -6,6 +6,7 @@ import GroupNode from "./nodes/group-node";
|
||||
import FrameNode from "./nodes/frame-node";
|
||||
import NoteNode from "./nodes/note-node";
|
||||
import CompareNode from "./nodes/compare-node";
|
||||
import AssetNode from "./nodes/asset-node";
|
||||
|
||||
/**
|
||||
* Node-Type-Map für React Flow.
|
||||
@@ -23,4 +24,5 @@ export const nodeTypes = {
|
||||
frame: FrameNode,
|
||||
note: NoteNode,
|
||||
compare: CompareNode,
|
||||
asset: AssetNode,
|
||||
} as const;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
245
components/canvas/nodes/asset-node.tsx
Normal file
245
components/canvas/nodes/asset-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user