feat(canvas): share magnet state across connection drags
This commit is contained in:
@@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDroppedConnectionTarget: vi.fn(),
|
||||
resolveCanvasMagnetTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
@@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-connection-magnetism", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/components/canvas/canvas-connection-magnetism")
|
||||
>("@/components/canvas/canvas-connection-magnetism");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget,
|
||||
};
|
||||
});
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
import {
|
||||
CanvasConnectionMagnetismProvider,
|
||||
useCanvasConnectionMagnetism,
|
||||
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
@@ -35,6 +52,14 @@ const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasConnections> | null;
|
||||
} = { current: null };
|
||||
|
||||
const latestMagnetTargetRef: {
|
||||
current: CanvasMagnetTarget | null;
|
||||
} = { current: null };
|
||||
|
||||
const latestSetActiveTargetRef: {
|
||||
current: ((target: CanvasMagnetTarget | null) => void) | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
@@ -47,9 +72,12 @@ type HookHarnessProps = {
|
||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
initialMagnetTarget?: CanvasMagnetTarget | null;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
type HookHarnessInnerProps = HookHarnessProps;
|
||||
|
||||
function HookHarnessInner({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
@@ -59,7 +87,10 @@ function HookHarness({
|
||||
setEdgesMock,
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
initialMagnetTarget,
|
||||
}: HookHarnessInnerProps) {
|
||||
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
const didInitializeMagnetTargetRef = useRef(false);
|
||||
const [nodes] = useState<RFNode[]>(
|
||||
providedNodes ?? [
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
@@ -88,6 +119,17 @@ function HookHarness({
|
||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||
}, [helperResult]);
|
||||
|
||||
useEffect(() => {
|
||||
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
|
||||
didInitializeMagnetTargetRef.current = true;
|
||||
setActiveTarget(initialMagnetTarget);
|
||||
}
|
||||
}, [initialMagnetTarget, setActiveTarget]);
|
||||
|
||||
const handlers = useCanvasConnections({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
@@ -115,15 +157,36 @@ function HookHarness({
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
latestMagnetTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSetActiveTargetRef.current = setActiveTarget;
|
||||
return () => {
|
||||
latestSetActiveTargetRef.current = null;
|
||||
};
|
||||
}, [setActiveTarget]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function HookHarness(props: HookHarnessProps) {
|
||||
return (
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<HookHarnessInner {...props} />
|
||||
</CanvasConnectionMagnetismProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCanvasConnections", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
latestMagnetTargetRef.current = null;
|
||||
latestSetActiveTargetRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
|
||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
});
|
||||
|
||||
it("falls back to active magnet target when direct drop resolution misses", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleId: "base",
|
||||
handleType: "target",
|
||||
centerX: 320,
|
||||
centerY: 180,
|
||||
distancePx: 12,
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "base",
|
||||
});
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid active magnet target and clears transient state", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-source",
|
||||
handleType: "target",
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
distancePx: 10,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 120, clientY: 120 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 120, y: 120 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("clears transient magnet state when dropping on background opens menu", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleType: "target",
|
||||
centerX: 200,
|
||||
centerY: 220,
|
||||
distancePx: 14,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 500, clientY: 460 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 500, y: 460 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
|
||||
expect.objectContaining({
|
||||
screenX: 500,
|
||||
screenY: 460,
|
||||
}),
|
||||
);
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it("clears transient magnet state when reconnect drag ends", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
]}
|
||||
initialMagnetTarget={{
|
||||
nodeId: "node-target",
|
||||
handleType: "target",
|
||||
handleId: "overlay",
|
||||
centerX: 300,
|
||||
centerY: 180,
|
||||
distancePx: 11,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = {
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
} as RFEdge;
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
sourceHandle: null,
|
||||
targetHandle: "overlay",
|
||||
});
|
||||
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalled();
|
||||
expect(latestMagnetTargetRef.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
51
components/canvas/canvas-connection-magnetism-context.tsx
Normal file
51
components/canvas/canvas-connection-magnetism-context.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||
|
||||
type CanvasConnectionMagnetismState = {
|
||||
activeTarget: CanvasMagnetTarget | null;
|
||||
setActiveTarget: (target: CanvasMagnetTarget | null) => void;
|
||||
};
|
||||
|
||||
const CanvasConnectionMagnetismContext =
|
||||
createContext<CanvasConnectionMagnetismState | null>(null);
|
||||
|
||||
export function CanvasConnectionMagnetismProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeTarget, setActiveTarget] = useState<CanvasMagnetTarget | null>(null);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeTarget,
|
||||
setActiveTarget,
|
||||
}),
|
||||
[activeTarget],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasConnectionMagnetismContext.Provider value={value}>
|
||||
{children}
|
||||
</CanvasConnectionMagnetismContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState {
|
||||
const context = useContext(CanvasConnectionMagnetismContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCanvasConnectionMagnetism must be used within CanvasConnectionMagnetismProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = {
|
||||
nextOtherEdgeHandle: "base" | "overlay";
|
||||
} | null;
|
||||
onInvalidConnection?: (message: string) => void;
|
||||
clearActiveMagnetTarget?: () => void;
|
||||
};
|
||||
|
||||
export function useCanvasReconnectHandlers({
|
||||
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
|
||||
validateConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection,
|
||||
clearActiveMagnetTarget,
|
||||
}: UseCanvasReconnectHandlersParams): {
|
||||
onReconnectStart: () => void;
|
||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
|
||||
>(null);
|
||||
|
||||
const onReconnectStart = useCallback(() => {
|
||||
clearActiveMagnetTarget?.();
|
||||
edgeReconnectSuccessful.current = false;
|
||||
isReconnectDragActiveRef.current = true;
|
||||
pendingReconnectRef.current = null;
|
||||
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||
}, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||
|
||||
const onReconnect = useCallback(
|
||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
} finally {
|
||||
clearActiveMagnetTarget?.();
|
||||
isReconnectDragActiveRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
clearActiveMagnetTarget,
|
||||
edgeReconnectSuccessful,
|
||||
isReconnectDragActiveRef,
|
||||
runCreateEdgeMutation,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
|
||||
|
||||
interface CanvasInnerProps {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -709,7 +710,9 @@ interface CanvasProps {
|
||||
export default function Canvas({ canvasId }: CanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<CanvasInner canvasId={canvasId} />
|
||||
</CanvasConnectionMagnetismProvider>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
|
||||
import {
|
||||
resolveCanvasMagnetTarget,
|
||||
type CanvasMagnetTarget,
|
||||
} from "./canvas-connection-magnetism";
|
||||
import {
|
||||
getConnectEndClientPoint,
|
||||
hasHandleKey,
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
validateCanvasConnectionByType,
|
||||
validateCanvasEdgeSplit,
|
||||
} from "./canvas-connection-validation";
|
||||
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
|
||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||
|
||||
@@ -122,6 +127,7 @@ export function useCanvasConnections({
|
||||
runSwapMixerInputsMutation,
|
||||
showConnectionRejectedToast,
|
||||
}: UseCanvasConnectionsParams) {
|
||||
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
useState<ConnectionDropMenuState | null>(null);
|
||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||
@@ -133,17 +139,40 @@ export function useCanvasConnections({
|
||||
}, [connectionDropMenu]);
|
||||
|
||||
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||
setActiveTarget(null);
|
||||
isConnectDragActiveRef.current = true;
|
||||
logCanvasConnectionDebug("connect:start", {
|
||||
nodeId: params.nodeId,
|
||||
handleId: params.handleId,
|
||||
handleType: params.handleType,
|
||||
});
|
||||
}, []);
|
||||
}, [setActiveTarget]);
|
||||
|
||||
const toDroppedConnectionFromMagnetTarget = useCallback(
|
||||
(fromHandleType: "source" | "target", fromNodeId: string, fromHandleId: string | undefined, magnetTarget: CanvasMagnetTarget) => {
|
||||
if (fromHandleType === "source") {
|
||||
return {
|
||||
sourceNodeId: fromNodeId,
|
||||
targetNodeId: magnetTarget.nodeId,
|
||||
sourceHandle: fromHandleId,
|
||||
targetHandle: magnetTarget.handleId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceNodeId: magnetTarget.nodeId,
|
||||
targetNodeId: fromNodeId,
|
||||
sourceHandle: magnetTarget.handleId,
|
||||
targetHandle: fromHandleId,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
isConnectDragActiveRef.current = false;
|
||||
try {
|
||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||
if (validationError) {
|
||||
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||
@@ -181,8 +210,11 @@ export function useCanvasConnections({
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
});
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
}
|
||||
},
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
|
||||
);
|
||||
|
||||
const resolveMixerSwapReconnect = useCallback(
|
||||
@@ -252,6 +284,7 @@ export function useCanvasConnections({
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
setActiveTarget(null);
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "drag-not-active",
|
||||
isValid: connectionState.isValid ?? null,
|
||||
@@ -264,6 +297,7 @@ export function useCanvasConnections({
|
||||
}
|
||||
|
||||
isConnectDragActiveRef.current = false;
|
||||
try {
|
||||
if (isReconnectDragActiveRef.current) {
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "reconnect-active",
|
||||
@@ -319,7 +353,7 @@ export function useCanvasConnections({
|
||||
});
|
||||
|
||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||
const droppedConnection = resolveDroppedConnectionTarget({
|
||||
let droppedConnection = resolveDroppedConnectionTarget({
|
||||
point: pt,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
@@ -328,6 +362,28 @@ export function useCanvasConnections({
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
|
||||
if (!droppedConnection) {
|
||||
const fallbackMagnetTarget =
|
||||
activeTarget ??
|
||||
resolveCanvasMagnetTarget({
|
||||
point: pt,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
fromHandleType: fromHandle.type,
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
|
||||
if (fallbackMagnetTarget) {
|
||||
droppedConnection = toDroppedConnectionFromMagnetTarget(
|
||||
fromHandle.type,
|
||||
fromNode.id,
|
||||
fromHandle.id ?? undefined,
|
||||
fallbackMagnetTarget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logCanvasConnectionDebug("connect:end-drop-result", {
|
||||
point: pt,
|
||||
flow,
|
||||
@@ -445,6 +501,9 @@ export function useCanvasConnections({
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
fromHandleType: fromHandle.type,
|
||||
});
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
@@ -454,7 +513,10 @@ export function useCanvasConnections({
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
screenToFlowPosition,
|
||||
setActiveTarget,
|
||||
showConnectionRejectedToast,
|
||||
activeTarget,
|
||||
toDroppedConnectionFromMagnetTarget,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -598,6 +660,9 @@ export function useCanvasConnections({
|
||||
onInvalidConnection: (reason) => {
|
||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||
},
|
||||
clearActiveMagnetTarget: () => {
|
||||
setActiveTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user