836 lines
50 KiB
TypeScript
836 lines
50 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
|
||
import { App } from "antd";
|
||
import { nanoid } from "nanoid";
|
||
import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
|
||
|
||
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
|
||
import { getNodeSpec } from "@/constant/canvas";
|
||
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||
import { batchReferenceHandleAtY } from "@/lib/canvas/canvas-batch-table";
|
||
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
|
||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
|
||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
|
||
import { normalizeRunningHubCapability, type AiConfig } from "@/stores/use-config-store";
|
||
import { useUserStore } from "@/stores/use-user-store";
|
||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
|
||
import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows";
|
||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||
|
||
type UseCanvasConnectionControllerOptions = {
|
||
projectId: string;
|
||
config: AiConfig;
|
||
defaultDrawingEngine: CanvasDrawingEngine;
|
||
nodesRef: { current: CanvasNodeData[] };
|
||
connectionsRef: { current: CanvasConnection[] };
|
||
viewportRef: { current: ViewportTransform };
|
||
scriptScrollTopById: Record<string, number>;
|
||
screenToCanvas: (clientX: number, clientY: number) => Position;
|
||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
|
||
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
|
||
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
|
||
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
|
||
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
|
||
setDrawingNodeId: Dispatch<SetStateAction<string | null>>;
|
||
onReplaceReference?: (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => void;
|
||
};
|
||
|
||
export type ConnectionReplaceHover = {
|
||
targetNodeId: string;
|
||
referenceLabel: string;
|
||
clientX: number;
|
||
clientY: number;
|
||
};
|
||
|
||
type ConnectionDropTarget = {
|
||
nodeId: string | null;
|
||
handleId?: string;
|
||
anchorRatio?: number;
|
||
isNearNode: boolean;
|
||
};
|
||
|
||
type BatchConnectionDropTarget = ConnectionDropTarget;
|
||
|
||
// LibTV's 80px world-space quick-add zone renders at roughly 110px on the
|
||
// reference canvas. Use the same generous 112px screen-space diameter here,
|
||
// while retaining a circular boundary around the corresponding side anchor.
|
||
const CONNECTION_SNAP_RADIUS = 56;
|
||
const NODE_STATUS_IDLE = "idle" as const;
|
||
|
||
function selectRunningHubWorkflow(config: AiConfig) {
|
||
const capability = normalizeRunningHubCapability(config.runningHub.capability);
|
||
return config.runningHub.workflows.find((item) => item.workflowId.trim() === config.runningHub.workflowId.trim()
|
||
&& (item.kind === "app" ? "app" : "workflow") === config.runningHub.selectedKind)
|
||
|| config.runningHub.workflows.find((item) => normalizeRunningHubCapability(item.capability, capability) === capability)
|
||
|| config.runningHub.workflows[0];
|
||
}
|
||
|
||
export function useCanvasConnectionController({
|
||
projectId,
|
||
config,
|
||
defaultDrawingEngine,
|
||
nodesRef,
|
||
connectionsRef,
|
||
viewportRef,
|
||
scriptScrollTopById,
|
||
screenToCanvas,
|
||
setNodes,
|
||
setConnections,
|
||
setSelectedNodeIds,
|
||
setSelectedConnectionId,
|
||
setContextMenu,
|
||
setDialogNodeId,
|
||
setDrawingNodeId,
|
||
onReplaceReference,
|
||
}: UseCanvasConnectionControllerOptions) {
|
||
const { message } = App.useApp();
|
||
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
|
||
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
|
||
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
|
||
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
|
||
const [connectionApproach, setConnectionApproach] = useState<CanvasConnectionApproach>(null);
|
||
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
|
||
const [connectionReplaceHover, setConnectionReplaceHover] = useState<ConnectionReplaceHover | null>(null);
|
||
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
|
||
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
|
||
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
|
||
const connectingParamsRef = useRef(connectingParams);
|
||
const connectingPointerIdRef = useRef<number | null>(null);
|
||
const connectingPointerStartRef = useRef<Position | null>(null);
|
||
const pendingConnectionCreateRef = useRef(pendingConnectionCreate);
|
||
const batchConnectionPreviewRef = useRef<CanvasBatchConnectionPreview | null>(null);
|
||
const batchConnectionPointerIdRef = useRef<number | null>(null);
|
||
const batchConnectionPointerStartRef = useRef<Position | null>(null);
|
||
const pointerMoveFrameRef = useRef<number | null>(null);
|
||
const latestPointerMoveRef = useRef<PointerEvent | null>(null);
|
||
const hoveredReplaceElRef = useRef<HTMLElement | null>(null);
|
||
|
||
const updateConnectionReplaceHover = useCallback((element: HTMLElement | null, clientX = 0, clientY = 0) => {
|
||
if (hoveredReplaceElRef.current === element) {
|
||
if (element) {
|
||
setConnectionReplaceHover((prev) => prev ? { ...prev, clientX, clientY } : null);
|
||
}
|
||
return;
|
||
}
|
||
if (hoveredReplaceElRef.current) {
|
||
hoveredReplaceElRef.current.removeAttribute("data-connection-hover-replace");
|
||
}
|
||
hoveredReplaceElRef.current = element;
|
||
if (element) {
|
||
element.setAttribute("data-connection-hover-replace", "true");
|
||
const targetNodeId = element.getAttribute("data-target-node-id") || "";
|
||
const referenceLabel = element.getAttribute("data-reference-label") || "参考图";
|
||
setConnectionReplaceHover({ targetNodeId, referenceLabel, clientX, clientY });
|
||
} else {
|
||
setConnectionReplaceHover(null);
|
||
}
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
connectingParamsRef.current = connectingParams;
|
||
pendingConnectionCreateRef.current = pendingConnectionCreate;
|
||
}, [connectingParams, pendingConnectionCreate]);
|
||
|
||
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
|
||
batchConnectionPreviewRef.current = next;
|
||
setBatchConnectionPreview(next);
|
||
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, next && next.status !== "invalid" ? next.targetNodeId : null, next?.mouseWorld || { x: 0, y: 0 }));
|
||
}, []);
|
||
|
||
const clearBatchConnection = useCallback(() => {
|
||
batchConnectionPointerIdRef.current = null;
|
||
batchConnectionPointerStartRef.current = null;
|
||
updateBatchConnectionPreview(null);
|
||
}, [updateBatchConnectionPreview]);
|
||
|
||
const setConnecting = useCallback((next: ConnectionHandle | null) => {
|
||
connectingParamsRef.current = next;
|
||
setConnectingParams(next);
|
||
if (!next) {
|
||
updateConnectionReplaceHover(null);
|
||
setConnectionApproach(null);
|
||
connectingPointerIdRef.current = null;
|
||
connectingPointerStartRef.current = null;
|
||
setConnectionTargetNodeId(null);
|
||
setConnectionTargetAnchorRatio(undefined);
|
||
}
|
||
}, [updateConnectionReplaceHover]);
|
||
|
||
const closeConnectionCreateMenu = useCallback(() => {
|
||
pendingConnectionCreateRef.current = null;
|
||
setPendingConnectionCreate(null);
|
||
}, []);
|
||
|
||
const cancelPendingConnectionCreate = useCallback(() => {
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
clearBatchConnection();
|
||
}, [clearBatchConnection, closeConnectionCreateMenu, setConnecting]);
|
||
|
||
const previewBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string | null, targetHandleId: string | undefined, targetAnchorRatio: number | undefined, mouseWorld: Position) => {
|
||
const plan = targetNodeId
|
||
? planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config })
|
||
: null;
|
||
const eligibleSourceCount = sourceNodeIds.filter((id) => {
|
||
const node = nodesRef.current.find((item) => item.id === id);
|
||
return Boolean(node && !batchSourceRestriction(node));
|
||
}).length;
|
||
const status = !targetNodeId || !plan ? "idle" : plan.connections.length === eligibleSourceCount ? "valid" : plan.connections.length ? "partial" : "invalid";
|
||
updateBatchConnectionPreview({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, mouseWorld, status });
|
||
return plan;
|
||
}, [config, connectionsRef, nodesRef, updateBatchConnectionPreview]);
|
||
|
||
const commitBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => {
|
||
const plan = planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config });
|
||
if (!plan.connections.length) {
|
||
const reason = plan.skipped[0]?.reason || "没有可建立的连接";
|
||
message.warning(reason);
|
||
return plan;
|
||
}
|
||
setNodes((currentNodes) => plan.connections.reduce((current, connection) => attachNodeToStoryboardRow(current, connection), currentNodes));
|
||
setConnections((currentConnections) => [...currentConnections, ...plan.connections]);
|
||
setContextMenu(null);
|
||
const skippedCount = plan.skipped.length;
|
||
const duplicateCount = plan.duplicates.length;
|
||
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
|
||
if (skippedCount) message.warning(`已连接 ${plan.connected.length} 个节点${suffix}:${plan.skipped[0].reason}`);
|
||
else message.success(`已连接 ${plan.connected.length} 个节点${suffix}`);
|
||
return plan;
|
||
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||
|
||
const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => {
|
||
if (current.nodeId === targetNodeId) return;
|
||
const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType);
|
||
if (!connection) {
|
||
message.warning("配置节点之间不能连接");
|
||
return;
|
||
}
|
||
const { fromNodeId, toNodeId } = connection;
|
||
const fromHandleId = fromNodeId === current.nodeId ? current.handleId : targetHandleId;
|
||
const toHandleId = toNodeId === current.nodeId ? current.handleId : targetHandleId;
|
||
const fromAnchorRatio = fromNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio;
|
||
const toAnchorRatio = toNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio;
|
||
const policyError = canvasConnectionError(config, nodesRef.current, connectionsRef.current, { fromNodeId, toNodeId });
|
||
if (policyError) {
|
||
message.warning(policyError);
|
||
return;
|
||
}
|
||
const exists = connectionsRef.current.find((item) => item.fromNodeId === fromNodeId && item.toNodeId === toNodeId && item.fromHandleId === fromHandleId && item.toHandleId === toHandleId);
|
||
if (exists) {
|
||
setConnections((currentConnections) => currentConnections.map((item) => item.id === exists.id ? { ...item, fromAnchorRatio, toAnchorRatio } : item));
|
||
} else {
|
||
setConnections((currentConnections) => [...currentConnections, { id: `conn-${Date.now()}`, fromNodeId, toNodeId, fromHandleId, toHandleId, fromAnchorRatio, toAnchorRatio }]);
|
||
setNodes((currentNodes) => attachNodeToStoryboardRow(currentNodes, { fromNodeId, toNodeId, fromHandleId, toHandleId }));
|
||
}
|
||
setContextMenu(null);
|
||
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||
|
||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||
const nodeType = type;
|
||
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
|
||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const batchSourceNodeIds = pending.batchSourceNodeIds?.length ? Array.from(new Set(pending.batchSourceNodeIds)) : [];
|
||
const batchSourceNodes = batchSourceNodeIds
|
||
.map((nodeId) => nodesRef.current.find((node) => node.id === nodeId))
|
||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||
const storyboardRow = batchSourceNodeIds.length ? undefined : nodeType === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
|
||
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
|
||
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||
const batchScriptPrompt = batchSourceNodes
|
||
.filter((node) => node.type === CanvasNodeType.Text)
|
||
.map((node) => (node.metadata?.content || node.metadata?.prompt || "").trim())
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
const scriptPrompt = nodeType === CanvasNodeType.Script
|
||
? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""
|
||
: "";
|
||
const selectedWorkflowProvider = nodeType === CanvasNodeType.Config
|
||
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : undefined)
|
||
: undefined;
|
||
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
|
||
message.error("RunningHub 工作流插件未启用");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined;
|
||
const workflowCapability = normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
|
||
const metadata: CanvasNodeMetadata | undefined = nodeType === CanvasNodeType.Config
|
||
? {
|
||
generationMode: selectedWorkflowProvider ? workflowCapability === "video" ? "video" as const : workflowCapability === "audio" ? "audio" as const : "image" as const : "image" as const,
|
||
workflowProvider: selectedWorkflowProvider || "model",
|
||
status: NODE_STATUS_IDLE,
|
||
...(selectedWorkflowProvider === "runninghub" ? {
|
||
workflowTitle: "RunningHub 工作流",
|
||
...(runningHubWorkflow ? { runningHubWorkflowId: runningHubWorkflow.workflowId, runningHubWorkflowKind: runningHubWorkflow.kind === "app" ? "app" as const : "workflow" as const } : {}),
|
||
} : {})
|
||
}
|
||
: nodeType === CanvasNodeType.Drawing
|
||
? { drawingEngine: defaultDrawingEngine }
|
||
: nodeType === CanvasNodeType.Script && scriptPrompt
|
||
? { prompt: scriptPrompt, composerContent: scriptPrompt }
|
||
: nodeType === CanvasNodeType.Video && storyboardRow
|
||
? { prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(storyboardRow, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||
: undefined;
|
||
const sourceNodeForQuickCreate = pending.quick ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||
const spec = getNodeSpec(nodeType);
|
||
const anchorY = sourceNodeForQuickCreate ? sourceNodeForQuickCreate.position.y + sourceNodeForQuickCreate.height * (pending.connection.anchorRatio ?? 0.5) : pending.position.y;
|
||
const position = sourceNodeForQuickCreate
|
||
? {
|
||
x: pending.connection.handleType === "source"
|
||
? sourceNodeForQuickCreate.position.x + sourceNodeForQuickCreate.width + 96 + spec.width / 2
|
||
: sourceNodeForQuickCreate.position.x - 96 - spec.width / 2,
|
||
y: anchorY,
|
||
}
|
||
: batchSourceNodeIds.length
|
||
? pending.position
|
||
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
|
||
const newNode = createCanvasNode(nodeType, position, metadata);
|
||
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = "RunningHub 工作流";
|
||
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
|
||
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
|
||
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const batchPlan = batchSourceNodeIds.length
|
||
? planBatchConnections({ sourceNodeIds: batchSourceNodeIds, targetNodeId: newNode.id, nodes: [...nodesRef.current, newNode], connections: connectionsRef.current, config, allowCapacityOverflow: true })
|
||
: null;
|
||
if (batchPlan) {
|
||
if (!batchPlan.connections.length) {
|
||
const detail = batchPlan.skipped.slice(0, 3).map((item) => item.reason).join(";");
|
||
message.warning(detail ? `没有可建立的连接:${detail}` : "没有可建立的连接");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const nextConnections = [...connectionsRef.current, ...batchPlan.connections];
|
||
const nextNodes = batchPlan.connections.reduce((currentNodes, connection) => attachNodeToStoryboardRow(currentNodes, connection), [...nodesRef.current, newNode]);
|
||
nodesRef.current = nextNodes;
|
||
connectionsRef.current = nextConnections;
|
||
setNodes(nextNodes);
|
||
setConnections(nextConnections);
|
||
setSelectedNodeIds(new Set([newNode.id]));
|
||
setSelectedConnectionId(null);
|
||
if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
|
||
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
|
||
const skippedCount = batchPlan.skipped.length;
|
||
const duplicateCount = batchPlan.duplicates.length;
|
||
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
|
||
if (skippedCount) message.warning(`已创建并连接 ${batchPlan.connections.length} 个源节点${suffix}:${batchPlan.skipped[0].reason}`);
|
||
else message.success(`已创建节点并连接 ${batchPlan.connections.length} 个源节点${suffix}`);
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
|
||
if (!connection) {
|
||
message.warning("当前节点不能建立这条连线");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
const policyError = canvasConnectionError(config, [...nodesRef.current, newNode], connectionsRef.current, connection);
|
||
if (policyError) {
|
||
message.warning(policyError);
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
if (nodeType === CanvasNodeType.Drawing) {
|
||
const drawingSourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
|
||
const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : "";
|
||
if (pending.connection.handleType !== "source" || !drawingSourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
|
||
message.error("只有已有图片内容的输出连线可以创建绘图");
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
try {
|
||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, defaultDrawingEngine, {
|
||
url: sourceUrl,
|
||
storageKey: drawingSourceNode.metadata?.storageKey,
|
||
name: drawingSourceNode.title || "来源图片",
|
||
mimeType: drawingSourceNode.metadata?.mimeType,
|
||
});
|
||
newNode.title = `${drawingSourceNode.title || "图片"} · 绘图`;
|
||
newNode.metadata = {
|
||
...newNode.metadata,
|
||
drawingEngine: saved.engine,
|
||
drawingRevision: saved.revision,
|
||
drawingUpdatedAt: saved.updatedAt,
|
||
drawingShapeCount: saved.shapeCount,
|
||
drawingPageCount: saved.pageCount,
|
||
};
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? `创建绘图失败:${error.message}` : "创建绘图失败");
|
||
return;
|
||
}
|
||
}
|
||
const fromHandleId = connection.fromNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined;
|
||
const toHandleId = connection.toNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined;
|
||
const fromAnchorRatio = connection.fromNodeId === pending.connection.nodeId ? pending.connection.anchorRatio : 0.5;
|
||
const toAnchorRatio = connection.toNodeId === pending.connection.nodeId ? pending.connection.anchorRatio : 0.5;
|
||
const connected = { ...connection, fromHandleId, toHandleId, fromAnchorRatio, toAnchorRatio };
|
||
setNodes((currentNodes) => attachNodeToStoryboardRow([...currentNodes, newNode], connected));
|
||
setConnections((currentConnections) => [...currentConnections, { id: nanoid(), ...connected }]);
|
||
setSelectedNodeIds(new Set([newNode.id]));
|
||
setSelectedConnectionId(null);
|
||
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
|
||
else if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
|
||
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.BatchTable && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
|
||
closeConnectionCreateMenu();
|
||
setConnecting(null);
|
||
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
|
||
|
||
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
|
||
const nodeType = type;
|
||
if (nodeType === CanvasNodeType.Config) {
|
||
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return "RunningHub 工作流插件未启用";
|
||
}
|
||
if (pending.batchSourceNodeIds?.length) {
|
||
if (nodeType === CanvasNodeType.Drawing || nodeType === CanvasNodeType.Config) return "批量连接暂不支持此节点类型";
|
||
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: getNodeSpec(nodeType).width, height: getNodeSpec(nodeType).height };
|
||
const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true });
|
||
return plan.connections.length ? "" : plan.skipped[0]?.reason || "当前选中的节点不能连接到此类型";
|
||
}
|
||
const spec = getNodeSpec(nodeType);
|
||
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: spec.width, height: spec.height };
|
||
const pendingNodes = [...nodesRef.current, pendingNode];
|
||
const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType);
|
||
if (!connection) return "当前节点类型不能这样连接";
|
||
return canvasConnectionError(config, pendingNodes, connectionsRef.current, connection);
|
||
}, [config, connectionsRef, nodesRef, runtimeStatuses]);
|
||
|
||
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
|
||
const world = screenToCanvas(clientX, clientY);
|
||
const scale = Math.max(viewportRef.current.k, 0.05);
|
||
const handleRadius = CONNECTION_SNAP_RADIUS / scale;
|
||
let isNearNode = false;
|
||
let bestNodeId: string | null = null;
|
||
let bestHandleId: string | undefined;
|
||
let bestAnchorRatio: number | undefined;
|
||
let bestPriority = Number.POSITIVE_INFINITY;
|
||
|
||
[...nodesRef.current]
|
||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current) && !isFrameNode(node))
|
||
.reverse()
|
||
.forEach((node) => {
|
||
const scrollTop = scriptScrollTopById[node.id] || 0;
|
||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
|
||
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
|
||
// Ordinary nodes expose one centered input/output port. Only
|
||
// storyboard rows have a meaningful vertical target position.
|
||
const targetAnchorRatio = undefined;
|
||
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
|
||
const dx = world.x - anchor.x;
|
||
const dy = world.y - anchor.y;
|
||
// Do not treat the node body or a rectangular padding band as a
|
||
// connection target. LibTV snaps only inside the circular
|
||
// quick-add area centred on the corresponding side handle.
|
||
const hitsSnapZone = dx * dx + dy * dy <= handleRadius * handleRadius;
|
||
if (!hitsSnapZone) return;
|
||
isNearNode = true;
|
||
const normalized = node.id === current.nodeId ? null : normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType);
|
||
if (!normalized || canvasConnectionError(config, nodesRef.current, connectionsRef.current, normalized)) return;
|
||
if (1 < bestPriority) {
|
||
bestNodeId = node.id;
|
||
bestHandleId = targetHandleId;
|
||
bestAnchorRatio = targetAnchorRatio;
|
||
bestPriority = 1;
|
||
}
|
||
});
|
||
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
|
||
}, [config, connectionsRef, nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
|
||
|
||
const getBatchConnectionDropTarget = useCallback((clientX: number, clientY: number, sourceNodeIds: string[]): BatchConnectionDropTarget => {
|
||
const source = sourceNodeIds
|
||
.map((id) => nodesRef.current.find((node) => node.id === id))
|
||
.find((node): node is CanvasNodeData => Boolean(node && !batchSourceRestriction(node)));
|
||
if (!source) return { nodeId: null, isNearNode: false };
|
||
|
||
const world = screenToCanvas(clientX, clientY);
|
||
const scale = Math.max(viewportRef.current.k, 0.05);
|
||
const handleRadius = CONNECTION_SNAP_RADIUS / scale;
|
||
let isNearNode = false;
|
||
let bestNodeId: string | null = null;
|
||
let bestHandleId: string | undefined;
|
||
let bestAnchorRatio: number | undefined;
|
||
let bestPriority = Number.POSITIVE_INFINITY;
|
||
const current: ConnectionHandle = { nodeId: source.id, handleType: "source" };
|
||
|
||
[...nodesRef.current]
|
||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current) && !isFrameNode(node))
|
||
.reverse()
|
||
.forEach((node) => {
|
||
const scrollTop = scriptScrollTopById[node.id] || 0;
|
||
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : node.type === CanvasNodeType.BatchTable ? batchReferenceHandleAtY(node, world.y, handleRadius) : undefined;
|
||
if ((node.type === CanvasNodeType.Script || node.type === CanvasNodeType.BatchTable) && !targetHandleId) return;
|
||
const targetAnchorRatio = undefined;
|
||
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
|
||
const dx = world.x - anchor.x;
|
||
const dy = world.y - anchor.y;
|
||
const hitsSnapZone = dx * dx + dy * dy <= handleRadius * handleRadius;
|
||
if (!hitsSnapZone) return;
|
||
isNearNode = true;
|
||
if (!hasBatchConnectionCandidate(sourceNodeIds, node.id, nodesRef.current)) return;
|
||
if (1 < bestPriority) {
|
||
bestNodeId = node.id;
|
||
bestHandleId = targetHandleId;
|
||
bestAnchorRatio = targetAnchorRatio;
|
||
bestPriority = 1;
|
||
}
|
||
});
|
||
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
|
||
}, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
|
||
|
||
const startBatchConnection = useCallback((event: ReactPointerEvent, sourceNodeIds: string[]) => {
|
||
const eligible = sourceNodeIds.filter((id) => {
|
||
const node = nodesRef.current.find((item) => item.id === id);
|
||
return Boolean(node && !batchSourceRestriction(node));
|
||
});
|
||
if (!eligible.length) {
|
||
message.warning("当前选区没有可作为连接源的节点");
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
batchConnectionPointerIdRef.current = event.pointerId;
|
||
batchConnectionPointerStartRef.current = { x: event.clientX, y: event.clientY };
|
||
setSelectedConnectionId(null);
|
||
const mouseWorld = screenToCanvas(event.clientX, event.clientY);
|
||
previewBatchConnection(eligible, null, undefined, undefined, mouseWorld);
|
||
}, [message, nodesRef, previewBatchConnection, screenToCanvas, setSelectedConnectionId]);
|
||
|
||
const beginBatchConnectionMode = useCallback((sourceNodeIds: string[]) => {
|
||
const eligible = sourceNodeIds.filter((id) => {
|
||
const node = nodesRef.current.find((item) => item.id === id);
|
||
return Boolean(node && !batchSourceRestriction(node));
|
||
});
|
||
const source = eligible.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => Boolean(node));
|
||
if (!source) {
|
||
message.warning("当前选区没有可作为连接源的节点");
|
||
return;
|
||
}
|
||
previewBatchConnection(eligible, null, undefined, undefined, { x: source.position.x + source.width, y: source.position.y + source.height / 2 });
|
||
setSelectedConnectionId(null);
|
||
}, [message, nodesRef, previewBatchConnection, setSelectedConnectionId]);
|
||
|
||
const finishBatchConnection = useCallback((clientX: number, clientY: number) => {
|
||
const batch = batchConnectionPreviewRef.current;
|
||
if (!batch) return false;
|
||
const target = getBatchConnectionDropTarget(clientX, clientY, batch.sourceNodeIds);
|
||
if (!target.nodeId) return false;
|
||
commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio);
|
||
clearBatchConnection();
|
||
return true;
|
||
}, [clearBatchConnection, commitBatchConnection, getBatchConnectionDropTarget]);
|
||
|
||
const openBatchConnectionCreateMenu = useCallback((clientX: number, clientY: number) => {
|
||
const batch = batchConnectionPreviewRef.current;
|
||
if (!batch) return false;
|
||
const position = screenToCanvas(clientX, clientY);
|
||
const request = buildBatchConnectionCreateRequest(batch.sourceNodeIds, nodesRef.current, position);
|
||
if (!request) {
|
||
clearBatchConnection();
|
||
message.warning("当前选区没有可作为连接源的节点");
|
||
return false;
|
||
}
|
||
const pending: PendingConnectionCreate = request;
|
||
pendingConnectionCreateRef.current = pending;
|
||
setPendingConnectionCreate(pending);
|
||
setMouseWorld(position);
|
||
clearBatchConnection();
|
||
return true;
|
||
}, [clearBatchConnection, message, nodesRef, screenToCanvas]);
|
||
|
||
const handleBatchConnectionTargetClick = useCallback((event: ReactPointerEvent | ReactMouseEvent) => {
|
||
if (!batchConnectionPreviewRef.current) return false;
|
||
const completed = finishBatchConnection(event.clientX, event.clientY);
|
||
if (!completed) message.warning("请点击目标节点的输入端");
|
||
return true;
|
||
}, [finishBatchConnection, message]);
|
||
|
||
const finishConnection = useCallback((clientX: number, clientY: number) => {
|
||
updateConnectionReplaceHover(null);
|
||
if (pendingConnectionCreateRef.current) return;
|
||
const currentConnection = connectingParamsRef.current;
|
||
if (!currentConnection) return;
|
||
|
||
// 1. 如果是从输出端(source)拖出的连线,检查是否拖到了参考图卡片或参考区上进行替换
|
||
if (currentConnection.handleType === "source" && typeof document !== "undefined") {
|
||
const hitElement = document.elementFromPoint(clientX, clientY);
|
||
if (hitElement) {
|
||
// A) 直接释放在提示词面板的参考图卡片上
|
||
const refChip = hitElement.closest<HTMLElement>("[data-reference-chip]");
|
||
if (refChip) {
|
||
const targetNodeId = refChip.getAttribute("data-target-node-id");
|
||
const oldNodeId = refChip.getAttribute("data-reference-node-id");
|
||
const oldLabel = refChip.getAttribute("data-reference-label") || "";
|
||
const oldRefId = refChip.getAttribute("data-reference-id") || "";
|
||
const oldTitle = refChip.getAttribute("data-reference-title") || oldLabel;
|
||
if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) {
|
||
if (oldNodeId === currentConnection.nodeId) {
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId);
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// B) 释放在提示词参考架容器内(自动就近匹配最近的参考卡片)
|
||
const refShelf = hitElement.closest<HTMLElement>(".canvas-node-composer-references");
|
||
if (refShelf) {
|
||
const chips = Array.from(refShelf.querySelectorAll<HTMLElement>("[data-reference-chip]"));
|
||
let closestChip: HTMLElement | null = null;
|
||
let minDistance = Number.POSITIVE_INFINITY;
|
||
chips.forEach((chip) => {
|
||
const rect = chip.getBoundingClientRect();
|
||
const centerX = rect.left + rect.width / 2;
|
||
const dist = Math.abs(clientX - centerX);
|
||
if (dist < minDistance) {
|
||
minDistance = dist;
|
||
closestChip = chip;
|
||
}
|
||
});
|
||
if (closestChip) {
|
||
const targetNodeId = (closestChip as HTMLElement).getAttribute("data-target-node-id");
|
||
const oldNodeId = (closestChip as HTMLElement).getAttribute("data-reference-node-id");
|
||
const oldLabel = (closestChip as HTMLElement).getAttribute("data-reference-label") || "";
|
||
const oldRefId = (closestChip as HTMLElement).getAttribute("data-reference-id") || "";
|
||
const oldTitle = (closestChip as HTMLElement).getAttribute("data-reference-title") || oldLabel;
|
||
if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) {
|
||
if (oldNodeId === currentConnection.nodeId) {
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId);
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const dropTarget = getConnectionDropTarget(clientX, clientY, currentConnection);
|
||
if (dropTarget.nodeId) {
|
||
connectNodes(currentConnection, dropTarget.nodeId, dropTarget.handleId, dropTarget.anchorRatio);
|
||
setConnecting(null);
|
||
} else if (dropTarget.isNearNode) {
|
||
setConnecting(null);
|
||
} else {
|
||
const position = screenToCanvas(clientX, clientY);
|
||
setMouseWorld(position);
|
||
const pending = { connection: currentConnection, position };
|
||
pendingConnectionCreateRef.current = pending;
|
||
setPendingConnectionCreate(pending);
|
||
}
|
||
}, [connectNodes, getConnectionDropTarget, onReplaceReference, screenToCanvas, setConnecting, updateConnectionReplaceHover]);
|
||
|
||
const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
// A new pin interaction always starts a fresh session. Without this
|
||
// reset, an old quick-create menu can coexist with the new draft line;
|
||
// pointerup then sees the stale pending menu and silently aborts.
|
||
if (pendingConnectionCreateRef.current) closeConnectionCreateMenu();
|
||
if (batchConnectionPreviewRef.current && handleType === "target") {
|
||
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio);
|
||
clearBatchConnection();
|
||
return;
|
||
}
|
||
// A batch table exposes real target pins inside its content. When a normal
|
||
// image output is dragged onto one, commit that active connection instead
|
||
// of starting a new connection session from the target pin.
|
||
const activeConnection = connectingParamsRef.current;
|
||
if (activeConnection && handleType === "target" && activeConnection.nodeId !== nodeId) {
|
||
connectNodes(activeConnection, nodeId, handleId, anchorRatio);
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
if (activeConnection) setConnecting(null);
|
||
if (batchConnectionPreviewRef.current) clearBatchConnection();
|
||
connectingPointerIdRef.current = event.pointerId;
|
||
connectingPointerStartRef.current = { x: event.clientX, y: event.clientY };
|
||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||
setConnecting({ nodeId, handleType, handleId, anchorRatio });
|
||
setConnectionTargetNodeId(null);
|
||
setConnectionTargetAnchorRatio(undefined);
|
||
setSelectedConnectionId(null);
|
||
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, connectNodes, screenToCanvas, setConnecting, setSelectedConnectionId]);
|
||
|
||
const handleConnectDrop = useCallback((event: ReactPointerEvent, nodeId: string, handleId?: string) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (batchConnectionPreviewRef.current) {
|
||
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId);
|
||
clearBatchConnection();
|
||
return;
|
||
}
|
||
const current = connectingParamsRef.current;
|
||
if (!current || current.nodeId === nodeId) return;
|
||
connectNodes(current, nodeId, handleId);
|
||
setConnecting(null);
|
||
}, [clearBatchConnection, commitBatchConnection, connectNodes, setConnecting]);
|
||
|
||
useEffect(() => {
|
||
const cancelPendingPointerMove = () => {
|
||
latestPointerMoveRef.current = null;
|
||
if (pointerMoveFrameRef.current !== null) window.cancelAnimationFrame(pointerMoveFrameRef.current);
|
||
pointerMoveFrameRef.current = null;
|
||
};
|
||
const flushPointerMove = () => {
|
||
pointerMoveFrameRef.current = null;
|
||
const event = latestPointerMoveRef.current;
|
||
latestPointerMoveRef.current = null;
|
||
if (!event) return;
|
||
const batch = batchConnectionPreviewRef.current;
|
||
if (batch && (batchConnectionPointerIdRef.current === null || batchConnectionPointerIdRef.current === event.pointerId)) {
|
||
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds);
|
||
const mouseWorld = screenToCanvas(event.clientX, event.clientY);
|
||
previewBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio, mouseWorld);
|
||
return;
|
||
}
|
||
const current = connectingParamsRef.current;
|
||
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
|
||
if (current.handleType === "source" && typeof document !== "undefined") {
|
||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
||
let chip = el?.closest<HTMLElement>("[data-reference-chip]");
|
||
if (!chip) {
|
||
const shelf = el?.closest<HTMLElement>(".canvas-node-composer-references");
|
||
if (shelf) {
|
||
const chips = Array.from(shelf.querySelectorAll<HTMLElement>("[data-reference-chip]"));
|
||
let closestChip: HTMLElement | null = null;
|
||
let minDistance = Number.POSITIVE_INFINITY;
|
||
chips.forEach((c) => {
|
||
const rect = c.getBoundingClientRect();
|
||
const dist = Math.abs(event.clientX - (rect.left + rect.width / 2));
|
||
if (dist < minDistance) {
|
||
minDistance = dist;
|
||
closestChip = c;
|
||
}
|
||
});
|
||
chip = closestChip;
|
||
}
|
||
}
|
||
updateConnectionReplaceHover(chip || null, event.clientX, event.clientY);
|
||
} else {
|
||
updateConnectionReplaceHover(null);
|
||
}
|
||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
|
||
const point = screenToCanvas(event.clientX, event.clientY);
|
||
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
|
||
setConnectionTargetNodeId(dropTarget.nodeId);
|
||
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
|
||
setMouseWorld(point);
|
||
};
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
// Pointer events can arrive faster than the canvas can paint. Keep
|
||
// only the latest position and update the preview once per frame,
|
||
// which prevents redundant React/Leafer work and visible jitter.
|
||
latestPointerMoveRef.current = event;
|
||
if (pointerMoveFrameRef.current === null) pointerMoveFrameRef.current = window.requestAnimationFrame(flushPointerMove);
|
||
};
|
||
const handlePointerUp = (event: PointerEvent) => {
|
||
latestPointerMoveRef.current = null;
|
||
if (pointerMoveFrameRef.current !== null) {
|
||
window.cancelAnimationFrame(pointerMoveFrameRef.current);
|
||
pointerMoveFrameRef.current = null;
|
||
}
|
||
if (batchConnectionPointerIdRef.current === event.pointerId) {
|
||
const start = batchConnectionPointerStartRef.current;
|
||
const batch = batchConnectionPreviewRef.current;
|
||
if (batch && start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) {
|
||
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds);
|
||
if (target.nodeId) {
|
||
commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio);
|
||
clearBatchConnection();
|
||
} else if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY);
|
||
else clearBatchConnection();
|
||
return;
|
||
}
|
||
const completed = finishBatchConnection(event.clientX, event.clientY);
|
||
if (!completed) {
|
||
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch?.sourceNodeIds || []);
|
||
if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY);
|
||
else clearBatchConnection();
|
||
}
|
||
return;
|
||
}
|
||
if (connectingPointerIdRef.current !== event.pointerId) return;
|
||
const start = connectingPointerStartRef.current;
|
||
if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) {
|
||
const current = connectingParamsRef.current;
|
||
if (current) {
|
||
const pending = { connection: current, position: screenToCanvas(event.clientX, event.clientY), quick: true };
|
||
pendingConnectionCreateRef.current = pending;
|
||
setPendingConnectionCreate(pending);
|
||
setConnecting(null);
|
||
return;
|
||
}
|
||
}
|
||
finishConnection(event.clientX, event.clientY);
|
||
};
|
||
const handlePointerCancel = (event: PointerEvent) => {
|
||
cancelPendingPointerMove();
|
||
updateConnectionReplaceHover(null);
|
||
if (batchConnectionPointerIdRef.current === event.pointerId) {
|
||
clearBatchConnection();
|
||
return;
|
||
}
|
||
if (connectingPointerIdRef.current === event.pointerId) setConnecting(null);
|
||
};
|
||
const cancel = () => {
|
||
cancelPendingPointerMove();
|
||
updateConnectionReplaceHover(null);
|
||
if (connectingParamsRef.current) setConnecting(null);
|
||
if (batchConnectionPreviewRef.current) clearBatchConnection();
|
||
};
|
||
window.addEventListener("pointermove", handlePointerMove);
|
||
window.addEventListener("pointerup", handlePointerUp);
|
||
window.addEventListener("pointercancel", handlePointerCancel);
|
||
window.addEventListener("blur", cancel);
|
||
return () => {
|
||
cancelPendingPointerMove();
|
||
window.removeEventListener("pointermove", handlePointerMove);
|
||
window.removeEventListener("pointerup", handlePointerUp);
|
||
window.removeEventListener("pointercancel", handlePointerCancel);
|
||
window.removeEventListener("blur", cancel);
|
||
};
|
||
}, [clearBatchConnection, commitBatchConnection, finishBatchConnection, finishConnection, getBatchConnectionDropTarget, getConnectionDropTarget, openBatchConnectionCreateMenu, previewBatchConnection, screenToCanvas, setConnecting]);
|
||
|
||
return {
|
||
cancelPendingConnectionCreate,
|
||
closeConnectionCreateMenu,
|
||
connectionTargetNodeId,
|
||
connectionApproach,
|
||
connectionTargetAnchorRatio,
|
||
connectionReplaceHover,
|
||
connectingParams,
|
||
createConnectedNode,
|
||
getConnectionCreateDisabledReason,
|
||
handleConnectStart,
|
||
handleConnectDrop,
|
||
handleBatchConnectionTargetClick,
|
||
batchConnectionPreview,
|
||
beginBatchConnectionMode,
|
||
startBatchConnection,
|
||
mouseWorld,
|
||
pendingConnectionCreate,
|
||
setConnecting,
|
||
};
|
||
}
|