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

@@ -0,0 +1,322 @@
// @vitest-environment jsdom
import React, { act, useEffect, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Connection, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
const {
validateCanvasConnectionMock,
validateCanvasConnectionByTypeMock,
useCanvasReconnectHandlersMock,
getConnectEndClientPointMock,
} = vi.hoisted(() => ({
validateCanvasConnectionMock: vi.fn(),
validateCanvasConnectionByTypeMock: vi.fn(),
useCanvasReconnectHandlersMock: vi.fn(),
getConnectEndClientPointMock: vi.fn(),
}));
vi.mock("@/components/canvas/canvas-connection-validation", () => ({
validateCanvasConnection: validateCanvasConnectionMock,
validateCanvasConnectionByType: validateCanvasConnectionByTypeMock,
}));
vi.mock("@/components/canvas/canvas-reconnect", () => ({
useCanvasReconnectHandlers: useCanvasReconnectHandlersMock,
}));
vi.mock("@/components/canvas/canvas-helpers", async () => {
const actual = await vi.importActual<
typeof import("@/components/canvas/canvas-helpers")
>("@/components/canvas/canvas-helpers");
return {
...actual,
getConnectEndClientPoint: getConnectEndClientPointMock,
};
});
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">;
type HarnessProps = {
nodes: RFNode[];
edges: RFEdge[];
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
runRemoveEdgeMutation?: ReturnType<typeof vi.fn>;
runCreateNodeWithEdgeFromSourceOnlineOnly?: ReturnType<typeof vi.fn>;
runCreateNodeWithEdgeToTargetOnlineOnly?: ReturnType<typeof vi.fn>;
syncPendingMoveForClientRequest?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
isReconnectDragActive?: boolean;
};
const latestHookRef: {
current: ReturnType<typeof useCanvasConnections> | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HookHarness(props: HarnessProps) {
const nodesRef = useRef(props.nodes);
const edgesRef = useRef(props.edges);
const pendingConnectionCreatesRef = useRef(new Set<string>());
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
const edgeReconnectSuccessful = useRef(true);
const isReconnectDragActiveRef = useRef(Boolean(props.isReconnectDragActive));
useEffect(() => {
nodesRef.current = props.nodes;
edgesRef.current = props.edges;
}, [props.edges, props.nodes]);
const hookValue = useCanvasConnections({
canvasId: asCanvasId("canvas-1"),
nodes: props.nodes,
edges: props.edges,
nodesRef,
edgesRef,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
setEdges: vi.fn(),
setEdgeSyncNonce: vi.fn(),
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x: x - 10, y: y - 20 }),
syncPendingMoveForClientRequest:
props.syncPendingMoveForClientRequest ?? vi.fn(async () => undefined),
runCreateEdgeMutation: props.runCreateEdgeMutation ?? vi.fn(async () => undefined),
runRemoveEdgeMutation: props.runRemoveEdgeMutation ?? vi.fn(async () => undefined),
runCreateNodeWithEdgeFromSourceOnlineOnly:
props.runCreateNodeWithEdgeFromSourceOnlineOnly ?? vi.fn(async () => asNodeId("node-new")),
runCreateNodeWithEdgeToTargetOnlineOnly:
props.runCreateNodeWithEdgeToTargetOnlineOnly ?? vi.fn(async () => asNodeId("node-new")),
showConnectionRejectedToast:
props.showConnectionRejectedToast ?? vi.fn(),
});
useEffect(() => {
latestHookRef.current = hookValue;
}, [hookValue]);
return null;
}
describe("useCanvasConnections", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
validateCanvasConnectionMock.mockReturnValue(null);
validateCanvasConnectionByTypeMock.mockReturnValue(null);
getConnectEndClientPointMock.mockReturnValue({ x: 140, y: 220 });
useCanvasReconnectHandlersMock.mockReturnValue({
onReconnectStart: vi.fn(),
onReconnect: vi.fn(),
onReconnectEnd: vi.fn(),
});
});
afterEach(async () => {
latestHookRef.current = null;
vi.clearAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
async function renderHook(props: HarnessProps) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<HookHarness {...props} />);
});
}
it("creates a valid edge through centralized validation", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
await renderHook({
nodes: [
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-text", type: "text", position: { x: 10, y: 10 }, data: {} },
],
edges: [],
runCreateEdgeMutation,
});
const connection: Connection = {
source: "node-image",
target: "node-text",
sourceHandle: null,
targetHandle: null,
};
await act(async () => {
latestHookRef.current?.onConnect(connection);
});
expect(validateCanvasConnectionMock).toHaveBeenCalledWith(connection, expect.any(Array), []);
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
sourceNodeId: "node-image",
targetNodeId: "node-text",
sourceHandle: undefined,
targetHandle: undefined,
});
});
it("rejects invalid connections without mutating edges", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
validateCanvasConnectionMock.mockReturnValue("self-loop");
await renderHook({
nodes: [
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
],
edges: [],
runCreateEdgeMutation,
showConnectionRejectedToast,
});
await act(async () => {
latestHookRef.current?.onConnect({
source: "node-image",
target: "node-image",
sourceHandle: null,
targetHandle: null,
});
});
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
});
it("opens the connection drop menu from an invalid connect end", async () => {
await renderHook({
nodes: [
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
],
edges: [],
});
await act(async () => {
latestHookRef.current?.onConnectEnd({} as MouseEvent, {
isValid: false,
fromNode: { id: "node-image" },
fromHandle: { id: "source", type: "source" },
} as never);
});
expect(latestHookRef.current?.connectionDropMenu).toEqual({
screenX: 140,
screenY: 220,
flowX: 130,
flowY: 200,
fromNodeId: "node-image",
fromHandleId: "source",
fromHandleType: "source",
});
});
it("routes connection-drop creation through type validation and source creation", async () => {
const runCreateNodeWithEdgeFromSourceOnlineOnly = vi.fn(async () => asNodeId("node-new"));
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
await renderHook({
nodes: [
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
],
edges: [],
runCreateNodeWithEdgeFromSourceOnlineOnly,
syncPendingMoveForClientRequest,
});
await act(async () => {
latestHookRef.current?.onConnectEnd({} as MouseEvent, {
isValid: false,
fromNode: { id: "node-image" },
fromHandle: { id: "source", type: "source" },
} as never);
});
const template = {
...CANVAS_NODE_TEMPLATES.find((entry) => entry.type === "text")!,
defaultData: { content: "Hello" },
} as unknown as CanvasNodeTemplate;
await act(async () => {
latestHookRef.current?.handleConnectionDropPick(template);
await Promise.resolve();
});
expect(validateCanvasConnectionByTypeMock).toHaveBeenCalledWith({
sourceType: "image",
targetType: "text",
targetNodeId: expect.stringMatching(/^__pending_text_/),
edges: [],
});
expect(runCreateNodeWithEdgeFromSourceOnlineOnly).toHaveBeenCalledWith(
expect.objectContaining({
canvasId: "canvas-1",
type: "text",
positionX: 130,
positionY: 200,
sourceNodeId: "node-image",
sourceHandle: "source",
data: expect.objectContaining({
canvasId: "canvas-1",
content: "Hello",
}),
}),
);
expect(syncPendingMoveForClientRequest).toHaveBeenCalled();
});
it("adapts reconnect handlers through the shared connection validation", async () => {
const showConnectionRejectedToast = vi.fn();
await renderHook({
nodes: [
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-text", type: "text", position: { x: 10, y: 10 }, data: {} },
],
edges: [{ id: "edge-1", source: "node-image", target: "node-text" }],
showConnectionRejectedToast,
});
const reconnectArgs = useCanvasReconnectHandlersMock.mock.calls[0][0];
const reconnectValidation = reconnectArgs.validateConnection(
{ id: "edge-1", source: "node-image", target: "node-text" },
{ source: "node-image", target: "node-text" },
);
reconnectArgs.onInvalidConnection("unknown-node");
expect(validateCanvasConnectionMock).toHaveBeenCalledWith(
{ source: "node-image", target: "node-text" },
expect.any(Array),
expect.any(Array),
"edge-1",
);
expect(reconnectValidation).toBeNull();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("unknown-node");
expect(latestHookRef.current?.onReconnect).toBe(
useCanvasReconnectHandlersMock.mock.results[0]?.value.onReconnect,
);
});
});

