From 824939307c5f5cdc68f56718060ff8be6a9eb83f Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 26 Mar 2026 10:57:10 +0100 Subject: [PATCH] feat: enhance canvas functionality with proximity edge handling and improved authentication logging - Introduced temporary edge styling for proximity connections in the canvas. - Updated edge synchronization logic to filter out temporary edges during data processing. - Enhanced authentication logging to provide detailed user identity information when authentication fails. - Added a new utility function for sanitizing edge handle IDs to ensure compatibility with React Flow. - Implemented a node handle mapping for proximity connections to streamline edge management. --- app/globals.css | 14 +- components/canvas/canvas.tsx | 349 +++++++++++++++++++++++++++++++++-- convex/auth.config.ts | 19 +- convex/helpers.ts | 7 +- lib/canvas-utils.ts | 27 ++- 5 files changed, 398 insertions(+), 18 deletions(-) diff --git a/app/globals.css b/app/globals.css index ed6143e..5a7f3ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -149,4 +149,16 @@ .react-flow__handle { z-index: 50; } -} \ No newline at end of file + + /* Proximity-Vorschaukante (temp) */ + .react-flow__edge.temp { + opacity: 0.9; + } + + .react-flow__edge.temp .react-flow__edge-path { + stroke: hsl(var(--foreground) / 0.3); + stroke-width: 2.5; + stroke-dasharray: 8 6; + stroke-linecap: round; + } +} diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 31ab457..b5d687b 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -11,6 +11,9 @@ import { applyNodeChanges, applyEdgeChanges, useReactFlow, + useStoreApi, + useNodesInitialized, + reconnectEdge, type Node as RFNode, type Edge as RFEdge, type NodeChange, @@ -23,9 +26,10 @@ import "@xyflow/react/dist/style.css"; import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; +import { authClient } from "@/lib/auth-client"; import { nodeTypes } from "./node-types"; -import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils"; +import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import CanvasToolbar from "@/components/canvas/canvas-toolbar"; interface CanvasInnerProps { @@ -33,10 +37,13 @@ interface CanvasInnerProps { } function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { - return nodes.map((node) => { + const persistedEdges = edges.filter((edge) => edge.className !== "temp"); + let hasNodeUpdates = false; + + const nextNodes = nodes.map((node) => { if (node.type !== "compare") return node; - const incoming = edges.filter((edge) => edge.target === node.id); + const incoming = persistedEdges.filter((edge) => edge.target === node.id); let leftUrl: string | undefined; let rightUrl: string | undefined; let leftLabel: string | undefined; @@ -73,11 +80,15 @@ function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { return node; } + hasNodeUpdates = true; + return { ...node, data: { ...node.data, leftUrl, rightUrl, leftLabel, rightLabel }, }; }); + + return hasNodeUpdates ? nextNodes : nodes; } function getMiniMapNodeColor(node: RFNode): string { @@ -88,11 +99,20 @@ function getMiniMapNodeStrokeColor(node: RFNode): string { return node.type === "frame" ? "transparent" : "#4f46e5"; } +const MIN_DISTANCE = 150; + function CanvasInner({ canvasId }: CanvasInnerProps) { - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, getInternalNode } = useReactFlow(); + const store = useStoreApi(); + const nodesInitialized = useNodesInitialized(); const { resolvedTheme } = useTheme(); + const { data: session, isPending: isSessionPending } = authClient.useSession(); const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); const shouldSkipCanvasQueries = isAuthLoading || !isAuthenticated; + const convexAuthUserProbe = useQuery( + api.auth.safeGetAuthUser, + isAuthLoading ? "skip" : {}, + ); useEffect(() => { if (process.env.NODE_ENV === "production") return; @@ -101,6 +121,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } }, [canvasId, isAuthLoading, isAuthenticated]); + useEffect(() => { + if (process.env.NODE_ENV === "production") return; + if (isAuthLoading || isSessionPending) return; + + console.info("[Canvas auth state]", { + canvasId, + convex: { + isAuthenticated, + shouldSkipCanvasQueries, + probeUserId: convexAuthUserProbe?.userId ?? null, + probeRecordId: convexAuthUserProbe?._id ?? null, + }, + session: { + hasUser: Boolean(session?.user), + email: session?.user?.email ?? null, + }, + }); + }, [ + canvasId, + convexAuthUserProbe?._id, + convexAuthUserProbe?.userId, + isAuthLoading, + isAuthenticated, + isSessionPending, + session?.user, + shouldSkipCanvasQueries, + ]); + // ─── Convex Realtime Queries ─────────────────────────────────── const convexNodes = useQuery( api.nodes.list, @@ -131,6 +179,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Drag-Lock: während des Drags kein Convex-Override const isDragging = useRef(false); + // Delete Edge on Drop + const edgeReconnectSuccessful = useRef(true); + const uninitializedDragNodeIds = useRef>(new Set()); + // ─── Convex → Lokaler State Sync ────────────────────────────── useEffect(() => { if (!convexNodes || isDragging.current) return; @@ -141,10 +193,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { useEffect(() => { if (!convexEdges) return; // eslint-disable-next-line react-hooks/set-state-in-effect - setEdges(convexEdges.map(convexEdgeToRF)); + 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]); useEffect(() => { + if (isDragging.current) return; // eslint-disable-next-line react-hooks/set-state-in-effect setNodes((nds) => withResolvedCompareData(nds, edges)); }, [edges]); @@ -179,6 +239,192 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setEdges((eds) => applyEdgeChanges(changes, eds)); }, []); + // ─── Delete Edge on Drop ────────────────────────────────────── + const onReconnectStart = useCallback(() => { + edgeReconnectSuccessful.current = false; + }, []); + + const onReconnect = useCallback( + (oldEdge: RFEdge, newConnection: Connection) => { + edgeReconnectSuccessful.current = true; + setEdges((els) => reconnectEdge(oldEdge, newConnection, els)); + }, + [], + ); + + const onReconnectEnd = useCallback( + (_: MouseEvent | TouchEvent, edge: RFEdge) => { + if (!edgeReconnectSuccessful.current) { + setEdges((eds) => eds.filter((e) => e.id !== edge.id)); + removeEdge({ edgeId: edge.id as Id<"edges"> }); + } + edgeReconnectSuccessful.current = true; + }, + [removeEdge], + ); + + // ─── Proximity Connect ──────────────────────────────────────── + const getClosestEdge = useCallback( + (node: RFNode) => { + if (!nodesInitialized) { + if (!uninitializedDragNodeIds.current.has(node.id)) { + uninitializedDragNodeIds.current.add(node.id); + console.warn("[Canvas debug] proximity skipped: nodes not initialized", { + canvasId, + nodeId: node.id, + nodeType: node.type, + }); + } + return null; + } + + const { nodeLookup } = store.getState(); + const internalNode = getInternalNode(node.id); + if (!internalNode) { + if (!uninitializedDragNodeIds.current.has(node.id)) { + uninitializedDragNodeIds.current.add(node.id); + console.warn("[Canvas debug] proximity skipped: missing internal node", { + canvasId, + nodeId: node.id, + nodeType: node.type, + nodeLookupSize: nodeLookup.size, + }); + } + return null; + } + + const getNodeSize = (n: { + measured?: { width?: number; height?: number }; + width?: number; + height?: number; + internals?: { userNode?: { width?: number; height?: number } }; + }) => { + const width = + n.measured?.width ?? n.width ?? n.internals?.userNode?.width ?? 0; + const height = + n.measured?.height ?? n.height ?? n.internals?.userNode?.height ?? 0; + return { width, height }; + }; + + const rectDistance = ( + a: { x: number; y: number; width: number; height: number }, + b: { x: number; y: number; width: number; height: number }, + ) => { + const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0); + const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0); + return Math.sqrt(dx * dx + dy * dy); + }; + + const thisSize = getNodeSize(internalNode); + const thisRect = { + x: internalNode.internals.positionAbsolute.x, + y: internalNode.internals.positionAbsolute.y, + width: thisSize.width, + height: thisSize.height, + }; + + let minDist = Number.MAX_VALUE; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let closestN: any = null; + + for (const n of nodeLookup.values()) { + if (n.id !== internalNode.id) { + const nSize = getNodeSize(n); + const nRect = { + x: n.internals.positionAbsolute.x, + y: n.internals.positionAbsolute.y, + width: nSize.width, + height: nSize.height, + }; + const d = rectDistance(thisRect, nRect); + if (d < minDist) { + minDist = d; + closestN = n; + } + } + } + + if (!closestN || minDist >= MIN_DISTANCE) { + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas proximity debug] skipped: distance", { + canvasId, + nodeId: node.id, + nodeType: node.type, + closestNodeId: closestN?.id ?? null, + closestNodeType: closestN?.type ?? null, + minDist, + minDistanceThreshold: MIN_DISTANCE, + }); + } + return null; + } + + const closeNodeIsSource = + closestN.internals.positionAbsolute.x < + internalNode.internals.positionAbsolute.x; + + const sourceNode = closeNodeIsSource ? closestN : internalNode; + const targetNode = closeNodeIsSource ? internalNode : closestN; + + const srcHandles = NODE_HANDLE_MAP[sourceNode.type ?? ""] ?? {}; + const tgtHandles = NODE_HANDLE_MAP[targetNode.type ?? ""] ?? {}; + + if (!("source" in srcHandles) || !("target" in tgtHandles)) { + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas proximity debug] skipped: handle map", { + canvasId, + nodeId: node.id, + nodeType: node.type, + sourceNodeId: sourceNode.id, + sourceType: sourceNode.type, + targetNodeId: targetNode.id, + targetType: targetNode.type, + sourceHandles: srcHandles, + targetHandles: tgtHandles, + minDist, + }); + } + return null; + } + + // #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:'run3',hypothesisId:'H2-fix',location:'canvas.tsx:getClosestEdge',message:'proximity match with handles',data:{sourceId:sourceNode.id,sourceType:sourceNode.type,targetId:targetNode.id,targetType:targetNode.type,sourceHandle:srcHandles.source,targetHandle:tgtHandles.target,minDist},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + + return { + id: closeNodeIsSource + ? `${closestN.id}-${node.id}` + : `${node.id}-${closestN.id}`, + source: sourceNode.id, + target: targetNode.id, + sourceHandle: srcHandles.source, + targetHandle: tgtHandles.target, + }; + }, + [store, getInternalNode, nodesInitialized, canvasId], + ); + + const onNodeDrag = useCallback( + (_: React.MouseEvent, node: RFNode) => { + const closeEdge = getClosestEdge(node); + + setEdges((es) => { + const nextEdges = es.filter((e) => e.className !== "temp"); + if ( + closeEdge && + !nextEdges.find( + (ne) => + ne.source === closeEdge.source && ne.target === closeEdge.target, + ) + ) { + nextEdges.push({ ...closeEdge, className: "temp" }); + } + return nextEdges; + }); + }, + [getClosestEdge], + ); + // ─── Drag Start → Lock ──────────────────────────────────────── const onNodeDragStart = useCallback(() => { isDragging.current = true; @@ -187,26 +433,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Drag Stop → Commit zu Convex ───────────────────────────── const onNodeDragStop = useCallback( (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { - isDragging.current = false; + // Proximity Connect: closeEdge bestimmen bevor isDragging zurückgesetzt wird + const closeEdge = getClosestEdge(node); - // Wenn mehrere Nodes gleichzeitig gedraggt wurden → batchMove + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas proximity debug] drag stop decision", { + canvasId, + nodeId: node.id, + nodeType: node.type, + draggedCount: draggedNodes.length, + closeEdge, + }); + } + + // Proximity Connect: temporäre Edge entfernen, ggf. echte Edge anlegen + setEdges((es) => { + const nextEdges = es.filter((e) => e.className !== "temp"); + if ( + closeEdge && + !nextEdges.find( + (ne) => + ne.source === closeEdge.source && ne.target === closeEdge.target, + ) + ) { + void createEdge({ + canvasId, + sourceNodeId: closeEdge.source as Id<"nodes">, + targetNodeId: closeEdge.target as Id<"nodes">, + sourceHandle: closeEdge.sourceHandle ?? undefined, + targetHandle: closeEdge.targetHandle ?? undefined, + }) + .then((edgeId) => { + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas proximity debug] edge created", { + canvasId, + edgeId, + sourceNodeId: closeEdge.source, + targetNodeId: closeEdge.target, + sourceHandle: closeEdge.sourceHandle ?? null, + targetHandle: closeEdge.targetHandle ?? null, + }); + } + }) + .catch((error) => { + console.error("[Canvas proximity debug] edge create failed", { + canvasId, + sourceNodeId: closeEdge.source, + targetNodeId: closeEdge.target, + sourceHandle: closeEdge.sourceHandle ?? null, + targetHandle: closeEdge.targetHandle ?? null, + error: String(error), + }); + }); + } + return nextEdges; + }); + + // isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich if (draggedNodes.length > 1) { - batchMoveNodes({ + void batchMoveNodes({ moves: draggedNodes.map((n) => ({ nodeId: n.id as Id<"nodes">, positionX: n.position.x, positionY: n.position.y, })), + }).then(() => { + isDragging.current = false; }); } else { - moveNode({ + void moveNode({ nodeId: node.id as Id<"nodes">, positionX: node.position.x, positionY: node.position.y, + }).then(() => { + isDragging.current = false; }); } }, - [moveNode, batchMoveNodes], + [moveNode, batchMoveNodes, getClosestEdge, createEdge, canvasId], ); // ─── Neue Verbindung → Convex Edge ──────────────────────────── @@ -227,12 +531,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Node löschen → Convex ──────────────────────────────────── const onNodesDelete = useCallback( - (deletedNodes: RFNode[]) => { + async (deletedNodes: RFNode[]) => { for (const node of deletedNodes) { + const incomingEdges = edges.filter((e) => e.target === node.id); + const outgoingEdges = edges.filter((e) => e.source === node.id); + + 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, + }); + } + } + } + removeNode({ nodeId: node.id as Id<"nodes"> }); } }, - [removeNode], + [edges, removeNode, createEdge, canvasId], ); // ─── Edge löschen → Convex ──────────────────────────────────── @@ -306,9 +627,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { nodeTypes={nodeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onNodeDrag={onNodeDrag} onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} onConnect={onConnect} + onReconnect={onReconnect} + onReconnectStart={onReconnectStart} + onReconnectEnd={onReconnectEnd} onNodesDelete={onNodesDelete} onEdgesDelete={onEdgesDelete} onDragOver={onDragOver} diff --git a/convex/auth.config.ts b/convex/auth.config.ts index a2851b4..4ae7f06 100644 --- a/convex/auth.config.ts +++ b/convex/auth.config.ts @@ -1,6 +1,21 @@ -import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config"; import type { AuthConfig } from "convex/server"; +const issuer = (process.env.CONVEX_SITE_URL ?? process.env.SITE_URL ?? "").replace(/\/$/, ""); + +if (!issuer) { + throw new Error( + "Missing CONVEX_SITE_URL (or SITE_URL) for Better Auth JWT issuer in convex/auth.config.ts", + ); +} + export default { - providers: [getAuthConfigProvider()], + providers: [ + { + type: "customJwt", + issuer, + applicationID: "convex", + algorithm: "RS256", + jwks: `${issuer}/api/auth/convex/jwks`, + }, + ], } satisfies AuthConfig; diff --git a/convex/helpers.ts b/convex/helpers.ts index a0f96ca..ad4c1c0 100644 --- a/convex/helpers.ts +++ b/convex/helpers.ts @@ -17,7 +17,12 @@ export async function requireAuth( ): Promise { const user = await authComponent.safeGetAuthUser(ctx); if (!user) { - console.error("[requireAuth] safeGetAuthUser returned null"); + const identity = await ctx.auth.getUserIdentity(); + console.error("[requireAuth] safeGetAuthUser returned null", { + hasIdentity: Boolean(identity), + identityIssuer: identity?.issuer ?? null, + identitySubject: identity?.subject ?? null, + }); throw new Error("Unauthenticated"); } const userId = user.userId ?? String(user._id); diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 71235cf..ff2a91d 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -29,17 +29,40 @@ export function convexNodeToRF(node: Doc<"nodes">): RFNode { /** * Convex Edge → React Flow Edge + * Sanitize handles: null/undefined/"null" → undefined (ReactFlow erwartet string | null | undefined, aber nie den String "null") */ 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, target: edge.targetNodeId, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, + sourceHandle: sanitize(edge.sourceHandle), + targetHandle: sanitize(edge.targetHandle), }; } +/** + * Handle-IDs pro Node-Typ für Proximity Connect. + * `undefined` = default handle (kein explizites `id`-Attribut auf dem Handle). + * Fehlendes Feld = Node hat keinen Handle dieses Typs. + */ +export const NODE_HANDLE_MAP: Record< + string, + { source?: string; target?: string } +> = { + image: { source: undefined }, + text: { source: undefined }, + prompt: { source: "prompt-out", target: "image-in" }, + "ai-image": { source: "image-out", target: "prompt-in" }, + frame: { source: "frame-out", target: "frame-in" }, + compare: { target: "left" }, +}; + /** * Default-Größen für neue Nodes je nach Typ. */