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

@@ -61,6 +61,11 @@ export default function DashboardPage() {
);
const createCanvas = useMutation(api.canvases.create);
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
const [hasClientMounted, setHasClientMounted] = useState(false);
useEffect(() => {
setHasClientMounted(true);
}, []);
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName);
@@ -207,7 +212,12 @@ export default function DashboardPage() {
className="cursor-pointer text-muted-foreground"
type="button"
onClick={handleCreateWorkspace}
disabled={isCreatingWorkspace || isSessionPending || !session?.user}
disabled={
isCreatingWorkspace ||
!hasClientMounted ||
isSessionPending ||
!session?.user
}
>
{isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
</Button>

View File

@@ -23,6 +23,7 @@ type CreateNodeWithIntersectionInput = {
height?: number;
data?: Record<string, unknown>;
clientPosition?: FlowPoint;
zIndex?: number;
};
type CanvasPlacementContextValue = {
@@ -107,6 +108,7 @@ export function CanvasPlacementProvider({
height,
data,
clientPosition,
zIndex,
}: CreateNodeWithIntersectionInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
@@ -140,6 +142,7 @@ export function CanvasPlacementProvider({
...(data ?? {}),
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
});
if (!hitEdge) {

View File

@@ -11,7 +11,6 @@ import {
applyNodeChanges,
applyEdgeChanges,
useReactFlow,
useStoreApi,
reconnectEdge,
type Node as RFNode,
type Edge as RFEdge,
@@ -36,6 +35,7 @@ import {
convexEdgeToRF,
NODE_DEFAULTS,
NODE_HANDLE_MAP,
resolveMediaAspectRatio,
} from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
@@ -221,6 +221,58 @@ function mergeNodesPreservingLocalState(
return previousNode;
}
if (incomingNode.type === "prompt") {
const prevW = typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
const prevH = typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
const inW = typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
const inH = typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
void prevW;
void prevH;
void inW;
void inH;
}
const previousResizing =
typeof (previousNode as { resizing?: boolean }).resizing === "boolean"
? (previousNode as { resizing?: boolean }).resizing
: false;
const isMediaNode = incomingNode.type === "asset" || incomingNode.type === "image";
const shouldPreserveInteractivePosition =
isMediaNode && (Boolean(previousNode.selected) || Boolean(previousNode.dragging) || previousResizing);
const shouldPreserveInteractiveSize =
isMediaNode && (Boolean(previousNode.dragging) || previousResizing);
const previousStyleWidth = typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
const previousStyleHeight = typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
const incomingStyleWidth = typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
const incomingStyleHeight = typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
const isAssetSeedSize = previousStyleWidth === 260 && previousStyleHeight === 240;
const isImageSeedSize = previousStyleWidth === 280 && previousStyleHeight === 200;
const canApplySeedSizeCorrection =
isMediaNode &&
Boolean(previousNode.selected) &&
!previousNode.dragging &&
!previousResizing &&
((incomingNode.type === "asset" && isAssetSeedSize) ||
(incomingNode.type === "image" && isImageSeedSize)) &&
incomingStyleWidth !== null &&
incomingStyleHeight !== null &&
(incomingStyleWidth !== previousStyleWidth || incomingStyleHeight !== previousStyleHeight);
if (shouldPreserveInteractivePosition) {
const nextStyle = shouldPreserveInteractiveSize || !canApplySeedSizeCorrection
? previousNode.style
: incomingNode.style;
return {
...previousNode,
...incomingNode,
position: previousNode.position,
style: nextStyle,
selected: previousNode.selected,
dragging: previousNode.dragging,
};
}
return {
...previousNode,
...incomingNode,
@@ -232,7 +284,6 @@ function mergeNodesPreservingLocalState(
function CanvasInner({ canvasId }: CanvasInnerProps) {
const { screenToFlowPosition } = useReactFlow();
const storeApi = useStoreApi();
const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession();
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
@@ -395,9 +446,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
setEdges((prev) => {
const tempEdges = prev.filter((e) => e.className === "temp");
const mapped = convexEdges.map(convexEdgeToRF);
// #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':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run1',hypothesisId:'H1-H2',location:'canvas.tsx:edgeSyncEffect',message:'edges passed to ReactFlow',data:{edgeCount:mapped.length,edges:mapped.map(e=>({id:e.id,source:e.source,target:e.target,sourceHandle:e.sourceHandle,targetHandle:e.targetHandle,typeofTH:typeof e.targetHandle,isNullTH:e.targetHandle===null}))},timestamp:Date.now()})}).catch(()=>{});
// #endregion
return [...mapped, ...tempEdges];
});
}, [convexEdges]);
@@ -418,12 +466,107 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
setNodes((nds) => {
const nextNodes = applyNodeChanges(changes, nds);
const adjustedChanges = changes
.map((change) => {
if (change.type !== "dimensions" || !change.dimensions) {
return change;
}
for (const change of changes) {
const node = nds.find((candidate) => candidate.id === change.id);
if (!node || node.type !== "asset") {
return change;
}
const isActiveResize =
change.resizing === true || change.resizing === false;
if (!isActiveResize) {
return change;
}
const nodeData = node.data as {
intrinsicWidth?: number;
intrinsicHeight?: number;
orientation?: string;
};
const hasIntrinsicRatioInput =
typeof nodeData.intrinsicWidth === "number" &&
nodeData.intrinsicWidth > 0 &&
typeof nodeData.intrinsicHeight === "number" &&
nodeData.intrinsicHeight > 0;
if (!hasIntrinsicRatioInput) {
return change;
}
const targetRatio = resolveMediaAspectRatio(
nodeData.intrinsicWidth,
nodeData.intrinsicHeight,
nodeData.orientation,
);
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
return change;
}
const previousWidth =
typeof node.style?.width === "number"
? node.style.width
: change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number"
? node.style.height
: change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
// Axis with larger delta drives resize; the other axis is ratio-locked.
if (heightDelta > widthDelta) {
constrainedWidth = constrainedHeight * targetRatio;
} else {
constrainedHeight = constrainedWidth / targetRatio;
}
const assetChromeHeight = 88;
const assetMinPreviewHeight = 120;
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
const assetMinNodeWidth = 140;
const minWidthFromHeight = assetMinNodeHeight * targetRatio;
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromHeight);
const minimumAllowedHeight = minimumAllowedWidth / targetRatio;
const enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
const enforcedHeight = Math.max(
constrainedHeight,
minimumAllowedHeight,
assetMinNodeHeight,
);
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
})
.filter((change): change is NodeChange => change !== null);
const nextNodes = applyNodeChanges(adjustedChanges, nds);
for (const change of adjustedChanges) {
if (change.type !== "dimensions") continue;
if (change.resizing !== false || !change.dimensions) continue;
if (!change.dimensions) continue;
if (removedIds.has(change.id)) continue;
const prevNode = nds.find((node) => node.id === change.id);
const nextNode = nextNodes.find((node) => node.id === change.id);
void prevNode;
void nextNode;
if (change.resizing !== false) continue;
void resizeNode({
nodeId: change.id as Id<"nodes">,
@@ -446,55 +589,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
setEdges((eds) => applyEdgeChanges(changes, eds));
}, []);
const onFlowError = useCallback(
(code: string, message: string) => {
const onFlowError = useCallback((code: string, message: string) => {
if (process.env.NODE_ENV === "production") return;
if (code !== "015") {
console.error("[ReactFlow error]", { canvasId, code, message });
return;
}
const state = storeApi.getState() as {
nodeLookup?: Map<
string,
{
id: string;
selected?: boolean;
type?: string;
measured?: { width?: number; height?: number };
internals?: { positionAbsolute?: { x: number; y: number } };
}
>;
};
const uninitializedNodes = Array.from(state.nodeLookup?.values() ?? [])
.filter(
(node) =>
node.measured?.width === undefined ||
node.measured?.height === undefined,
)
.map((node) => ({
id: node.id,
type: node.type ?? null,
selected: Boolean(node.selected),
measuredWidth: node.measured?.width,
measuredHeight: node.measured?.height,
positionAbsolute: node.internals?.positionAbsolute ?? null,
}));
console.error("[ReactFlow error 015 diagnostics]", {
canvasId,
message,
localNodeCount: nodes.length,
localSelectedNodeIds: nodes.filter((n) => n.selected).map((n) => n.id),
isDragging: isDragging.current,
uninitializedNodeCount: uninitializedNodes.length,
uninitializedNodes,
});
},
[canvasId, nodes, storeApi],
);
}, [canvasId]);
// ─── Delete Edge on Drop ──────────────────────────────────────
const onReconnectStart = useCallback(() => {

View File

@@ -104,7 +104,8 @@ export default function ConnectionBanner() {
return showReconnected ? "reconnected" : "hidden";
}
if (!isBrowserOnline) {
// Streng `=== false`, damit kein undefined/SSR-Artefakt wie „offline“ wird.
if (isBrowserOnline === false) {
return "disconnected";
}
@@ -121,6 +122,12 @@ export default function ConnectionBanner() {
showReconnected,
]);
// WebSocket/Convex-Verbindung gibt es im Browser; SSR soll keinen Banner rendern,
// sonst weicht die Geschwister-Reihenfolge vom ersten Client-Render ab (Hydration).
if (typeof window === "undefined") {
return null;
}
if (bannerState === "hidden") {
return null;
}

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,10 +85,12 @@ export default function CompareNode({ data, selected }: NodeProps) {
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
/>
<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 w-full select-none overflow-hidden rounded-b-xl bg-muted"
style={{ height: "100%" }}
className="nodrag relative min-h-0 w-full select-none overflow-hidden rounded-b-xl bg-muted"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
@@ -170,6 +170,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
</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"
/>
)}

View File

@@ -35,9 +35,6 @@ export function convexNodeToRF(node: Doc<"nodes">): RFNode {
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
const sanitize = (h: string | undefined): string | undefined =>
h === undefined || h === "null" ? undefined : h;
// #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':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run1',hypothesisId:'H1-H3-H4',location:'canvas-utils.ts:convexEdgeToRF',message:'raw edge from convex',data:{edgeId:edge._id,sourceNodeId:edge.sourceNodeId,targetNodeId:edge.targetNodeId,rawSourceHandle:edge.sourceHandle,rawTargetHandle:edge.targetHandle,typeofSourceHandle:typeof edge.sourceHandle,typeofTargetHandle:typeof edge.targetHandle,isNullSH:edge.sourceHandle===null,isNullTH:edge.targetHandle===null,isUndefinedSH:edge.sourceHandle===undefined,isUndefinedTH:edge.targetHandle===undefined,isStringNullSH:edge.sourceHandle==='null',isStringNullTH:edge.targetHandle==='null',sanitizedSH:sanitize(edge.sourceHandle),sanitizedTH:sanitize(edge.targetHandle)},timestamp:Date.now()})}).catch(()=>{});
// #endregion
return {
id: edge._id,
source: edge.sourceNodeId,