Files
lemonspace_app/components/canvas/use-canvas-connections.ts

680 lines
23 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd, OnConnectStart } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import {
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types";
import {
resolveCanvasMagnetTarget,
type CanvasMagnetTarget,
} from "./canvas-connection-magnetism";
import {
getConnectEndClientPoint,
hasHandleKey,
isOptimisticEdgeId,
isOptimisticNodeId,
logCanvasConnectionDebug,
normalizeHandle,
resolveDroppedConnectionTarget,
} from "./canvas-helpers";
import {
validateCanvasConnection,
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";
type UseCanvasConnectionsParams = {
canvasId: Id<"canvases">;
nodes: RFNode[];
edges: RFEdge[];
nodesRef: MutableRefObject<RFNode[]>;
edgesRef: MutableRefObject<RFEdge[]>;
edgeReconnectSuccessful: MutableRefObject<boolean>;
isReconnectDragActiveRef: MutableRefObject<boolean>;
pendingConnectionCreatesRef: MutableRefObject<Set<string>>;
resolvedRealIdByClientRequestRef: MutableRefObject<Map<string, Id<"nodes">>>;
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
setEdgeSyncNonce: Dispatch<SetStateAction<number>>;
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
syncPendingMoveForClientRequest: (
clientRequestId: string,
realId?: Id<"nodes">,
) => Promise<unknown>;
runCreateEdgeMutation: (args: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
}) => Promise<unknown>;
runSplitEdgeAtExistingNodeMutation: (args: {
canvasId: Id<"canvases">;
splitEdgeId: Id<"edges">;
middleNodeId: Id<"nodes">;
splitSourceHandle?: string;
splitTargetHandle?: string;
newNodeSourceHandle?: string;
newNodeTargetHandle?: string;
}) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
runSwapMixerInputsMutation: (args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
}) => Promise<unknown>;
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: CanvasNodeType;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
clientRequestId?: string;
sourceNodeId: string;
parentId?: Id<"nodes">;
zIndex?: number;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<Id<"nodes"> | string>;
runCreateNodeWithEdgeToTargetOnlineOnly: (args: {
canvasId: Id<"canvases">;
type: CanvasNodeType;
positionX: number;
positionY: number;
width: number;
height: number;
data: Record<string, unknown>;
clientRequestId?: string;
targetNodeId: string;
parentId?: Id<"nodes">;
zIndex?: number;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<Id<"nodes"> | string>;
showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void;
};
export function useCanvasConnections({
canvasId,
nodes,
edges,
nodesRef,
edgesRef,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
setEdges,
setEdgeSyncNonce,
screenToFlowPosition,
syncPendingMoveForClientRequest,
runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
runRemoveEdgeMutation,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
runSwapMixerInputsMutation,
showConnectionRejectedToast,
}: UseCanvasConnectionsParams) {
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
const isConnectDragActiveRef = useRef(false);
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
useEffect(() => {
connectionDropMenuRef.current = connectionDropMenu;
}, [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", {
sourceNodeId: connection.source ?? null,
targetNodeId: connection.target ?? null,
sourceHandle: connection.sourceHandle ?? null,
targetHandle: connection.targetHandle ?? null,
validationError,
});
showConnectionRejectedToast(validationError);
return;
}
if (!connection.source || !connection.target) {
logCanvasConnectionDebug("connect:missing-endpoint", {
sourceNodeId: connection.source ?? null,
targetNodeId: connection.target ?? null,
sourceHandle: connection.sourceHandle ?? null,
targetHandle: connection.targetHandle ?? null,
});
return;
}
logCanvasConnectionDebug("connect:direct", {
sourceNodeId: connection.source,
targetNodeId: connection.target,
sourceHandle: connection.sourceHandle ?? null,
targetHandle: connection.targetHandle ?? null,
});
void runCreateEdgeMutation({
canvasId,
sourceNodeId: connection.source as Id<"nodes">,
targetNodeId: connection.target as Id<"nodes">,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
});
} finally {
setActiveTarget(null);
}
},
[canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
);
const resolveMixerSwapReconnect = useCallback(
(oldEdge: RFEdge, newConnection: Connection, validationError: string) => {
if (validationError !== "mixer-handle-incoming-limit") {
return null;
}
if (!newConnection.target || oldEdge.target !== newConnection.target) {
return null;
}
const targetNode = nodes.find((node) => node.id === newConnection.target);
if (!targetNode || targetNode.type !== "mixer") {
return null;
}
const normalizeMixerHandle = (handle: string | null | undefined): "base" | "overlay" | null => {
if (handle == null || handle === "" || handle === "null") {
return "base";
}
if (handle === "base" || handle === "overlay") {
return handle;
}
return null;
};
const oldHandle = normalizeMixerHandle(oldEdge.targetHandle);
const requestedHandle = normalizeMixerHandle(newConnection.targetHandle);
if (!oldHandle || !requestedHandle || oldHandle === requestedHandle) {
return null;
}
const mixerIncomingEdges = edges.filter(
(edge) =>
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id) &&
edge.target === newConnection.target,
);
if (mixerIncomingEdges.length !== 2) {
return null;
}
const otherEdge = mixerIncomingEdges.find(
(candidate) => candidate.id !== oldEdge.id,
);
if (!otherEdge) {
return null;
}
const otherHandle = normalizeMixerHandle(otherEdge.targetHandle);
if (!otherHandle || otherHandle !== requestedHandle) {
return null;
}
return {
edgeId: oldEdge.id as Id<"edges">,
otherEdgeId: otherEdge.id as Id<"edges">,
nextEdgeHandle: requestedHandle,
nextOtherEdgeHandle: oldHandle,
};
},
[edges, nodes],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (!isConnectDragActiveRef.current) {
setActiveTarget(null);
logCanvasConnectionDebug("connect:end-ignored", {
reason: "drag-not-active",
isValid: connectionState.isValid ?? null,
fromNodeId: connectionState.fromNode?.id ?? null,
fromHandleId: connectionState.fromHandle?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null,
});
return;
}
isConnectDragActiveRef.current = false;
try {
if (isReconnectDragActiveRef.current) {
logCanvasConnectionDebug("connect:end-ignored", {
reason: "reconnect-active",
isValid: connectionState.isValid ?? null,
fromNodeId: connectionState.fromNode?.id ?? null,
fromHandleId: connectionState.fromHandle?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null,
});
return;
}
if (connectionState.isValid === true) {
logCanvasConnectionDebug("connect:end-ignored", {
reason: "react-flow-valid-connection",
fromNodeId: connectionState.fromNode?.id ?? null,
fromHandleId: connectionState.fromHandle?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null,
});
return;
}
const fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle;
if (!fromNode || !fromHandle) {
logCanvasConnectionDebug("connect:end-aborted", {
reason: "missing-from-node-or-handle",
fromNodeId: fromNode?.id ?? null,
fromHandleId: fromHandle?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null,
});
return;
}
const pt = getConnectEndClientPoint(event);
if (!pt) {
logCanvasConnectionDebug("connect:end-aborted", {
reason: "missing-client-point",
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
});
return;
}
logCanvasConnectionDebug("connect:end", {
point: pt,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
toNodeId: connectionState.toNode?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null,
});
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
let droppedConnection = resolveDroppedConnectionTarget({
point: pt,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
nodes: nodesRef.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", {
point: pt,
flow,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
droppedConnection,
});
if (droppedConnection) {
const validationError = validateCanvasConnection(
{
source: droppedConnection.sourceNodeId,
target: droppedConnection.targetNodeId,
sourceHandle: droppedConnection.sourceHandle ?? null,
targetHandle: droppedConnection.targetHandle ?? null,
},
nodesRef.current,
edgesRef.current,
);
if (validationError) {
const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id);
const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""];
const incomingEdges = edgesRef.current.filter(
(edge) =>
edge.target === droppedConnection.targetNodeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
);
const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined;
const splitValidationError =
validationError === "adjustment-incoming-limit" &&
droppedConnection.sourceNodeId === fromNode.id &&
fromHandle.type === "source" &&
fullFromNode !== undefined &&
splitHandles !== undefined &&
hasHandleKey(splitHandles, "source") &&
hasHandleKey(splitHandles, "target") &&
incomingEdge !== undefined &&
incomingEdge.source !== fullFromNode.id &&
incomingEdge.target !== fullFromNode.id
? validateCanvasEdgeSplit({
nodes: nodesRef.current,
edges: edgesRef.current,
splitEdge: incomingEdge,
middleNode: fullFromNode,
})
: null;
if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) {
logCanvasConnectionDebug("connect:end-auto-split", {
point: pt,
flow,
droppedConnection,
splitEdgeId: incomingEdge.id,
middleNodeId: fullFromNode.id,
});
void runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: incomingEdge.id as Id<"edges">,
middleNodeId: fullFromNode.id as Id<"nodes">,
splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle),
splitTargetHandle: normalizeHandle(incomingEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target),
});
return;
}
logCanvasConnectionDebug("connect:end-drop-rejected", {
point: pt,
flow,
droppedConnection,
validationError,
attemptedAutoSplit:
validationError === "adjustment-incoming-limit" &&
droppedConnection.sourceNodeId === fromNode.id &&
fromHandle.type === "source",
splitValidationError,
});
showConnectionRejectedToast(validationError);
return;
}
logCanvasConnectionDebug("connect:end-create-edge", {
point: pt,
flow,
droppedConnection,
});
void runCreateEdgeMutation({
canvasId,
sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">,
targetNodeId: droppedConnection.targetNodeId as Id<"nodes">,
sourceHandle: droppedConnection.sourceHandle,
targetHandle: droppedConnection.targetHandle,
});
return;
}
logCanvasConnectionDebug("connect:end-open-menu", {
point: pt,
flow,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type,
});
setConnectionDropMenu({
screenX: pt.x,
screenY: pt.y,
flowX: flow.x,
flowY: flow.y,
fromNodeId: fromNode.id as Id<"nodes">,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
});
} finally {
setActiveTarget(null);
}
},
[
canvasId,
edgesRef,
isReconnectDragActiveRef,
nodesRef,
runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation,
screenToFlowPosition,
setActiveTarget,
showConnectionRejectedToast,
activeTarget,
toDroppedConnectionFromMagnetTarget,
],
);
const handleConnectionDropPick = useCallback(
(template: CanvasNodeTemplate) => {
const ctx = connectionDropMenuRef.current;
if (!ctx) return;
const fromNode = nodesRef.current.find((node) => node.id === ctx.fromNodeId);
if (!fromNode) {
showConnectionRejectedToast("unknown-node");
return;
}
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
pendingConnectionCreatesRef.current.add(clientRequestId);
const handles = NODE_HANDLE_MAP[template.type];
const width = template.width ?? defaults.width;
const height = template.height ?? defaults.height;
const data = {
...defaults.data,
...(template.defaultData as Record<string, unknown>),
canvasId,
};
const base = {
canvasId,
type: template.type,
positionX: ctx.flowX,
positionY: ctx.flowY,
width,
height,
data,
clientRequestId,
};
const settle = (realId: Id<"nodes">) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error("[Canvas] settle syncPendingMove failed", error);
},
);
};
if (ctx.fromHandleType === "source") {
const validationError = validateCanvasConnectionByType({
sourceType: fromNode.type ?? "",
targetType: template.type,
targetNodeId: `__pending_${template.type}_${Date.now()}`,
targetHandle: handles?.target,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeFromSourceOnlineOnly({
...base,
sourceNodeId: ctx.fromNodeId,
sourceHandle: ctx.fromHandleId,
targetHandle: handles?.target ?? undefined,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
const settledRealId = realId as Id<"nodes">;
resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId);
settle(settledRealId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
});
} else {
const validationError = validateCanvasConnectionByType({
sourceType: template.type,
targetType: fromNode.type ?? "",
targetNodeId: fromNode.id,
targetHandle: ctx.fromHandleId,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeToTargetOnlineOnly({
...base,
targetNodeId: ctx.fromNodeId,
sourceHandle: handles?.source ?? undefined,
targetHandle: ctx.fromHandleId,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
const settledRealId = realId as Id<"nodes">;
resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId);
settle(settledRealId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeToTarget failed", error);
});
}
},
[
canvasId,
edgesRef,
nodesRef,
pendingConnectionCreatesRef,
resolvedRealIdByClientRequestRef,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
setEdgeSyncNonce,
showConnectionRejectedToast,
syncPendingMoveForClientRequest,
],
);
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
canvasId,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
setEdges,
runCreateEdgeMutation,
runRemoveEdgeMutation,
runSwapMixerInputsMutation,
validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
resolveMixerSwapReconnect,
onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
clearActiveMagnetTarget: () => {
setActiveTarget(null);
},
});
return {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectStart,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
};
}