feat: enhance dashboard and canvas components with improved state management and resizing logic

- 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.
This commit is contained in:
Matthias
2026-03-27 23:17:10 +01:00
parent e96c9c611c
commit 4e84e7f76f
11 changed files with 357 additions and 215 deletions

View File

@@ -166,7 +166,7 @@ export default function AiImageNode({
<BaseNodeWrapper
nodeType="ai-image"
selected={selected}
className="flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden"
className="flex h-full w-full min-h-0 min-w-0 flex-col"
>
<Handle
type="target"

View File

@@ -18,7 +18,7 @@ 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 } from "@/lib/canvas-utils";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
type AssetNodeData = {
assetId?: number;
@@ -60,28 +60,40 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
const hasAutoSizedRef = useRef(false);
const rootRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const lastMetricsRef = useRef<string>("");
useEffect(() => {
if (!hasAsset) return;
if (hasAutoSizedRef.current) return;
hasAutoSizedRef.current = true;
const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: data.intrinsicWidth,
intrinsicHeight: data.intrinsicHeight,
orientation: data.orientation,
});
if (width === targetSize.width && height === targetSize.height) {
const targetAspectRatio = resolveMediaAspectRatio(
data.intrinsicWidth,
data.intrinsicHeight,
data.orientation,
);
const minimumNodeHeight = 208;
const baseNodeWidth = 260;
const targetWidth = Math.max(baseNodeWidth, Math.round(minimumNodeHeight * targetAspectRatio));
const targetHeight = Math.round(targetWidth / targetAspectRatio);
const targetSize = {
width: targetWidth,
height: targetHeight,
};
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 === 260 && currentHeight === 240;
const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize;
if (!shouldRunInitialAutoSize) {
hasAutoSizedRef.current = true;
return;
}
hasAutoSizedRef.current = true;
void resizeNode({
nodeId: id as Id<"nodes">,
width: targetSize.width,
@@ -104,61 +116,12 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
const showPreview = Boolean(hasAsset && previewUrl);
useEffect(() => {
if (!selected) return;
const rootEl = rootRef.current;
const headerEl = headerRef.current;
if (!rootEl || !headerEl) return;
const rootHeight = rootEl.getBoundingClientRect().height;
const headerHeight = headerEl.getBoundingClientRect().height;
const previewHeight = previewRef.current?.getBoundingClientRect().height ?? null;
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
const imageEl = imageRef.current;
const rootStyles = window.getComputedStyle(rootEl);
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
const rows = rootStyles.gridTemplateRows;
const imageRect = imageEl?.getBoundingClientRect();
const previewRect = previewRef.current?.getBoundingClientRect();
const naturalRatio =
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
? imageEl.naturalWidth / imageEl.naturalHeight
: null;
const previewRatio =
previewRect && previewRect.width > 0 && previewRect.height > 0
? previewRect.width / previewRect.height
: null;
let expectedContainWidth: number | null = null;
let expectedContainHeight: number | null = null;
if (previewRect && naturalRatio) {
const fitByWidthHeight = previewRect.width / naturalRatio;
if (fitByWidthHeight <= previewRect.height) {
expectedContainWidth = previewRect.width;
expectedContainHeight = fitByWidthHeight;
} else {
expectedContainHeight = previewRect.height;
expectedContainWidth = previewRect.height * naturalRatio;
}
}
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight ?? -1)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showPreview}`;
if (lastMetricsRef.current === signature) {
return;
}
lastMetricsRef.current = signature;
// #region agent log
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H13-H14',location:'asset-node.tsx:metricsEffect',message:'asset contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect?.width ?? null,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showPreview},timestamp:Date.now()})}).catch(()=>{});
// #endregion
}, [height, id, selected, showPreview, width]);
return (
<BaseNodeWrapper
nodeType="asset"
selected={selected}
status={data._status}
statusMessage={data._statusMessage}
className="overflow-hidden"
>
<Handle
type="target"
@@ -167,14 +130,13 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
/>
<div
ref={rootRef}
className={`grid h-full min-h-0 w-full ${
showPreview
? "grid-rows-[auto_minmax(0,1fr)_auto]"
: "grid-rows-[auto_minmax(0,1fr)]"
}`}
>
<div ref={headerRef} 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">
Asset
</span>
@@ -191,7 +153,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
{showPreview ? (
<>
<div ref={previewRef} className="relative min-h-0 overflow-hidden bg-muted/30">
<div className="relative min-h-0 overflow-hidden bg-muted/30">
{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...
@@ -204,10 +166,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={previewUrl}
alt={data.title ?? "Asset preview"}
className={`h-full w-full object-cover object-center transition-opacity ${
className={`h-full w-full object-cover object-right transition-opacity ${
isPreviewLoading ? "opacity-0" : "opacity-100"
}`}
draggable={false}
@@ -234,7 +195,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
) : null}
</div>
<div ref={footerRef} className="flex flex-col gap-1 px-3 py-2">
<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>

View File

@@ -1,7 +1,9 @@
"use client";
import type { ReactNode } from "react";
import { NodeResizeControl } from "@xyflow/react";
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
import { Trash2, Copy } from "lucide-react";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { NodeErrorBoundary } from "./node-error-boundary";
interface ResizeConfig {
@@ -14,10 +16,10 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
frame: { minWidth: 200, minHeight: 150 },
group: { minWidth: 150, minHeight: 100 },
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
asset: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
"ai-image": { minWidth: 200, minHeight: 200 },
compare: { minWidth: 300, minHeight: 200 },
prompt: { minWidth: 260, minHeight: 200 },
prompt: { minWidth: 260, minHeight: 220 },
text: { minWidth: 220, minHeight: 90 },
note: { minWidth: 200, minHeight: 90 },
};
@@ -31,6 +33,117 @@ const CORNERS = [
"bottom-right",
] as const;
/** Internal fields to strip when duplicating a node */
const INTERNAL_FIELDS = new Set([
"_status",
"_statusMessage",
"retryCount",
"url",
"canvasId",
]);
function NodeToolbarActions() {
const nodeId = useNodeId();
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
const { createNodeWithIntersection } = useCanvasPlacement();
const handleDelete = () => {
if (!nodeId) return;
void deleteElements({ nodes: [{ id: nodeId }] });
};
const handleDuplicate = async () => {
if (!nodeId) return;
const node = getNode(nodeId);
if (!node) return;
// Strip internal/runtime fields, keep only user content
const originalData = (node.data ?? {}) as Record<string, unknown>;
const cleanedData: Record<string, unknown> = {};
for (const [key, value] of Object.entries(originalData)) {
if (!INTERNAL_FIELDS.has(key)) {
cleanedData[key] = value;
}
}
const originalPosition = node.position ?? { x: 0, y: 0 };
const width = typeof node.style?.width === "number" ? node.style.width : undefined;
const height = typeof node.style?.height === "number" ? node.style.height : undefined;
// Find the highest zIndex across all nodes to ensure the duplicate renders on top
const allNodes = getNodes();
const maxZIndex = allNodes.reduce(
(max, n) => Math.max(max, n.zIndex ?? 0),
0,
);
const createdNodeId = await createNodeWithIntersection({
type: node.type ?? "text",
position: {
x: originalPosition.x + 50,
y: originalPosition.y + 50,
},
width,
height,
data: cleanedData,
zIndex: maxZIndex + 1,
});
const selectCreatedNode = (attempt = 0) => {
const createdNode = getNode(createdNodeId);
if (!createdNode) {
if (attempt < 10) {
requestAnimationFrame(() => selectCreatedNode(attempt + 1));
}
return;
}
setNodes((nodes) =>
nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, selected: false };
}
if (n.id === createdNodeId) {
return { ...n, selected: true };
}
return n;
}),
);
};
selectCreatedNode();
};
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
e.stopPropagation();
};
return (
<NodeToolbar position={Position.Top} offset={8}>
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
<button
type="button"
onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
onPointerDown={stopPropagation}
title="Duplicate"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Copy size={14} />
</button>
<button
type="button"
onClick={(e) => { stopPropagation(e); handleDelete(); }}
onPointerDown={stopPropagation}
title="Delete"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={14} />
</button>
</div>
</NodeToolbar>
);
}
interface BaseNodeWrapperProps {
nodeType: string;
selected?: boolean;
@@ -128,6 +241,7 @@ export default function BaseNodeWrapper({
{statusMessage}
</div>
)}
<NodeToolbarActions />
</div>
);
}

