diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index b19c928..cb490ee 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -297,7 +297,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const resizeNode = useMutation(api.nodes.resize); const batchMoveNodes = useMutation(api.nodes.batchMove); const createNode = useMutation(api.nodes.create); - const removeNode = useMutation(api.nodes.remove); + const batchRemoveNodes = useMutation(api.nodes.batchRemove); const createEdge = useMutation(api.edges.create); const removeEdge = useMutation(api.edges.remove); @@ -308,6 +308,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Drag-Lock: während des Drags kein Convex-Override const isDragging = useRef(false); + // Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen + const deletingNodeIds = useRef>(new Set()); + // Delete Edge on Drop const edgeReconnectSuccessful = useRef(true); const overlappedEdgeRef = useRef(null); @@ -379,7 +382,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (!convexNodes || isDragging.current) return; setNodes((previousNodes) => { const incomingNodes = withResolvedCompareData(convexNodes.map(convexNodeToRF), edges); - return mergeNodesPreservingLocalState(previousNodes, incomingNodes); + // Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen + const filteredIncoming = deletingNodeIds.current.size > 0 + ? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id)) + : incomingNodes; + return mergeNodesPreservingLocalState(previousNodes, filteredIncoming); }); }, [convexNodes, edges]); @@ -403,17 +410,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Node Changes (Drag, Select, Remove) ───────────────────── const onNodesChange = useCallback( (changes: NodeChange[]) => { + const removedIds = new Set(); + for (const c of changes) { + if (c.type === "remove") { + removedIds.add(c.id); + } + } + setNodes((nds) => { const nextNodes = applyNodeChanges(changes, nds); for (const change of changes) { if (change.type !== "dimensions") continue; if (change.resizing !== false || !change.dimensions) continue; + if (removedIds.has(change.id)) continue; void resizeNode({ nodeId: change.id as Id<"nodes">, width: change.dimensions.width, height: change.dimensions.height, + }).catch((error: unknown) => { + if (process.env.NODE_ENV !== "production") { + console.warn("[Canvas] resizeNode failed", error); + } }); } @@ -723,8 +742,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Node löschen → Convex ──────────────────────────────────── const onNodesDelete = useCallback( - async (deletedNodes: RFNode[]) => { + (deletedNodes: RFNode[]) => { const count = deletedNodes.length; + if (count === 0) return; + + // Optimistic: Node-IDs sofort als "wird gelöscht" markieren + const idsToDelete = deletedNodes.map((n) => n.id); + for (const id of idsToDelete) { + deletingNodeIds.current.add(id); + } + + // Auto-Reconnect: Für jeden gelöschten Node eingehende und ausgehende Edges verbinden + const edgePromises: Promise[] = []; for (const node of deletedNodes) { const incomingEdges = edges.filter((e) => e.target === node.id); const outgoingEdges = edges.filter((e) => e.source === node.id); @@ -732,25 +761,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (incomingEdges.length > 0 && outgoingEdges.length > 0) { for (const incoming of incomingEdges) { for (const outgoing of outgoingEdges) { - await createEdge({ - canvasId, - sourceNodeId: incoming.source as Id<"nodes">, - targetNodeId: outgoing.target as Id<"nodes">, - sourceHandle: incoming.sourceHandle ?? undefined, - targetHandle: outgoing.targetHandle ?? undefined, - }); + edgePromises.push( + createEdge({ + canvasId, + sourceNodeId: incoming.source as Id<"nodes">, + targetNodeId: outgoing.target as Id<"nodes">, + sourceHandle: incoming.sourceHandle ?? undefined, + targetHandle: outgoing.targetHandle ?? undefined, + }), + ); } } } - - removeNode({ nodeId: node.id as Id<"nodes"> }); } + + // Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen + void Promise.all([ + batchRemoveNodes({ + nodeIds: idsToDelete as Id<"nodes">[], + }), + ...edgePromises, + ]) + .then(() => { + for (const id of idsToDelete) { + deletingNodeIds.current.delete(id); + } + }) + .catch((error: unknown) => { + console.error("[Canvas] batch remove failed", error); + // Bei Fehler: deletingNodeIds aufräumen, damit Nodes wieder erscheinen + for (const id of idsToDelete) { + deletingNodeIds.current.delete(id); + } + }); + if (count > 0) { const { title } = msg.canvas.nodesRemoved(count); toast.info(title); } }, - [edges, removeNode, createEdge, canvasId], + [edges, batchRemoveNodes, createEdge, canvasId], ); // ─── Edge löschen → Convex ──────────────────────────────────── diff --git a/components/canvas/nodes/asset-node.tsx b/components/canvas/nodes/asset-node.tsx index 72d959e..d94cd1a 100644 --- a/components/canvas/nodes/asset-node.tsx +++ b/components/canvas/nodes/asset-node.tsx @@ -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, resolveMediaAspectRatio } from "@/lib/canvas-utils"; +import { computeMediaNodeSize } from "@/lib/canvas-utils"; type AssetNodeData = { assetId?: number; @@ -58,13 +58,14 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl, ); const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl); - const aspectRatio = resolveMediaAspectRatio( - data.intrinsicWidth, - data.intrinsicHeight, - data.orientation, - ); const hasAutoSizedRef = useRef(false); + const rootRef = useRef(null); + const headerRef = useRef(null); + const previewRef = useRef(null); + const imageRef = useRef(null); + const footerRef = useRef(null); + const lastMetricsRef = useRef(""); useEffect(() => { if (!hasAsset) return; @@ -101,6 +102,56 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro event.stopPropagation(); }; + 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 ( -
-
+
+
Asset @@ -131,12 +189,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
- {hasAsset && previewUrl ? ( -
-
+ {showPreview ? ( + <> +
{isPreviewLoading ? (
Loading preview... @@ -149,9 +204,10 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro ) : null} {/* eslint-disable-next-line @next/next/no-img-element */} {data.title -
+

{data.title ?? "Untitled"}

@@ -200,9 +256,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro ) : null}
-
+ ) : ( -
+
diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index a4e86b2..6a66290 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -1,29 +1,28 @@ "use client"; -import { useCallback, useRef, type ReactNode } from "react"; -import { NodeResizeControl, type ShouldResize } from "@xyflow/react"; +import type { ReactNode } from "react"; +import { NodeResizeControl } from "@xyflow/react"; import { NodeErrorBoundary } from "./node-error-boundary"; interface ResizeConfig { minWidth: number; minHeight: number; keepAspectRatio?: boolean; - contentAware?: boolean; } const RESIZE_CONFIGS: Record = { frame: { minWidth: 200, minHeight: 150 }, group: { minWidth: 150, minHeight: 100 }, - image: { minWidth: 100, minHeight: 80, keepAspectRatio: true }, - asset: { minWidth: 100, minHeight: 80, keepAspectRatio: true }, + image: { minWidth: 140, minHeight: 120, keepAspectRatio: true }, + asset: { minWidth: 140, minHeight: 120, 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 }, + prompt: { minWidth: 260, minHeight: 200 }, + text: { minWidth: 220, minHeight: 90 }, + note: { minWidth: 200, minHeight: 90 }, }; -const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50, contentAware: true }; +const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50 }; const CORNERS = [ "top-left", @@ -49,7 +48,6 @@ export default function BaseNodeWrapper({ children, className = "", }: BaseNodeWrapperProps) { - const wrapperRef = useRef(null); const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG; const statusStyles: Record = { @@ -61,37 +59,10 @@ export default function BaseNodeWrapper({ 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 (
(null); + const headerRef = useRef(null); + const previewRef = useRef(null); + const imageRef = useRef(null); + const footerRef = useRef(null); + const lastMetricsRef = useRef(""); useEffect(() => { if (typeof data.width !== "number" || typeof data.height !== "number") { @@ -230,6 +233,57 @@ export default function ImageNode({ fileInputRef.current?.click(); }, []); + 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 ( -
-
+
+
🖼️ Bild
{data.url && (