diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 5032216..913ba62 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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"} diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index 77be94c..e362798 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -23,6 +23,7 @@ type CreateNodeWithIntersectionInput = { height?: number; data?: Record; 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) { diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index cb490ee..6cb1b78 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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) => { - 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], - ); + const onFlowError = useCallback((code: string, message: string) => { + if (process.env.NODE_ENV === "production") return; + console.error("[ReactFlow error]", { canvasId, code, message }); + }, [canvasId]); // ─── Delete Edge on Drop ────────────────────────────────────── const onReconnectStart = useCallback(() => { diff --git a/components/canvas/connection-banner.tsx b/components/canvas/connection-banner.tsx index 2a53e14..4bb2c17 100644 --- a/components/canvas/connection-banner.tsx +++ b/components/canvas/connection-banner.tsx @@ -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; } diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index 8d0f9eb..e348b9c 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -166,7 +166,7 @@ export default function AiImageNode({ (null); - const headerRef = useRef(null); - const previewRef = useRef(null); - const imageRef = useRef(null); - const footerRef = useRef(null); - const lastMetricsRef = useRef(""); 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 (
-
+
Asset @@ -191,7 +153,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro {showPreview ? ( <> -
+
{isPreviewLoading ? (
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 */} {data.title -
+

{data.title ?? "Untitled"}

diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 6a66290..f9be380 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -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 = { 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; + const cleanedData: Record = {}; + 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 ( + +
+ + +
+
+ ); +} + interface BaseNodeWrapperProps { nodeType: string; selected?: boolean; @@ -128,6 +241,7 @@ export default function BaseNodeWrapper({ {statusMessage}
)} +
); } diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index b9c5e1c..92ab64c 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -64,8 +64,6 @@ export default function CompareNode({ data, selected }: NodeProps) { return ( -
⚖️ Compare
- -
+
+
⚖️ Compare
+ +
{!hasLeft && !hasRight && (
@@ -169,6 +169,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
)} +
); diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index da3abb8..512820e 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -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(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") { @@ -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 (
-
+
🖼️ Bild
{data.url && (