feat: enhance asset browser panel with improved asset selection and loading states

- Added state management for asset selection to prevent multiple simultaneous selections.
- Implemented request sequence tracking to ensure accurate loading state handling during asset searches.
- Enhanced error handling and user feedback for asset loading failures.
- Updated UI elements to improve accessibility and user experience during asset browsing.
This commit is contained in:
Matthias
2026-03-27 21:26:29 +01:00
parent bc3bbf9d69
commit 8e4e2fcac1
9 changed files with 278 additions and 155 deletions

View File

@@ -59,11 +59,15 @@ export function AssetBrowserPanel({
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1); const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
const searchFreepik = useAction(api.freepik.search); const searchFreepik = useAction(api.freepik.search);
const updateData = useMutation(api.nodes.updateData); const updateData = useMutation(api.nodes.updateData);
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
const requestSequenceRef = useRef(0);
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const isSelecting = selectingAssetKey !== null;
useEffect(() => { useEffect(() => {
setIsMounted(true); setIsMounted(true);
@@ -102,6 +106,7 @@ export function AssetBrowserPanel({
const runSearch = useCallback( const runSearch = useCallback(
async (searchTerm: string, type: AssetType, requestedPage: number) => { async (searchTerm: string, type: AssetType, requestedPage: number) => {
const cleanedTerm = searchTerm.trim(); const cleanedTerm = searchTerm.trim();
const requestSequence = ++requestSequenceRef.current;
if (!cleanedTerm) { if (!cleanedTerm) {
setResults([]); setResults([]);
setErrorMessage(null); setErrorMessage(null);
@@ -121,17 +126,30 @@ export function AssetBrowserPanel({
limit: 20, limit: 20,
}); });
if (requestSequence !== requestSequenceRef.current) {
return;
}
setResults(response.results); setResults(response.results);
setTotalPages(response.totalPages); setTotalPages(response.totalPages);
setPage(response.currentPage); setPage(response.currentPage);
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0;
}
} catch (error) { } catch (error) {
if (requestSequence !== requestSequenceRef.current) {
return;
}
console.error("Freepik search error", error); console.error("Freepik search error", error);
setErrorMessage( setErrorMessage(
error instanceof Error ? error.message : "Freepik search failed", error instanceof Error ? error.message : "Freepik search failed",
); );
} finally { } finally {
if (requestSequence === requestSequenceRef.current) {
setIsLoading(false); setIsLoading(false);
} }
}
}, },
[searchFreepik], [searchFreepik],
); );
@@ -147,6 +165,10 @@ export function AssetBrowserPanel({
const handleSelect = useCallback( const handleSelect = useCallback(
async (asset: FreepikResult) => { async (asset: FreepikResult) => {
if (isSelecting) return;
const assetKey = `${asset.assetType}-${asset.id}`;
setSelectingAssetKey(assetKey);
try {
await updateData({ await updateData({
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
data: { data: {
@@ -177,8 +199,13 @@ export function AssetBrowserPanel({
height: targetSize.height, height: targetSize.height,
}); });
onClose(); onClose();
} catch (error) {
console.error("Failed to select asset", error);
} finally {
setSelectingAssetKey(null);
}
}, },
[canvasId, nodeId, onClose, resizeNode, updateData], [canvasId, isSelecting, nodeId, onClose, resizeNode, updateData],
); );
const handlePreviousPage = useCallback(() => { const handlePreviousPage = useCallback(() => {
@@ -204,6 +231,9 @@ export function AssetBrowserPanel({
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()} onPointerDownCapture={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Browse Freepik assets"
> >
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4"> <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> <h2 className="text-sm font-semibold">Browse Freepik Assets</h2>
@@ -244,6 +274,7 @@ export function AssetBrowserPanel({
</div> </div>
<div <div
ref={scrollAreaRef}
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5" className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
onWheelCapture={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}
> >
@@ -258,6 +289,14 @@ export function AssetBrowserPanel({
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-foreground">Search failed</p> <p className="text-sm text-foreground">Search failed</p>
<p className="max-w-md text-xs">{errorMessage}</p> <p className="max-w-md text-xs">{errorMessage}</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void runSearch(debouncedTerm, assetType, page)}
>
Try again
</Button>
</div> </div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
@@ -269,13 +308,20 @@ export function AssetBrowserPanel({
) : ( ) : (
<> <>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
{results.map((asset) => ( {results.map((asset) => {
const assetKey = `${asset.assetType}-${asset.id}`;
const isSelectingThisAsset = selectingAssetKey === assetKey;
return (
<button <button
key={`${asset.assetType}-${asset.id}`} key={assetKey}
onClick={() => void handleSelect(asset)} 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" 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} title={asset.title}
type="button" type="button"
disabled={isSelecting}
aria-busy={isSelectingThisAsset}
aria-label={`Select asset: ${asset.title}`}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -296,8 +342,15 @@ export function AssetBrowserPanel({
</Badge> </Badge>
</div> </div>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" /> <div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
{isSelectingThisAsset ? (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 bg-black/55 text-[11px] text-white">
<Loader2 className="h-4 w-4 animate-spin" />
Applying...
</div>
) : null}
</button> </button>
))} );
})}
</div> </div>
</> </>
@@ -306,7 +359,7 @@ export function AssetBrowserPanel({
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3"> <div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
{results.length > 0 ? ( {results.length > 0 ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2" aria-live="polite">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -363,10 +416,14 @@ export function AssetBrowserPanel({
handleNextPage, handleNextPage,
handlePreviousPage, handlePreviousPage,
handleSelect, handleSelect,
debouncedTerm,
isLoading, isLoading,
isSelecting,
onClose, onClose,
page, page,
results, results,
runSearch,
selectingAssetKey,
term, term,
totalPages, totalPages,
], ],

View File

@@ -410,9 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (change.type !== "dimensions") continue; if (change.type !== "dimensions") continue;
if (change.resizing !== false || !change.dimensions) continue; if (change.resizing !== false || !change.dimensions) continue;
const resizedNode = nextNodes.find((node) => node.id === change.id);
if (resizedNode?.type !== "frame") continue;
void resizeNode({ void resizeNode({
nodeId: change.id as Id<"nodes">, nodeId: change.id as Id<"nodes">,
width: change.dimensions.width, width: change.dimensions.width,

View File

@@ -2,7 +2,6 @@
import { import {
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useState, useState,
type MouseEvent, type MouseEvent,
@@ -42,7 +41,8 @@ export type AssetNodeType = Node<AssetNodeData, "asset">;
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) { export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [handleTop, setHandleTop] = useState<number | undefined>(undefined); const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({ const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
term: "", term: "",
assetType: "photo", assetType: "photo",
@@ -51,19 +51,25 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
totalPages: 1, totalPages: 1,
}); });
const resizeNode = useMutation(api.nodes.resize); 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 hasAsset = typeof data.assetId === "number";
const previewUrl = data.url ?? data.previewUrl; const previewUrl = data.url ?? data.previewUrl;
const isPreviewLoading = Boolean(
previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl,
);
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
const aspectRatio = resolveMediaAspectRatio( const aspectRatio = resolveMediaAspectRatio(
data.intrinsicWidth, data.intrinsicWidth,
data.intrinsicHeight, data.intrinsicHeight,
data.orientation, data.orientation,
); );
const hasAutoSizedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!hasAsset) return; if (!hasAsset) return;
if (hasAutoSizedRef.current) return;
hasAutoSizedRef.current = true;
const targetSize = computeMediaNodeSize("asset", { const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: data.intrinsicWidth, intrinsicWidth: data.intrinsicWidth,
@@ -91,39 +97,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
width, 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>) => { const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation(); event.stopPropagation();
}; };
@@ -140,10 +113,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
type="target" type="target"
position={Position.Left} position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!" 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="w-full">
<div className="flex items-center justify-between border-b px-3 py-2"> <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"> <span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Asset Asset
@@ -162,16 +134,36 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
{hasAsset && previewUrl ? ( {hasAsset && previewUrl ? (
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<div <div
ref={mediaRef}
className="relative overflow-hidden bg-muted/30" className="relative overflow-hidden bg-muted/30"
style={{ aspectRatio }} style={{ aspectRatio }}
> >
{isPreviewLoading ? (
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
Loading preview...
</div>
) : null}
{previewLoadError ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/70 text-[11px] text-muted-foreground">
Preview unavailable
</div>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={previewUrl} src={previewUrl}
alt={data.title ?? "Asset preview"} alt={data.title ?? "Asset preview"}
className="h-full w-full object-contain" className={`h-full w-full object-contain transition-opacity ${
isPreviewLoading ? "opacity-0" : "opacity-100"
}`}
draggable={false} draggable={false}
onLoad={() => {
setLoadedPreviewUrl(previewUrl ?? null);
setFailedPreviewUrl((current) =>
current === (previewUrl ?? null) ? null : current,
);
}}
onError={() => {
setFailedPreviewUrl(previewUrl ?? null);
}}
/> />
<Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]"> <Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]">
{data.assetType ?? "asset"} {data.assetType ?? "asset"}
@@ -238,7 +230,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
type="source" type="source"
position={Position.Right} position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!" className="h-3! w-3! border-2! border-background! bg-primary!"
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -1,8 +1,37 @@
"use client"; "use client";
import type { ReactNode } from "react"; import { useCallback, useRef, type ReactNode } from "react";
import { NodeResizeControl, type ShouldResize } from "@xyflow/react";
import { NodeErrorBoundary } from "./node-error-boundary"; import { NodeErrorBoundary } from "./node-error-boundary";
interface ResizeConfig {
minWidth: number;
minHeight: number;
keepAspectRatio?: boolean;
contentAware?: boolean;
}
const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
frame: { minWidth: 200, minHeight: 150 },
group: { minWidth: 150, minHeight: 100 },
image: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
asset: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
"ai-image": { minWidth: 200, minHeight: 200 },
compare: { minWidth: 300, minHeight: 200 },
prompt: { minWidth: 240, minHeight: 200, contentAware: true },
text: { minWidth: 180, minHeight: 80, contentAware: true },
note: { minWidth: 160, minHeight: 80, contentAware: true },
};
const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50, contentAware: true };
const CORNERS = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
] as const;
interface BaseNodeWrapperProps { interface BaseNodeWrapperProps {
nodeType: string; nodeType: string;
selected?: boolean; selected?: boolean;
@@ -20,6 +49,9 @@ export default function BaseNodeWrapper({
children, children,
className = "", className = "",
}: BaseNodeWrapperProps) { }: BaseNodeWrapperProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG;
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
idle: "", idle: "",
analyzing: "border-yellow-400 animate-pulse", analyzing: "border-yellow-400 animate-pulse",
@@ -29,8 +61,35 @@ export default function BaseNodeWrapper({
error: "border-red-500", error: "border-red-500",
}; };
const shouldResize: ShouldResize = useCallback(
(event, params) => {
if (!wrapperRef.current || !config.contentAware) return true;
const contentEl = wrapperRef.current;
const paddingX =
parseFloat(getComputedStyle(contentEl).paddingLeft) +
parseFloat(getComputedStyle(contentEl).paddingRight);
const paddingY =
parseFloat(getComputedStyle(contentEl).paddingTop) +
parseFloat(getComputedStyle(contentEl).paddingBottom);
const minW = Math.max(
config.minWidth,
contentEl.scrollWidth - paddingX + paddingX * 0.5,
);
const minH = Math.max(
config.minHeight,
contentEl.scrollHeight - paddingY + paddingY * 0.5,
);
return params.width >= minW && params.height >= minH;
},
[config],
);
return ( return (
<div <div
ref={wrapperRef}
className={` className={`
rounded-xl border bg-card shadow-sm transition-shadow rounded-xl border bg-card shadow-sm transition-shadow
${selected ? "ring-2 ring-primary shadow-md" : ""} ${selected ? "ring-2 ring-primary shadow-md" : ""}
@@ -38,6 +97,61 @@ export default function BaseNodeWrapper({
${className} ${className}
`} `}
> >
{selected &&
CORNERS.map((corner) => (
<NodeResizeControl
key={corner}
position={corner}
minWidth={config.minWidth}
minHeight={config.minHeight}
keepAspectRatio={config.keepAspectRatio}
shouldResize={shouldResize}
style={{
background: "none",
border: "none",
width: 12,
height: 12,
}}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="text-primary/70"
>
<path
d={
corner === "bottom-right"
? "M11 5V11H5"
: corner === "bottom-left"
? "M1 5V11H7"
: corner === "top-right"
? "M11 7V1H5"
: "M1 7V1H7"
}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx={
corner === "bottom-right" || corner === "top-right"
? "11"
: "1"
}
cy={
corner === "bottom-right" || corner === "bottom-left"
? "11"
: "1"
}
r="1.5"
fill="currentColor"
/>
</svg>
</NodeResizeControl>
))}
<NodeErrorBoundary nodeType={nodeType}>{children}</NodeErrorBoundary> <NodeErrorBoundary nodeType={nodeType}>{children}</NodeErrorBoundary>
{status === "error" && statusMessage && ( {status === "error" && statusMessage && (
<div className="px-3 pb-2 text-xs text-red-500 truncate"> <div className="px-3 pb-2 text-xs text-red-500 truncate">

View File

@@ -63,7 +63,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
}, []); }, []);
return ( return (
<BaseNodeWrapper nodeType="compare" selected={selected} className="w-[500px] p-0"> <BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Compare</div> <div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Compare</div>
<Handle <Handle
@@ -89,7 +89,8 @@ export default function CompareNode({ data, selected }: NodeProps) {
<div <div
ref={containerRef} ref={containerRef}
className="nodrag relative h-[320px] w-[500px] select-none overflow-hidden rounded-b-xl bg-muted" className="nodrag relative w-full select-none overflow-hidden rounded-b-xl bg-muted"
style={{ height: "100%" }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
> >
@@ -122,7 +123,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
src={nodeData.leftUrl} src={nodeData.leftUrl}
alt={nodeData.leftLabel ?? "Left"} alt={nodeData.leftLabel ?? "Left"}
className="absolute inset-0 h-full w-full object-contain" className="absolute inset-0 h-full w-full object-contain"
style={{ width: "500px", maxWidth: "none" }} style={{ width: "100%", maxWidth: "none" }}
draggable={false} draggable={false}
/> />
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Handle, NodeResizer, Position, type NodeProps } from "@xyflow/react"; import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useAction, useMutation } from "convex/react"; import { useAction, useMutation } from "convex/react";
import { Download, Loader2 } from "lucide-react"; import { Download, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
@@ -78,8 +78,6 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
selected={selected} selected={selected}
className="relative h-full w-full border-2 border-dashed border-muted-foreground/40 !bg-transparent p-0 shadow-none" className="relative h-full w-full border-2 border-dashed border-muted-foreground/40 !bg-transparent p-0 shadow-none"
> >
<NodeResizer isVisible={selected} minWidth={200} minHeight={150} />
<div className="absolute -top-8 left-0 flex items-center gap-2"> <div className="absolute -top-8 left-0 flex items-center gap-2">
<input <input
value={label} value={label}

View File

@@ -4,7 +4,6 @@ import {
useState, useState,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
type ChangeEvent, type ChangeEvent,
type DragEvent, type DragEvent,
@@ -77,11 +76,9 @@ export default function ImageNode({
const updateData = useMutation(api.nodes.updateData); const updateData = useMutation(api.nodes.updateData);
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const mediaRef = useRef<HTMLDivElement | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [handleTop, setHandleTop] = useState<number | undefined>(undefined); const hasAutoSizedRef = useRef(false);
const aspectRatio = resolveMediaAspectRatio(data.width, data.height); const aspectRatio = resolveMediaAspectRatio(data.width, data.height);
@@ -90,6 +87,9 @@ export default function ImageNode({
return; return;
} }
if (hasAutoSizedRef.current) return;
hasAutoSizedRef.current = true;
const targetSize = computeMediaNodeSize("image", { const targetSize = computeMediaNodeSize("image", {
intrinsicWidth: data.width, intrinsicWidth: data.width,
intrinsicHeight: data.height, intrinsicHeight: data.height,
@@ -106,39 +106,6 @@ export default function ImageNode({
}); });
}, [data.height, data.width, height, id, resizeNode, width]); }, [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( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
if (!ALLOWED_IMAGE_TYPES.has(file.type)) { if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
@@ -274,10 +241,9 @@ export default function ImageNode({
type="target" type="target"
position={Position.Left} position={Position.Left}
className="h-3! w-3! bg-primary! border-2! border-background!" className="h-3! w-3! bg-primary! border-2! border-background!"
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
/> />
<div ref={contentRef} className="p-2"> <div className="p-2">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">🖼 Bild</div> <div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>
{data.url && ( {data.url && (
@@ -290,7 +256,7 @@ export default function ImageNode({
)} )}
</div> </div>
<div ref={mediaRef} className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}> <div className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}>
{isUploading ? ( {isUploading ? (
<div className="flex h-full w-full items-center justify-center bg-muted"> <div className="flex h-full w-full items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -349,7 +315,6 @@ export default function ImageNode({
type="source" type="source"
position={Position.Right} position={Position.Right}
className="h-3! w-3! bg-primary! border-2! border-background!" className="h-3! w-3! bg-primary! border-2! border-background!"
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
/> />
</BaseNodeWrapper> </BaseNodeWrapper>
); );

View File

@@ -53,7 +53,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
); );
return ( return (
<BaseNodeWrapper nodeType="note" selected={selected} className="w-52 p-3"> <BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}

View File

@@ -87,7 +87,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
className="!h-3 !w-3 !bg-primary !border-2 !border-background" className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/> />
<div className="w-64 p-3"> <div className="w-full p-3">
<div className="text-xs font-medium text-muted-foreground mb-1"> <div className="text-xs font-medium text-muted-foreground mb-1">
📝 Text 📝 Text
</div> </div>
@@ -97,14 +97,14 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
onChange={handleChange} onChange={handleChange}
onBlur={() => setIsEditing(false)} onBlur={() => setIsEditing(false)}
autoFocus autoFocus
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem]" className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem] overflow-hidden"
placeholder="Text eingeben…" placeholder="Text eingeben…"
rows={3} rows={3}
/> />
) : ( ) : (
<div <div
onDoubleClick={() => setIsEditing(true)} onDoubleClick={() => setIsEditing(true)}
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap" className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap overflow-wrap-break-word"
> >
{content || ( {content || (
<span className="text-muted-foreground"> <span className="text-muted-foreground">