refactor(canvas): extract connection handling hook

This commit is contained in:
2026-04-03 22:59:47 +02:00
parent 47cb167bd3
commit 59658cb8be
4 changed files with 649 additions and 198 deletions

View File

@@ -20,8 +20,6 @@ import {
type Node as RFNode,
type Edge as RFEdge,
type EdgeChange,
type Connection,
type OnConnectEnd,
BackgroundVariant,
} from "@xyflow/react";
import { cn } from "@/lib/utils";
@@ -42,14 +40,7 @@ import {
} from "@/lib/canvas-node-types";
import { nodeTypes } from "./node-types";
import {
validateCanvasConnection,
validateCanvasConnectionByType,
} from "./canvas-connection-validation";
import {
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
import CanvasToolbar, {
type CanvasNavTool,
} from "@/components/canvas/canvas-toolbar";
@@ -57,7 +48,6 @@ import { CanvasAppMenu } from "@/components/canvas/canvas-app-menu";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import {
CanvasConnectionDropMenu,
type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
@@ -66,24 +56,21 @@ import {
type AssetBrowserTargetApi,
} from "@/components/canvas/asset-browser-panel";
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import {
CANVAS_MIN_ZOOM,
DEFAULT_EDGE_OPTIONS,
getConnectEndClientPoint,
getMiniMapNodeColor,
getMiniMapNodeStrokeColor,
getPendingRemovedEdgeIdsFromLocalOps,
getPendingMovePinsFromLocalOps,
isEditableKeyboardTarget,
isOptimisticNodeId,
withResolvedCompareData,
} from "./canvas-helpers";
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
import { getImageDimensions } from "./canvas-media-utils";
import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import { useCanvasConnections } from "./use-canvas-connections";
import { useCanvasScissors } from "./canvas-scissors";
import { CanvasSyncProvider } from "./canvas-sync-context";
import { useCanvasData } from "./use-canvas-data";
@@ -167,10 +154,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Future hook seam: render composition + shared local flow state ─────
const nodesRef = useRef<RFNode[]>(nodes);
nodesRef.current = nodes;
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
connectionDropMenuRef.current = connectionDropMenu;
const [scissorsMode, setScissorsMode] = useState(false);
const [scissorStrokePreview, setScissorStrokePreview] = useState<
@@ -293,18 +276,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
runRemoveEdgeMutation,
});
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
const {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
} = useCanvasConnections({
canvasId,
nodes,
edges,
nodesRef,
edgesRef,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
setEdges,
setEdgeSyncNonce,
screenToFlowPosition,
syncPendingMoveForClientRequest,
runCreateEdgeMutation,
runRemoveEdgeMutation,
validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast,
});
useCanvasFlowReconciliation({
@@ -372,178 +371,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
console.error("[ReactFlow error]", { canvasId, id, error });
}, [canvasId]);
// ─── Future hook seam: connections ────────────────────────────
const onConnect = useCallback(
(connection: Connection) => {
const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
if (!connection.source || !connection.target) return;
void runCreateEdgeMutation({
canvasId,
sourceNodeId: connection.source as Id<"nodes">,
targetNodeId: connection.target as Id<"nodes">,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
});
},
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (isReconnectDragActiveRef.current) return;
if (connectionState.isValid === true) return;
const fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle;
if (!fromNode || !fromHandle) return;
const pt = getConnectEndClientPoint(event);
if (!pt) return;
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
setConnectionDropMenu({
screenX: pt.x,
screenY: pt.y,
flowX: flow.x,
flowY: flow.y,
fromNodeId: fromNode.id as Id<"nodes">,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
});
},
[screenToFlowPosition],
);
const handleConnectionDropPick = useCallback(
(template: CanvasNodeTemplate) => {
const ctx = connectionDropMenuRef.current;
if (!ctx) return;
const fromNode = nodesRef.current.find((node) => node.id === ctx.fromNodeId);
if (!fromNode) {
showConnectionRejectedToast("unknown-node");
return;
}
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
pendingConnectionCreatesRef.current.add(clientRequestId);
const handles = NODE_HANDLE_MAP[template.type];
const width = template.width ?? defaults.width;
const height = template.height ?? defaults.height;
const data = {
...defaults.data,
...(template.defaultData as Record<string, unknown>),
canvasId,
};
const base = {
canvasId,
type: template.type,
positionX: ctx.flowX,
positionY: ctx.flowY,
width,
height,
data,
clientRequestId,
};
const settle = (realId: Id<"nodes">) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error("[Canvas] settle syncPendingMove failed", error);
},
);
};
if (ctx.fromHandleType === "source") {
const validationError = validateCanvasConnectionByType({
sourceType: fromNode.type ?? "",
targetType: template.type,
targetNodeId: `__pending_${template.type}_${Date.now()}`,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeFromSourceOnlineOnly({
...base,
sourceNodeId: ctx.fromNodeId,
sourceHandle: ctx.fromHandleId,
targetHandle: handles?.target ?? undefined,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
resolvedRealIdByClientRequestRef.current.set(
clientRequestId,
realId,
);
settle(realId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
});
} else {
const validationError = validateCanvasConnectionByType({
sourceType: template.type,
targetType: fromNode.type ?? "",
targetNodeId: fromNode.id,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeToTargetOnlineOnly({
...base,
targetNodeId: ctx.fromNodeId,
sourceHandle: handles?.source ?? undefined,
targetHandle: ctx.fromHandleId,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
resolvedRealIdByClientRequestRef.current.set(
clientRequestId,
realId,
);
settle(realId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeToTarget failed", error);
});
}
},
[
canvasId,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast,
syncPendingMoveForClientRequest,
],
);
// ─── Future hook seam: drop flows ─────────────────────────────
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
@@ -753,7 +580,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
<CanvasCommandPalette />
<CanvasConnectionDropMenu
state={connectionDropMenu}
onClose={() => setConnectionDropMenu(null)}
onClose={closeConnectionDropMenu}
onPick={handleConnectionDropPick}
/>
{scissorsMode ? (