View File

@@ -20,8 +20,6 @@ import {
type Node as RFNode, type Node as RFNode,
type Edge as RFEdge, type Edge as RFEdge,
type EdgeChange, type EdgeChange,
type Connection,
type OnConnectEnd,
BackgroundVariant, BackgroundVariant,
} from "@xyflow/react"; } from "@xyflow/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -42,14 +40,7 @@ import {
} from "@/lib/canvas-node-types"; } from "@/lib/canvas-node-types";
import { nodeTypes } from "./node-types"; import { nodeTypes } from "./node-types";
import { import { NODE_DEFAULTS } from "@/lib/canvas-utils";
validateCanvasConnection,
validateCanvasConnectionByType,
} from "./canvas-connection-validation";
import {
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import CanvasToolbar, { import CanvasToolbar, {
type CanvasNavTool, type CanvasNavTool,
} from "@/components/canvas/canvas-toolbar"; } 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 { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import { import {
CanvasConnectionDropMenu, CanvasConnectionDropMenu,
type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu"; } from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context"; import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
@@ -66,24 +56,21 @@ import {
type AssetBrowserTargetApi, type AssetBrowserTargetApi,
} from "@/components/canvas/asset-browser-panel"; } from "@/components/canvas/asset-browser-panel";
import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import { import {
CANVAS_MIN_ZOOM, CANVAS_MIN_ZOOM,
DEFAULT_EDGE_OPTIONS, DEFAULT_EDGE_OPTIONS,
getConnectEndClientPoint,
getMiniMapNodeColor, getMiniMapNodeColor,
getMiniMapNodeStrokeColor, getMiniMapNodeStrokeColor,
getPendingRemovedEdgeIdsFromLocalOps, getPendingRemovedEdgeIdsFromLocalOps,
getPendingMovePinsFromLocalOps, getPendingMovePinsFromLocalOps,
isEditableKeyboardTarget, isEditableKeyboardTarget,
isOptimisticNodeId,
withResolvedCompareData, withResolvedCompareData,
} from "./canvas-helpers"; } from "./canvas-helpers";
import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useGenerationFailureWarnings } from "./canvas-generation-failures";
import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
import { getImageDimensions } from "./canvas-media-utils"; import { getImageDimensions } from "./canvas-media-utils";
import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; import { useCanvasNodeInteractions } from "./use-canvas-node-interactions";
import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasConnections } from "./use-canvas-connections";
import { useCanvasScissors } from "./canvas-scissors"; import { useCanvasScissors } from "./canvas-scissors";
import { CanvasSyncProvider } from "./canvas-sync-context"; import { CanvasSyncProvider } from "./canvas-sync-context";
import { useCanvasData } from "./use-canvas-data"; import { useCanvasData } from "./use-canvas-data";
@@ -167,10 +154,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Future hook seam: render composition + shared local flow state ───── // ─── Future hook seam: render composition + shared local flow state ─────
const nodesRef = useRef<RFNode[]>(nodes); const nodesRef = useRef<RFNode[]>(nodes);
nodesRef.current = 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 [scissorsMode, setScissorsMode] = useState(false);
const [scissorStrokePreview, setScissorStrokePreview] = useState< const [scissorStrokePreview, setScissorStrokePreview] = useState<
@@ -293,18 +276,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
runRemoveEdgeMutation, runRemoveEdgeMutation,
}); });
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({ const {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
} = useCanvasConnections({
canvasId, canvasId,
nodes,
edges,
nodesRef,
edgesRef,
edgeReconnectSuccessful, edgeReconnectSuccessful,
isReconnectDragActiveRef, isReconnectDragActiveRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
setEdges, setEdges,
setEdgeSyncNonce,
screenToFlowPosition,
syncPendingMoveForClientRequest,
runCreateEdgeMutation, runCreateEdgeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
validateConnection: (oldEdge, nextConnection) => runCreateNodeWithEdgeFromSourceOnlineOnly,
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id), runCreateNodeWithEdgeToTargetOnlineOnly,
onInvalidConnection: (reason) => { showConnectionRejectedToast,
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
}); });
useCanvasFlowReconciliation({ useCanvasFlowReconciliation({
@@ -372,178 +371,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
console.error("[ReactFlow error]", { canvasId, id, error }); console.error("[ReactFlow error]", { canvasId, id, error });
}, [canvasId]); }, [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 ───────────────────────────── // ─── Future hook seam: drop flows ─────────────────────────────
const onDragOver = useCallback((event: React.DragEvent) => { const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
@@ -753,7 +580,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
<CanvasCommandPalette /> <CanvasCommandPalette />
<CanvasConnectionDropMenu <CanvasConnectionDropMenu
state={connectionDropMenu} state={connectionDropMenu}
onClose={() => setConnectionDropMenu(null)} onClose={closeConnectionDropMenu}
onPick={handleConnectionDropPick} onPick={handleConnectionDropPick}
/> />
{scissorsMode ? ( {scissorsMode ? (

View File

@@ -0,0 +1,301 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import {
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
import {
validateCanvasConnection,
validateCanvasConnectionByType,
} from "./canvas-connection-validation";
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
type UseCanvasConnectionsParams = {
canvasId: Id<"canvases">;
nodes: RFNode[];
edges: RFEdge[];
nodesRef: MutableRefObject<RFNode[]>;
edgesRef: MutableRefObject<RFEdge[]>;
edgeReconnectSuccessful: MutableRefObject<boolean>;
isReconnectDragActiveRef: MutableRefObject<boolean>;
pendingConnectionCreatesRef: MutableRefObject<Set<string>>;
resolvedRealIdByClientRequestRef: MutableRefObject<Map<string, Id<"nodes">>>;
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
setEdgeSyncNonce: Dispatch<SetStateAction<number>>;
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
syncPendingMoveForClientRequest: (
clientRequestId: string,
realId?: Id<"nodes">,
) => Promise<unknown>;
runCreateEdgeMutation: (args: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
clientRequestId?: string;
sourceNodeId: string;
parentId?: string;
zIndex?: number;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<Id<"nodes"> | string>;
runCreateNodeWithEdgeToTargetOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
clientRequestId?: string;
targetNodeId: string;
parentId?: string;
zIndex?: number;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<Id<"nodes"> | string>;
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
};
export function useCanvasConnections({
canvasId,
nodes,
edges,
nodesRef,
edgesRef,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
setEdges,
setEdgeSyncNonce,
screenToFlowPosition,
syncPendingMoveForClientRequest,
runCreateEdgeMutation,
runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast,
}: UseCanvasConnectionsParams) {
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
useEffect(() => {
connectionDropMenuRef.current = connectionDropMenu;
}, [connectionDropMenu]);
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,
});
},
[isReconnectDragActiveRef, 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;
}
const settledRealId = realId as Id<"nodes">;
resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId);
settle(settledRealId);
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;
}
const settledRealId = realId as Id<"nodes">;
resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId);
settle(settledRealId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeToTarget failed", error);
});
}
},
[
canvasId,
edgesRef,
nodesRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
setEdgeSyncNonce,
showConnectionRejectedToast,
syncPendingMoveForClientRequest,
],
);
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
canvasId,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
setEdges,
runCreateEdgeMutation,
runRemoveEdgeMutation,
validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
});
return {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
};
}

View File

@@ -12,6 +12,7 @@ export default defineConfig({
include: [ include: [
"tests/**/*.test.ts", "tests/**/*.test.ts",
"components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts",
"components/canvas/__tests__/use-canvas-connections.test.tsx",
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
"components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine.test.ts",