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

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