mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat: improve canvas connection interactions
This commit is contained in:
@@ -64,6 +64,11 @@ type PendingConnectionCreate = {
|
||||
position: Position;
|
||||
};
|
||||
|
||||
type ConnectionDropTarget = {
|
||||
nodeId: string | null;
|
||||
isNearNode: boolean;
|
||||
};
|
||||
|
||||
type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
chatSessions: CanvasAssistantSession[];
|
||||
activeChatId: string | null;
|
||||
@@ -73,6 +78,8 @@ type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
|
||||
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 ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} /> : null}
|
||||
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} target={connectionTargetNodeId ? nodeById.get(connectionTargetNodeId) : undefined} /> : null}
|
||||
</svg>
|
||||
|
||||
{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<CanvasNodeDat
|
||||
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
||||
}
|
||||
|
||||
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
||||
return {
|
||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||
y: node.position.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
|
||||
const first = nodes.find((node) => node.id === firstNodeId);
|
||||
const second = nodes.find((node) => node.id === secondNodeId);
|
||||
|
||||
Reference in New Issue
Block a user