From c7e3e0af6a015413a5f9f0e62552c3316ebf4626 Mon Sep 17 00:00:00 2001 From: stupid-h4er Date: Mon, 1 Jun 2026 15:26:24 +0800 Subject: [PATCH] feat: improve canvas connection interactions --- docs/pending-test.md | 1 + .../(user)/canvas/[id]/canvas-client-page.tsx | 103 +++++++++++++----- .../canvas/components/canvas-connections.tsx | 33 +++++- .../canvas/components/canvas-context-menu.tsx | 2 +- web/src/app/(user)/canvas/types.ts | 19 +++- 5 files changed, 117 insertions(+), 41 deletions(-) diff --git a/docs/pending-test.md b/docs/pending-test.md index 94a9502..dec93bc 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- 画布连线支持右键打开删除菜单;拖拽连线到目标卡片内部、连接点附近或卡片边缘外扩范围内会自动吸附并连接,拖到已有但不可连接的节点附近不会再误弹创建节点菜单。 - 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。 - GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 - Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 469008b..1721a4e 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -64,6 +64,11 @@ type PendingConnectionCreate = { position: Position; }; +type ConnectionDropTarget = { + nodeId: string | null; + isNearNode: boolean; +}; + type CanvasHistoryEntry = Pick & { chatSessions: CanvasAssistantSession[]; activeChatId: string | null; @@ -73,6 +78,8 @@ type CanvasHistoryEntry = Pick & { const VIDEO_NODE_MAX_WIDTH = 420; const VIDEO_NODE_MAX_HEIGHT = 420; +const CONNECTION_HANDLE_HIT_RADIUS = 40; +const CONNECTION_NODE_HIT_PADDING = 32; const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_ERROR = "error" as const; @@ -509,23 +516,35 @@ function InfiniteCanvasPage() { setConnecting(null); }, [setConnecting]); - const getConnectableNodeAtPoint = useCallback( - (clientX: number, clientY: number, current: ConnectionHandle) => { + const getConnectionDropTarget = useCallback( + (clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => { const world = screenToCanvas(clientX, clientY); - return ( - [...nodesRef.current] - .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) - .reverse() - .find( - (node) => - node.id !== current.nodeId && - Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) && - world.x >= node.position.x && - world.x <= node.position.x + node.width && - world.y >= node.position.y && - world.y <= node.position.y + node.height, - )?.id || null - ); + const scale = Math.max(viewportRef.current.k, 0.05); + const padding = CONNECTION_NODE_HIT_PADDING / scale; + const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale; + let isNearNode = false; + let best: { nodeId: string; priority: number } | null = null; + + [...nodesRef.current] + .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) + .reverse() + .forEach((node) => { + const anchor = getConnectionTargetAnchor(node, current); + const dx = world.x - anchor.x; + const dy = world.y - anchor.y; + const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; + const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height; + const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding; + + if (!hitsHandle && !hitsInside && !hitsExpanded) return; + isNearNode = true; + if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return; + + const priority = hitsInside ? 0 : hitsHandle ? 1 : 2; + if (!best || priority < best.priority) best = { nodeId: node.id, priority }; + }); + + return { nodeId: best?.nodeId || null, isNearNode }; }, [screenToCanvas], ); @@ -657,12 +676,18 @@ function InfiniteCanvasPage() { setAngleNodeId((current) => (current && allIds.has(current) ? null : current)); setPreviewNodeId((current) => (current && allIds.has(current) ? null : current)); setRunningNodeId((current) => (current && allIds.has(current) ? null : current)); - setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current)); + setContextMenu((current) => (current?.type === "node" && allIds.has(current.nodeId) ? null : current)); cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions }); }, [chatSessions, cleanupCanvasFiles, projectId], ); + const deleteConnection = useCallback((connectionId: string) => { + setConnections((prev) => prev.filter((conn) => conn.id !== connectionId)); + setSelectedConnectionId((current) => (current === connectionId ? null : current)); + setContextMenu((current) => (current?.type === "connection" && current.connectionId === connectionId ? null : current)); + }, []); + const deselectCanvas = useCallback(() => { cancelPendingConnectionCreate(); setSelectedNodeIds(new Set()); @@ -986,13 +1011,13 @@ function InfiniteCanvasPage() { } if (connectingParamsRef.current && !pendingConnectionCreateRef.current) { - const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current); - connectionTargetNodeIdRef.current = targetNodeId; - setConnectionTargetNodeId(targetNodeId); + const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, connectingParamsRef.current); + connectionTargetNodeIdRef.current = dropTarget.nodeId; + setConnectionTargetNodeId(dropTarget.nodeId); setMouseWorld(screenToCanvas(event.clientX, event.clientY)); } }, - [finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas], + [finishNodeDrag, getConnectionDropTarget, screenToCanvas], ); const handleGlobalPointerMove = useCallback( @@ -1040,9 +1065,11 @@ function InfiniteCanvasPage() { const currentConnection = connectingParamsRef.current; if (currentConnection) { - const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current; - if (targetNodeId) { - connectNodes(currentConnection, targetNodeId); + const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, currentConnection); + if (dropTarget.nodeId) { + connectNodes(currentConnection, dropTarget.nodeId); + setConnecting(null); + } else if (dropTarget.isNearNode) { setConnecting(null); } else { setMouseWorld(screenToCanvas(event.clientX, event.clientY)); @@ -1050,7 +1077,7 @@ function InfiniteCanvasPage() { } } }, - [connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting], + [connectNodes, finishNodeDrag, getConnectionDropTarget, screenToCanvas, setConnecting], ); useEffect(() => { @@ -1217,8 +1244,7 @@ function InfiniteCanvasPage() { if (selectedNodeIdsRef.current.size) { deleteNodes(new Set(selectedNodeIdsRef.current)); } else if (selectedConnectionId) { - setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId)); - setSelectedConnectionId(null); + deleteConnection(selectedConnectionId); } } @@ -1240,7 +1266,7 @@ function InfiniteCanvasPage() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]); + }, [copySelectedNodes, deleteConnection, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]); const handleConnectStart = useCallback( (event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => { @@ -2105,10 +2131,15 @@ function InfiniteCanvasPage() { setSelectedNodeIds(new Set()); setContextMenu(null); }} + onContextMenu={(event) => { + setSelectedConnectionId(connection.id); + setSelectedNodeIds(new Set()); + setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId: connection.id }); + }} /> ); })} - {connectingParams ? : null} + {connectingParams ? : null} {visibleNodes.map((node) => ( @@ -2258,11 +2289,16 @@ function InfiniteCanvasPage() { menu={contextMenu} onClose={() => setContextMenu(null)} onDuplicate={() => { + if (contextMenu.type !== "node") return; duplicateNode(contextMenu.nodeId); setContextMenu(null); }} onDelete={() => { - deleteNodes(new Set([contextMenu.nodeId])); + if (contextMenu.type === "node") { + deleteNodes(new Set([contextMenu.nodeId])); + } else { + deleteConnection(contextMenu.connectionId); + } setContextMenu(null); }} /> @@ -2631,6 +2667,13 @@ function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial node.id === firstNodeId); const second = nodes.find((node) => node.id === secondNodeId); diff --git a/web/src/app/(user)/canvas/components/canvas-connections.tsx b/web/src/app/(user)/canvas/components/canvas-connections.tsx index 601e691..499f3a6 100644 --- a/web/src/app/(user)/canvas/components/canvas-connections.tsx +++ b/web/src/app/(user)/canvas/components/canvas-connections.tsx @@ -1,8 +1,24 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; + import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types"; -export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) { +export function ConnectionPath({ + connection, + from, + to, + active, + onSelect, + onContextMenu, +}: { + connection: CanvasConnection; + from: CanvasNodeData; + to: CanvasNodeData; + active: boolean; + onSelect: () => void; + onContextMenu?: (event: ReactMouseEvent) => void; +}) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const startX = from.position.x + from.width; const startY = from.position.y + from.height / 2; @@ -25,6 +41,11 @@ export function ConnectionPath({ connection, from, to, active, onSelect }: { con event.stopPropagation(); onSelect(); }} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu?.(event); + }} /> state.theme)]; if (!node) return null; @@ -46,8 +67,12 @@ export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: Canv const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y; const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x; const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2; - const distance = Math.abs(endX - startX); - const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`; + const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX; + const snappedStartY = handle.handleType === "target" && target ? target.position.y + target.height / 2 : startY; + const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX; + const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY; + const distance = Math.abs(snappedEndX - snappedStartX); + const pathD = `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`; return ; } diff --git a/web/src/app/(user)/canvas/components/canvas-context-menu.tsx b/web/src/app/(user)/canvas/components/canvas-context-menu.tsx index 2e75635..5a1a034 100644 --- a/web/src/app/(user)/canvas/components/canvas-context-menu.tsx +++ b/web/src/app/(user)/canvas/components/canvas-context-menu.tsx @@ -27,7 +27,7 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()} > - } label="Duplicate" onClick={onDuplicate} /> + {menu.type === "node" ? } label="Duplicate" onClick={onDuplicate} /> : null} } label="Delete" onClick={onDelete} danger /> ); diff --git a/web/src/app/(user)/canvas/types.ts b/web/src/app/(user)/canvas/types.ts index 023b5f2..a4fbd04 100644 --- a/web/src/app/(user)/canvas/types.ts +++ b/web/src/app/(user)/canvas/types.ts @@ -118,9 +118,16 @@ export type SelectionBox = { initialSelectedNodeIds: string[]; }; -export type ContextMenuState = { - type: "node"; - x: number; - y: number; - nodeId: string; -}; +export type ContextMenuState = + | { + type: "node"; + x: number; + y: number; + nodeId: string; + } + | { + type: "connection"; + x: number; + y: number; + connectionId: string; + };