View File

@@ -64,8 +64,6 @@ export default function CompareNode({ data, selected }: NodeProps) {
return (
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Compare</div>
<Handle
type="target"
position={Position.Left}
@@ -87,13 +85,15 @@ export default function CompareNode({ data, selected }: NodeProps) {
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
/>
<div
ref={containerRef}
className="nodrag relative w-full select-none overflow-hidden rounded-b-xl bg-muted"
style={{ height: "100%" }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div className="grid h-full min-h-0 w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Compare</div>
<div
ref={containerRef}
className="nodrag relative min-h-0 w-full select-none overflow-hidden rounded-b-xl bg-muted"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{!hasLeft && !hasRight && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" />
@@ -169,6 +169,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
</span>
</div>
)}
</div>
</div>
</BaseNodeWrapper>
);

View File

@@ -78,12 +78,6 @@ export default function ImageNode({
const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const hasAutoSizedRef = useRef(false);
const rootRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const footerRef = useRef<HTMLParagraphElement>(null);
const lastMetricsRef = useRef<string>("");
useEffect(() => {
if (typeof data.width !== "number" || typeof data.height !== "number") {
@@ -91,17 +85,27 @@ export default function ImageNode({
}
if (hasAutoSizedRef.current) return;
hasAutoSizedRef.current = true;
const targetSize = computeMediaNodeSize("image", {
intrinsicWidth: data.width,
intrinsicHeight: data.height,
});
if (width === targetSize.width && height === targetSize.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,
@@ -235,61 +239,11 @@ export default function ImageNode({
const showFilename = Boolean(data.filename && data.url);
useEffect(() => {
if (!selected) return;
const rootEl = rootRef.current;
const headerEl = headerRef.current;
const previewEl = previewRef.current;
if (!rootEl || !headerEl || !previewEl) return;
const rootHeight = rootEl.getBoundingClientRect().height;
const headerHeight = headerEl.getBoundingClientRect().height;
const previewHeight = previewEl.getBoundingClientRect().height;
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
const imageEl = imageRef.current;
const rootStyles = window.getComputedStyle(rootEl);
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
const rows = rootStyles.gridTemplateRows;
const imageRect = imageEl?.getBoundingClientRect();
const previewRect = previewEl.getBoundingClientRect();
const naturalRatio =
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
? imageEl.naturalWidth / imageEl.naturalHeight
: null;
const previewRatio =
previewRect.width > 0 && previewRect.height > 0
? previewRect.width / previewRect.height
: null;
let expectedContainWidth: number | null = null;
let expectedContainHeight: number | null = null;
if (naturalRatio) {
const fitByWidthHeight = previewRect.width / naturalRatio;
if (fitByWidthHeight <= previewRect.height) {
expectedContainWidth = previewRect.width;
expectedContainHeight = fitByWidthHeight;
} else {
expectedContainHeight = previewRect.height;
expectedContainWidth = previewRect.height * naturalRatio;
}
}
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showFilename}`;
if (lastMetricsRef.current === signature) {
return;
}
lastMetricsRef.current = signature;
// #region agent log
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H15-H16',location:'image-node.tsx:metricsEffect',message:'image contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect.width,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showFilename},timestamp:Date.now()})}).catch(()=>{});
// #endregion
}, [height, id, selected, showFilename, width]);
return (
<BaseNodeWrapper
nodeType="image"
selected={selected}
status={data._status}
className="overflow-hidden"
>
<Handle
type="target"
@@ -298,14 +252,13 @@ export default function ImageNode({
/>
<div
ref={rootRef}
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 ref={headerRef} className="flex items-center justify-between">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>
{data.url && (
<button
@@ -317,7 +270,7 @@ export default function ImageNode({
)}
</div>
<div ref={previewRef} className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
<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">
@@ -328,7 +281,6 @@ export default function ImageNode({
) : data.url ? (
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
<img
ref={imageRef}
src={data.url}
alt={data.filename ?? "Bild"}
className="h-full w-full object-cover object-center"
@@ -358,7 +310,7 @@ export default function ImageNode({
</div>
{showFilename ? (
<p ref={footerRef} className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
<p className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
) : null}
</div>

View File

@@ -297,13 +297,13 @@ export default function PromptNode({
className="!h-3 !w-3 !bg-violet-500 !border-2 !border-background"
/>
<div className="flex flex-col gap-2 p-3">
<div className="flex h-full flex-col gap-2 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Sparkles className="h-3.5 w-3.5" />
Eingabe
</div>
{inputMeta.hasTextInput ? (
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
<div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
<p className="text-[11px] font-medium text-violet-700 dark:text-violet-300">
Prompt aus verbundener Text-Node
</p>
@@ -316,8 +316,7 @@ export default function PromptNode({
value={prompt}
onChange={handlePromptChange}
placeholder="Beschreibe, was du generieren willst…"
rows={4}
className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
className="nodrag nowheel min-h-[72px] w-full flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
)}