feat(canvas): implement dropped connection resolution and enhance connection handling
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
|
||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
...overrides,
|
||||
} as RFNode;
|
||||
}
|
||||
|
||||
function createEdge(
|
||||
overrides: Partial<RFEdge> & Pick<RFEdge, "id" | "source" | "target">,
|
||||
): RFEdge {
|
||||
return {
|
||||
...overrides,
|
||||
} as RFEdge;
|
||||
}
|
||||
|
||||
function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.className = "react-flow__node";
|
||||
element.dataset.id = id;
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: rect.width ?? 200,
|
||||
bottom: rect.height ?? 120,
|
||||
width: rect.width ?? 200,
|
||||
height: rect.height ?? 120,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("resolveDroppedConnectionTarget", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("resolves a source-start body drop into a direct connection", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const targetNode = createNode({
|
||||
id: "node-target",
|
||||
type: "text",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const targetElement = makeNodeElement("node-target");
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [targetElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 340, y: 220 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, targetNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the pointer is over the canvas background", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => []),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 10, y: 10 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the free compare slot when dropping on a compare node body", () => {
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const compareNode = createNode({
|
||||
id: "node-compare",
|
||||
type: "compare",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const compareElement = makeNodeElement("node-compare", {
|
||||
width: 500,
|
||||
height: 380,
|
||||
});
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [compareElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 380, y: 290 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleType: "source",
|
||||
nodes: [sourceNode, compareNode],
|
||||
edges: [
|
||||
createEdge({
|
||||
id: "edge-left",
|
||||
source: "node-source",
|
||||
target: "node-compare",
|
||||
targetHandle: "left",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-compare",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "right",
|
||||
});
|
||||
});
|
||||
|
||||
it("reverses the connection when the drag starts from a target handle", () => {
|
||||
const droppedNode = createNode({
|
||||
id: "node-dropped",
|
||||
type: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const sourceNode = createNode({
|
||||
id: "node-source",
|
||||
type: "image",
|
||||
position: { x: 320, y: 200 },
|
||||
});
|
||||
const droppedElement = makeNodeElement("node-dropped");
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
value: vi.fn(() => [droppedElement]),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = resolveDroppedConnectionTarget({
|
||||
point: { x: 60, y: 60 },
|
||||
fromNodeId: "node-source",
|
||||
fromHandleId: "target-handle",
|
||||
fromHandleType: "target",
|
||||
nodes: [droppedNode, sourceNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: "node-dropped",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
});
|
||||
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect, useRef, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDroppedConnectionTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/components/canvas/canvas-helpers")
|
||||
>("@/components/canvas/canvas-helpers");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
resolveDroppedConnectionTarget: mocks.resolveDroppedConnectionTarget,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-reconnect", () => ({
|
||||
useCanvasReconnectHandlers: () => ({
|
||||
onReconnectStart: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onReconnectEnd: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasConnections> | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
helperResult: DroppedConnectionTarget | null;
|
||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
}: HookHarnessProps) {
|
||||
const [nodes] = useState<RFNode[]>([
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} },
|
||||
]);
|
||||
const [edges] = useState<RFEdge[]>([]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
const edgeReconnectSuccessful = useRef(true);
|
||||
const isReconnectDragActiveRef = useRef(false);
|
||||
const pendingConnectionCreatesRef = useRef(new Set<string>());
|
||||
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
||||
const setEdges = vi.fn();
|
||||
const setEdgeSyncNonce = vi.fn();
|
||||
|
||||
useEffect(() => {
|
||||
nodesRef.current = nodes;
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
edgesRef.current = edges;
|
||||
}, [edges]);
|
||||
|
||||
useEffect(() => {
|
||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||
}, [helperResult]);
|
||||
|
||||
const handlers = useCanvasConnections({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
edgeReconnectSuccessful,
|
||||
isReconnectDragActiveRef,
|
||||
pendingConnectionCreatesRef,
|
||||
resolvedRealIdByClientRequestRef,
|
||||
setEdges,
|
||||
setEdgeSyncNonce,
|
||||
screenToFlowPosition: (position) => position,
|
||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||
showConnectionRejectedToast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasConnections", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("creates an edge when a body drop lands on another node", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
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: undefined,
|
||||
});
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the node picker when the drop lands on the background", 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}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 123, clientY: 456 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 123, y: 456 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
|
||||
expect.objectContaining({
|
||||
screenX: 123,
|
||||
screenY: 456,
|
||||
flowX: 123,
|
||||
flowY: 456,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid body drop without opening the menu", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 300, clientY: 210 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 300, y: 210 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("reverses the edge direction when the drag starts from a target handle", 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={{
|
||||
sourceNodeId: "node-target",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 200, clientY: 200 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-source", type: "image" },
|
||||
fromHandle: { id: "target-handle", type: "target" },
|
||||
fromPosition: null,
|
||||
to: { x: 200, y: 200 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-target",
|
||||
targetNodeId: "node-source",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||
import { getSourceImage } from "@/lib/image-pipeline/contracts";
|
||||
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||
@@ -67,6 +68,110 @@ export function getConnectEndClientPoint(
|
||||
return null;
|
||||
}
|
||||
|
||||
export type DroppedConnectionTarget = {
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
|
||||
function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hit = document.elementsFromPoint(point.x, point.y).find((element) => {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
return (
|
||||
element.classList.contains("react-flow__node") &&
|
||||
typeof element.dataset.id === "string" &&
|
||||
element.dataset.id.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
return hit instanceof HTMLElement ? hit : null;
|
||||
}
|
||||
|
||||
function getCompareBodyDropTargetHandle(args: {
|
||||
point: { x: number; y: number };
|
||||
nodeElement: HTMLElement;
|
||||
targetNodeId: string;
|
||||
edges: RFEdge[];
|
||||
}): string | undefined {
|
||||
const { point, nodeElement, targetNodeId, edges } = args;
|
||||
const rect = nodeElement.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const incomingEdges = edges.filter(
|
||||
(edge) => edge.target === targetNodeId && edge.className !== "temp",
|
||||
);
|
||||
const leftTaken = incomingEdges.some((edge) => edge.targetHandle === "left");
|
||||
const rightTaken = incomingEdges.some((edge) => edge.targetHandle === "right");
|
||||
|
||||
if (!leftTaken && !rightTaken) {
|
||||
return point.y < midY ? "left" : "right";
|
||||
}
|
||||
|
||||
if (!leftTaken) {
|
||||
return "left";
|
||||
}
|
||||
|
||||
if (!rightTaken) {
|
||||
return "right";
|
||||
}
|
||||
|
||||
return point.y < midY ? "left" : "right";
|
||||
}
|
||||
|
||||
export function resolveDroppedConnectionTarget(args: {
|
||||
point: { x: number; y: number };
|
||||
fromNodeId: string;
|
||||
fromHandleId?: string;
|
||||
fromHandleType: "source" | "target";
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
}): DroppedConnectionTarget | null {
|
||||
const nodeElement = getNodeElementAtClientPoint(args.point);
|
||||
if (!nodeElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNodeId = nodeElement.dataset.id;
|
||||
if (!targetNodeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNode = args.nodes.find((node) => node.id === targetNodeId);
|
||||
if (!targetNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handles = NODE_HANDLE_MAP[targetNode.type ?? ""];
|
||||
|
||||
if (args.fromHandleType === "source") {
|
||||
return {
|
||||
sourceNodeId: args.fromNodeId,
|
||||
targetNodeId,
|
||||
sourceHandle: args.fromHandleId,
|
||||
targetHandle:
|
||||
targetNode.type === "compare"
|
||||
? getCompareBodyDropTargetHandle({
|
||||
point: args.point,
|
||||
nodeElement,
|
||||
targetNodeId,
|
||||
edges: args.edges,
|
||||
})
|
||||
: handles?.target,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceNodeId: targetNodeId,
|
||||
targetNodeId: args.fromNodeId,
|
||||
sourceHandle: handles?.source,
|
||||
targetHandle: args.fromHandleId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||
export type PendingEdgeSplit = {
|
||||
intersectedEdgeId: Id<"edges">;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Palette } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
|
||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeColorAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "color-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []);
|
||||
@@ -165,9 +153,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -176,9 +162,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = normalizeColorAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { TrendingUp } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<CurvesData>(() =>
|
||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeCurvesData({
|
||||
...cloneAdjustmentData(DEFAULT_CURVES_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "curves",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: CurvesData) => CurvesData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(CURVE_PRESETS), []);
|
||||
@@ -136,9 +124,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
const preset = CURVE_PRESETS[key];
|
||||
if (!preset) return;
|
||||
setPresetSelection(value);
|
||||
setLocalData(cloneAdjustmentData(preset));
|
||||
localDataRef.current = cloneAdjustmentData(preset);
|
||||
queueSave();
|
||||
applyLocalData(cloneAdjustmentData(preset));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,9 +134,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
if (!preset) return;
|
||||
const next = normalizeCurvesData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Focus } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
|
||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeDetailAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "detail-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []);
|
||||
@@ -176,9 +164,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -187,9 +173,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
if (!preset) return;
|
||||
const next = normalizeDetailAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Sun } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<LightAdjustData>(() =>
|
||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeLightAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "light-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []);
|
||||
@@ -187,9 +175,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -198,9 +184,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = normalizeLightAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
components/canvas/nodes/use-node-local-data.ts
Normal file
106
components/canvas/nodes/use-node-local-data.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
|
||||
function hashNodeData(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function logNodeDataDebug(event: string, payload: Record<string, unknown>): void {
|
||||
const nodeSyncDebugEnabled =
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
(globalThis as typeof globalThis & { __LEMONSPACE_DEBUG_NODE_SYNC__?: boolean })
|
||||
.__LEMONSPACE_DEBUG_NODE_SYNC__ === true;
|
||||
|
||||
if (!nodeSyncDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("[Canvas node debug]", event, payload);
|
||||
}
|
||||
|
||||
export function useNodeLocalData<T>({
|
||||
data,
|
||||
normalize,
|
||||
saveDelayMs,
|
||||
onSave,
|
||||
debugLabel,
|
||||
}: {
|
||||
data: unknown;
|
||||
normalize: (value: unknown) => T;
|
||||
saveDelayMs: number;
|
||||
onSave: (value: T) => Promise<void> | void;
|
||||
debugLabel: string;
|
||||
}) {
|
||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||
const localDataRef = useRef(localData);
|
||||
const hasPendingLocalChangesRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void onSave(localDataRef.current);
|
||||
}, saveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingData = normalize(data);
|
||||
const incomingHash = hashNodeData(incomingData);
|
||||
const localHash = hashNodeData(localDataRef.current);
|
||||
|
||||
if (incomingHash === localHash) {
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPendingLocalChangesRef.current) {
|
||||
logNodeDataDebug("skip-stale-external-data", {
|
||||
nodeType: debugLabel,
|
||||
incomingHash,
|
||||
localHash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
localDataRef.current = incomingData;
|
||||
setLocalDataState(incomingData);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data, debugLabel, normalize]);
|
||||
|
||||
const applyLocalData = useCallback(
|
||||
(next: T) => {
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
queueSave();
|
||||
},
|
||||
[queueSave],
|
||||
);
|
||||
|
||||
const updateLocalData = useCallback(
|
||||
(updater: (current: T) => T) => {
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
setLocalDataState((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[queueSave],
|
||||
);
|
||||
|
||||
return {
|
||||
localData,
|
||||
applyLocalData,
|
||||
updateLocalData,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
|
||||
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
|
||||
import { resolveDroppedConnectionTarget } from "./canvas-helpers";
|
||||
import {
|
||||
validateCanvasConnection,
|
||||
validateCanvasConnectionByType,
|
||||
@@ -138,6 +139,41 @@ export function useCanvasConnections({
|
||||
if (!pt) return;
|
||||
|
||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||
const droppedConnection = resolveDroppedConnectionTarget({
|
||||
point: pt,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
fromHandleType: fromHandle.type,
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
|
||||
if (droppedConnection) {
|
||||
const validationError = validateCanvasConnection(
|
||||
{
|
||||
source: droppedConnection.sourceNodeId,
|
||||
target: droppedConnection.targetNodeId,
|
||||
sourceHandle: droppedConnection.sourceHandle,
|
||||
targetHandle: droppedConnection.targetHandle,
|
||||
},
|
||||
nodesRef.current,
|
||||
edgesRef.current,
|
||||
);
|
||||
if (validationError) {
|
||||
showConnectionRejectedToast(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
void runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
|
||||
targetNodeId: droppedConnection.targetNodeId as Id<"nodes">,
|
||||
sourceHandle: droppedConnection.sourceHandle,
|
||||
targetHandle: droppedConnection.targetHandle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionDropMenu({
|
||||
screenX: pt.x,
|
||||
screenY: pt.y,
|
||||
@@ -148,7 +184,15 @@ export function useCanvasConnections({
|
||||
fromHandleType: fromHandle.type,
|
||||
});
|
||||
},
|
||||
[isReconnectDragActiveRef, screenToFlowPosition],
|
||||
[
|
||||
canvasId,
|
||||
edgesRef,
|
||||
isReconnectDragActiveRef,
|
||||
nodesRef,
|
||||
runCreateEdgeMutation,
|
||||
screenToFlowPosition,
|
||||
showConnectionRejectedToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleConnectionDropPick = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user