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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
resolveDroppedConnectionTarget: vi.fn(),
|
resolveDroppedConnectionTarget: vi.fn(),
|
||||||
|
resolveCanvasMagnetTarget: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
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 { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
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 { nodeTypes } from "@/components/canvas/node-types";
|
||||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||||
@@ -35,6 +52,14 @@ const latestHandlersRef: {
|
|||||||
current: ReturnType<typeof useCanvasConnections> | null;
|
current: ReturnType<typeof useCanvasConnections> | null;
|
||||||
} = { current: 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;
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
type HookHarnessProps = {
|
type HookHarnessProps = {
|
||||||
@@ -47,9 +72,12 @@ type HookHarnessProps = {
|
|||||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||||
nodes?: RFNode[];
|
nodes?: RFNode[];
|
||||||
edges?: RFEdge[];
|
edges?: RFEdge[];
|
||||||
|
initialMagnetTarget?: CanvasMagnetTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({
|
type HookHarnessInnerProps = HookHarnessProps;
|
||||||
|
|
||||||
|
function HookHarnessInner({
|
||||||
helperResult,
|
helperResult,
|
||||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||||
@@ -59,7 +87,10 @@ function HookHarness({
|
|||||||
setEdgesMock,
|
setEdgesMock,
|
||||||
nodes: providedNodes,
|
nodes: providedNodes,
|
||||||
edges: providedEdges,
|
edges: providedEdges,
|
||||||
}: HookHarnessProps) {
|
initialMagnetTarget,
|
||||||
|
}: HookHarnessInnerProps) {
|
||||||
|
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
|
const didInitializeMagnetTargetRef = useRef(false);
|
||||||
const [nodes] = useState<RFNode[]>(
|
const [nodes] = useState<RFNode[]>(
|
||||||
providedNodes ?? [
|
providedNodes ?? [
|
||||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||||
@@ -88,6 +119,17 @@ function HookHarness({
|
|||||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||||
}, [helperResult]);
|
}, [helperResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
|
||||||
|
didInitializeMagnetTargetRef.current = true;
|
||||||
|
setActiveTarget(initialMagnetTarget);
|
||||||
|
}
|
||||||
|
}, [initialMagnetTarget, setActiveTarget]);
|
||||||
|
|
||||||
const handlers = useCanvasConnections({
|
const handlers = useCanvasConnections({
|
||||||
canvasId: asCanvasId("canvas-1"),
|
canvasId: asCanvasId("canvas-1"),
|
||||||
nodes,
|
nodes,
|
||||||
@@ -115,15 +157,36 @@ function HookHarness({
|
|||||||
latestHandlersRef.current = handlers;
|
latestHandlersRef.current = handlers;
|
||||||
}, [handlers]);
|
}, [handlers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestMagnetTargetRef.current = activeTarget;
|
||||||
|
}, [activeTarget]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestSetActiveTargetRef.current = setActiveTarget;
|
||||||
|
return () => {
|
||||||
|
latestSetActiveTargetRef.current = null;
|
||||||
|
};
|
||||||
|
}, [setActiveTarget]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HookHarness(props: HookHarnessProps) {
|
||||||
|
return (
|
||||||
|
<CanvasConnectionMagnetismProvider>
|
||||||
|
<HookHarnessInner {...props} />
|
||||||
|
</CanvasConnectionMagnetismProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("useCanvasConnections", () => {
|
describe("useCanvasConnections", () => {
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
latestHandlersRef.current = null;
|
latestHandlersRef.current = null;
|
||||||
|
latestMagnetTargetRef.current = null;
|
||||||
|
latestSetActiveTargetRef.current = null;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
if (root) {
|
if (root) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
|
|||||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
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";
|
nextOtherEdgeHandle: "base" | "overlay";
|
||||||
} | null;
|
} | null;
|
||||||
onInvalidConnection?: (message: string) => void;
|
onInvalidConnection?: (message: string) => void;
|
||||||
|
clearActiveMagnetTarget?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasReconnectHandlers({
|
export function useCanvasReconnectHandlers({
|
||||||
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
|
|||||||
validateConnection,
|
validateConnection,
|
||||||
resolveMixerSwapReconnect,
|
resolveMixerSwapReconnect,
|
||||||
onInvalidConnection,
|
onInvalidConnection,
|
||||||
|
clearActiveMagnetTarget,
|
||||||
}: UseCanvasReconnectHandlersParams): {
|
}: UseCanvasReconnectHandlersParams): {
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||||
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const onReconnectStart = useCallback(() => {
|
const onReconnectStart = useCallback(() => {
|
||||||
|
clearActiveMagnetTarget?.();
|
||||||
edgeReconnectSuccessful.current = false;
|
edgeReconnectSuccessful.current = false;
|
||||||
isReconnectDragActiveRef.current = true;
|
isReconnectDragActiveRef.current = true;
|
||||||
pendingReconnectRef.current = null;
|
pendingReconnectRef.current = null;
|
||||||
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
}, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||||
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
|
|||||||
|
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
} finally {
|
} finally {
|
||||||
|
clearActiveMagnetTarget?.();
|
||||||
isReconnectDragActiveRef.current = false;
|
isReconnectDragActiveRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
|
clearActiveMagnetTarget,
|
||||||
edgeReconnectSuccessful,
|
edgeReconnectSuccessful,
|
||||||
isReconnectDragActiveRef,
|
isReconnectDragActiveRef,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
|||||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||||
|
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -709,7 +710,9 @@ interface CanvasProps {
|
|||||||
export default function Canvas({ canvasId }: CanvasProps) {
|
export default function Canvas({ canvasId }: CanvasProps) {
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
|
<CanvasConnectionMagnetismProvider>
|
||||||
<CanvasInner canvasId={canvasId} />
|
<CanvasInner canvasId={canvasId} />
|
||||||
|
</CanvasConnectionMagnetismProvider>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
|||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveCanvasMagnetTarget,
|
||||||
|
type CanvasMagnetTarget,
|
||||||
|
} from "./canvas-connection-magnetism";
|
||||||
import {
|
import {
|
||||||
getConnectEndClientPoint,
|
getConnectEndClientPoint,
|
||||||
hasHandleKey,
|
hasHandleKey,
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
validateCanvasConnectionByType,
|
validateCanvasConnectionByType,
|
||||||
validateCanvasEdgeSplit,
|
validateCanvasEdgeSplit,
|
||||||
} from "./canvas-connection-validation";
|
} from "./canvas-connection-validation";
|
||||||
|
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
|
||||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||||
|
|
||||||
@@ -122,6 +127,7 @@ export function useCanvasConnections({
|
|||||||
runSwapMixerInputsMutation,
|
runSwapMixerInputsMutation,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
}: UseCanvasConnectionsParams) {
|
}: UseCanvasConnectionsParams) {
|
||||||
|
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
const [connectionDropMenu, setConnectionDropMenu] =
|
const [connectionDropMenu, setConnectionDropMenu] =
|
||||||
useState<ConnectionDropMenuState | null>(null);
|
useState<ConnectionDropMenuState | null>(null);
|
||||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||||
@@ -133,17 +139,40 @@ export function useCanvasConnections({
|
|||||||
}, [connectionDropMenu]);
|
}, [connectionDropMenu]);
|
||||||
|
|
||||||
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||||
|
setActiveTarget(null);
|
||||||
isConnectDragActiveRef.current = true;
|
isConnectDragActiveRef.current = true;
|
||||||
logCanvasConnectionDebug("connect:start", {
|
logCanvasConnectionDebug("connect:start", {
|
||||||
nodeId: params.nodeId,
|
nodeId: params.nodeId,
|
||||||
handleId: params.handleId,
|
handleId: params.handleId,
|
||||||
handleType: params.handleType,
|
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(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
|
try {
|
||||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
logCanvasConnectionDebug("connect:invalid-direct", {
|
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||||
@@ -181,8 +210,11 @@ export function useCanvasConnections({
|
|||||||
sourceHandle: connection.sourceHandle ?? undefined,
|
sourceHandle: connection.sourceHandle ?? undefined,
|
||||||
targetHandle: connection.targetHandle ?? undefined,
|
targetHandle: connection.targetHandle ?? undefined,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setActiveTarget(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
[canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveMixerSwapReconnect = useCallback(
|
const resolveMixerSwapReconnect = useCallback(
|
||||||
@@ -252,6 +284,7 @@ export function useCanvasConnections({
|
|||||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
(event, connectionState) => {
|
(event, connectionState) => {
|
||||||
if (!isConnectDragActiveRef.current) {
|
if (!isConnectDragActiveRef.current) {
|
||||||
|
setActiveTarget(null);
|
||||||
logCanvasConnectionDebug("connect:end-ignored", {
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
reason: "drag-not-active",
|
reason: "drag-not-active",
|
||||||
isValid: connectionState.isValid ?? null,
|
isValid: connectionState.isValid ?? null,
|
||||||
@@ -264,6 +297,7 @@ export function useCanvasConnections({
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
|
try {
|
||||||
if (isReconnectDragActiveRef.current) {
|
if (isReconnectDragActiveRef.current) {
|
||||||
logCanvasConnectionDebug("connect:end-ignored", {
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
reason: "reconnect-active",
|
reason: "reconnect-active",
|
||||||
@@ -319,7 +353,7 @@ export function useCanvasConnections({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||||
const droppedConnection = resolveDroppedConnectionTarget({
|
let droppedConnection = resolveDroppedConnectionTarget({
|
||||||
point: pt,
|
point: pt,
|
||||||
fromNodeId: fromNode.id,
|
fromNodeId: fromNode.id,
|
||||||
fromHandleId: fromHandle.id ?? undefined,
|
fromHandleId: fromHandle.id ?? undefined,
|
||||||
@@ -328,6 +362,28 @@ export function useCanvasConnections({
|
|||||||
edges: edgesRef.current,
|
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", {
|
logCanvasConnectionDebug("connect:end-drop-result", {
|
||||||
point: pt,
|
point: pt,
|
||||||
flow,
|
flow,
|
||||||
@@ -445,6 +501,9 @@ export function useCanvasConnections({
|
|||||||
fromHandleId: fromHandle.id ?? undefined,
|
fromHandleId: fromHandle.id ?? undefined,
|
||||||
fromHandleType: fromHandle.type,
|
fromHandleType: fromHandle.type,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setActiveTarget(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -454,7 +513,10 @@ export function useCanvasConnections({
|
|||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
runSplitEdgeAtExistingNodeMutation,
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
|
setActiveTarget,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
|
activeTarget,
|
||||||
|
toDroppedConnectionFromMagnetTarget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -598,6 +660,9 @@ export function useCanvasConnections({
|
|||||||
onInvalidConnection: (reason) => {
|
onInvalidConnection: (reason) => {
|
||||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||||
},
|
},
|
||||||
|
clearActiveMagnetTarget: () => {
|
||||||
|
setActiveTarget(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user