refactor(canvas): extract connection handling hook
This commit is contained in:
322
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
322
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
301
components/canvas/use-canvas-connections.ts
Normal file
301
components/canvas/use-canvas-connections.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user