/** * 节点渲染逻辑 */ import { useRef, useState } from 'react'; /** * 节点拖拽管理 Hook * 集中管理节点拖拽的状态和逻辑 */ export function useNodeDrag() { const [draggedNodeId, setDraggedNodeId] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); // 使用 ref 来存储拖拽状态,避免闭包陷阱 const draggedNodeIdRef = useRef(null); const dragOffsetRef = useRef({ x: 0, y: 0 }); /** * 开始拖拽节点 */ const startDrag = (nodeId, offset) => { setDraggedNodeId(nodeId); setDragOffset(offset); draggedNodeIdRef.current = nodeId; dragOffsetRef.current = offset; // 将 cursor 应用到 document.body,确保在边界外也保持 grabbing document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }; /** * 结束拖拽节点 */ const endDrag = () => { if (draggedNodeIdRef.current) { // 恢复 document.body 的 cursor 样式 document.body.style.cursor = ''; document.body.style.userSelect = ''; // 同时清除 state 和 ref setDraggedNodeId(null); setDragOffset({ x: 0, y: 0 }); draggedNodeIdRef.current = null; dragOffsetRef.current = { x: 0, y: 0 }; } }; /** * 检查是否正在拖拽 */ const isDragging = () => { return draggedNodeIdRef.current !== null; }; /** * 获取当前拖拽的节点ID */ const getDraggedNodeId = () => { return draggedNodeIdRef.current; }; /** * 获取当前拖拽偏移量 */ const getDragOffset = () => { return dragOffsetRef.current; }; return { draggedNodeId, dragOffset, startDrag, endDrag, isDragging, getDraggedNodeId, getDragOffset }; } /** * 处理节点拖拽开始 * @param {Event} e - 鼠标事件 * @param {Object} node - 节点对象 * @param {Object} canvasRect - 画布矩形区域 * @param {Object} transform - 画布变换(scale, translateX, translateY) * @param {Function} startDrag - 开始拖拽回调 * @param {Function} onNodeSelect - 节点选择回调 * @param {Array} selectedNodeIds - 已选中的节点ID列表 */ export function handleNodeDragStart(e, node, canvasRect, transform, startDrag, onNodeSelect, selectedNodeIds) { if (e.button !== 0) return; const canvasX = (e.clientX - canvasRect.left - transform.translateX) / transform.scale; const canvasY = (e.clientY - canvasRect.top - transform.translateY) / transform.scale; const offset = { x: canvasX - node.x, y: canvasY - node.y }; startDrag(node.id, offset); if (e.ctrlKey || e.metaKey) { onNodeSelect?.(node.id, true); } else { if (!selectedNodeIds.includes(node.id)) { onNodeSelect?.(node.id, false); } } e.preventDefault(); e.stopPropagation(); } /** * 处理节点拖拽移动 * @param {Event} e - 鼠标事件 * @param {Object} canvasRect - 画布矩形区域 * @param {Object} transform - 画布变换 * @param {Function} getDraggedNodeId - 获取拖拽节点ID函数 * @param {Function} getDragOffset - 获取拖拽偏移量函数 * @param {Function} onNodeMove - 节点移动回调 */ export function handleNodeDragMove(e, canvasRect, transform, getDraggedNodeId, getDragOffset, onNodeMove) { const draggedNodeId = getDraggedNodeId(); if (!draggedNodeId) return; const dragOffset = getDragOffset(); const containerX = e.clientX - canvasRect.left; const containerY = e.clientY - canvasRect.top; const canvasX = (containerX - transform.translateX) / transform.scale - dragOffset.x; const canvasY = (containerY - transform.translateY) / transform.scale - dragOffset.y; onNodeMove?.(draggedNodeId, canvasX, canvasY); } /** * 处理节点拖拽结束 * @param {Function} endDrag - 结束拖拽回调 */ export function handleNodeDragEnd(endDrag) { endDrag(); } /** * 计算节点高度(根据端口数量) * @param {Object} node - 节点对象 * @returns {number} 节点高度(像素) */ export function calculateNodeHeight(node) { if (!node) { return 40; } // GET 变量节点固定高度 if (node.type === 'variable' && node.varMode !== 'set') { return 40; } // SET 变量节点:标题 + 执行箭头区域 + 数据端口区域 if (node.type === 'variable' && node.varMode === 'set') { const headerHeight = 32; // 标题区域 const executionHeight = 32; // 执行箭头区域 const dataHeight = 36; // 数据端口区域 return headerHeight + executionHeight + dataHeight; } const headerHeight = 40; // 区域1:标题区域高度 const executionHeight = 40; // 区域2:执行箭头区域(固定高度) const paramsPadding = 12 * 2; // 区域3:参数区域上下padding const portHeight = 28; // 每个参数端口的高度(包含间距) // 计算数据端口数量(取输入和输出端口的最大值) const dataInputs = (node.inputs || []).filter(input => input.type !== 'execution').length; const dataOutputs = (node.outputs || []).filter(output => output.type !== 'execution').length; const maxDataPorts = Math.max(dataInputs, dataOutputs); // 计算参数区域高度 const paramsContentHeight = maxDataPorts > 0 ? maxDataPorts * portHeight : 20; // 至少20px const paramsHeight = paramsPadding + paramsContentHeight; // 计算总高度 = 标题 + 执行箭头区域 + 参数区域 const totalHeight = headerHeight + executionHeight + paramsHeight; // 最小高度 const minHeight = headerHeight + executionHeight + paramsPadding + 20; return Math.max(totalHeight, minHeight); } // 缓存 canvas 上下文以提高性能 let textMeasureCanvas = null; let textMeasureContext = null; /** * 获取文本测量上下文 */ function getTextMeasureContext() { if (!textMeasureCanvas) { textMeasureCanvas = document.createElement('canvas'); textMeasureContext = textMeasureCanvas.getContext('2d'); } return textMeasureContext; } /** * 估算文本宽度(像素) * @param {string} text - 文本内容 * @param {number} fontSize - 字体大小(默认11px) * @returns {number} 文本宽度(像素) */ function estimateTextWidth(text, fontSize = 11) { if (!text) return 0; try { const context = getTextMeasureContext(); context.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`; return context.measureText(text).width; } catch (e) { // 如果无法测量(例如在 SSR 环境中),使用简单估算 // 中文字符约 1.2 * fontSize,英文字符约 0.6 * fontSize let width = 0; for (let i = 0; i < text.length; i++) { const char = text[i]; // 简单判断:中文字符范围 if (/[\u4e00-\u9fa5]/.test(char)) { width += fontSize * 1.2; } else { width += fontSize * 0.6; } } return width; } } /** * 计算节点宽度(根据参数名长度) * @param {Object} node - 节点对象 * @returns {number} 节点宽度(像素) */ export function calculateNodeWidth(node) { if (!node || node.type === 'variable') { // 变量节点宽度根据标签计算 const label = node.label || node.varName || node.type || ''; const labelWidth = estimateTextWidth(label, 13); return Math.max(140, labelWidth + 24 + 16); // 最小140px,加上padding和端口 } const minWidth = 180; const bodyPadding = 16 * 2; // 左右padding const portDotWidth = 16; // 端口圆点宽度 const portSpacing = 4; // 端口和标签之间的间距 const inputFieldMinWidth = 60; // 输入框最小宽度 const inputFieldMaxWidth = 120; // 输入框最大宽度 const labelFontSize = 11; const headerFontSize = 13; let maxWidth = 0; // 计算 header 标签宽度 const headerLabel = node.label || node.type || ''; const headerWidth = estimateTextWidth(headerLabel, headerFontSize) + 16 * 2; // padding maxWidth = Math.max(maxWidth, headerWidth); // 计算输入端口行的宽度 const dataInputs = (node.inputs || []).filter(input => input.type !== 'execution'); dataInputs.forEach(input => { const label = input.label || ''; const labelWidth = estimateTextWidth(label, labelFontSize); // 输入端口:左侧间距(8) + 端口圆点(16) + 间距(4) + 标签宽度 let rowWidth = 8 + portDotWidth + portSpacing + labelWidth; // 如果有输入框(数据端口且未连接) if (input.type === 'data') { // 检查是否有默认值,如果有则计算其宽度 let inputWidth = inputFieldMinWidth; if (node.data && node.data.inVars) { const paramIndex = node.inputs?.findIndex(inp => inp.id === input.id && inp.paramName === input.paramName ); if (paramIndex !== undefined && paramIndex >= 0) { const executionPortCount = node.inputs?.filter(inp => inp.type === 'execution').length || 0; const dataIndex = paramIndex - executionPortCount; if (dataIndex >= 0 && node.data.inVars[dataIndex] !== undefined) { const value = node.data.inVars[dataIndex]; if (typeof value === 'string' && !value.startsWith('{') && !value.startsWith('"')) { // 如果有实际值,计算其宽度 const valueWidth = estimateTextWidth(String(value), labelFontSize); inputWidth = Math.max(inputFieldMinWidth, Math.min(inputFieldMaxWidth, valueWidth + 12)); } else { // 使用默认宽度 inputWidth = inputFieldMinWidth; } } } } rowWidth += inputWidth + portSpacing; // 输入框宽度 + 间距 } rowWidth += 8; // 右侧间距 maxWidth = Math.max(maxWidth, rowWidth); }); // 计算输出端口行的宽度 const dataOutputs = (node.outputs || []).filter(output => output.type !== 'execution'); dataOutputs.forEach(output => { let label = output.label || ''; // 如果标签是变量名格式(如 {varName}),去掉大括号 if (label.startsWith('{') && label.endsWith('}')) { label = label.slice(1, -1); } const labelWidth = estimateTextWidth(label, labelFontSize); // 输出端口:左侧间距(16) + 标签宽度 + 间距(4) + 端口圆点(16) + 右侧间距(8) const rowWidth = 16 + labelWidth + portSpacing + portDotWidth + 8; maxWidth = Math.max(maxWidth, rowWidth); }); // 计算输入+输出端口同行的总宽度(如果有) const maxPorts = Math.max(dataInputs.length, dataOutputs.length); for (let i = 0; i < maxPorts; i++) { let rowWidth = 0; // 输入端口宽度 if (i < dataInputs.length) { const input = dataInputs[i]; const inputLabel = input.label || ''; const inputLabelWidth = estimateTextWidth(inputLabel, labelFontSize); // 圆点 + 间距 + 标签 + 输入框(如果有) rowWidth += portDotWidth + portSpacing + inputLabelWidth; if (input.type === 'data') { rowWidth += portSpacing + inputFieldMinWidth; } } // 输出端口宽度 if (i < dataOutputs.length) { const output = dataOutputs[i]; let outputLabel = output.label || ''; if (outputLabel.startsWith('{') && outputLabel.endsWith('}')) { outputLabel = outputLabel.slice(1, -1); } const outputLabelWidth = estimateTextWidth(outputLabel, labelFontSize); // 标签 + 间距 + 圆点 rowWidth += outputLabelWidth + portSpacing + portDotWidth; } // 加上两边的间距和中间的间隔 rowWidth += 16 + 16 + 20; // 左边距 + 右边距 + 中间间隔 maxWidth = Math.max(maxWidth, rowWidth); } // 返回最大宽度,但不小于最小宽度 return Math.max(minWidth, maxWidth + bodyPadding); } /** * 计算节点样式 */ export function getNodeStyle(node) { const height = calculateNodeHeight(node); // 宽度通过 CSS fit-content 自适应,不再通过 JS 计算 return { left: `${node.x}px`, top: `${node.y}px`, height: `${height}px` // width 由 CSS 的 fit-content 自动计算 }; } /** * 计算端口位置 * @param {number} index - 端口索引 * @param {string} portType - 端口类型 ('execution' | 'data') * @param {number} executionPortCount - 执行端口数量(用于计算数据端口的起始位置) * @param {number} headerHeight - 头部高度 * @param {number} bodyPadding - 主体内边距 * @param {number} portHeight - 端口间距 */ export function calculatePortPosition(index, portType = 'data', executionPortCount = 0, headerHeight = 40, bodyPadding = 24, portHeight = 56) { if (portType === 'execution') { // 执行端口固定在最上方,紧贴header下方(参考虚幻5蓝图风格) return headerHeight + 12; // 减小间距,使箭头更靠近header } else { // 数据端口在执行端口下方 const executionPortOffset = executionPortCount > 0 ? 32 : 0; // 减小偏移,使数据端口更靠近执行端口 const dataPortIndex = index - executionPortCount; // 数据端口的索引(从0开始) return headerHeight + bodyPadding + executionPortOffset + dataPortIndex * portHeight; } } /** * 获取节点类名 */ export function getNodeClassName(isSelected, node) { let baseClass = 'Blueprint-node'; if (node?.type === 'variable') { if (node.varMode === 'set') { baseClass = 'Blueprint-variable-set-node'; } else { baseClass = 'Blueprint-variable-node'; } } return `${baseClass} ${isSelected ? 'selected' : ''}`; } /** * 根据端口信息获取端口类型类名 * @param {Object} port - 端口对象 * @returns {string} 端口类型类名 */ export function getPortTypeClass(port) { // 执行端口保持默认蓝色 if (port.type === 'execution') { return 'port-execution'; } // 数据端口根据参数类型设置颜色 if (port.type === 'data') { const paramType = port.paramType || 'string'; if (paramType === 'int' || paramType === 'integer') { return 'port-int'; } else { return 'port-string'; } } // 默认返回执行端口样式 return 'port-execution'; } /** * 获取端口样式 * @param {number} portY - 端口 Y 位置 * @returns {Object} 样式对象 */ export function getPortStyle(portY) { return { top: `${portY}px` }; } /** * 检查端口是否有连接(作为输入端口或输出端口) * @param {string} nodeId - 节点ID * @param {string} portId - 端口ID * @param {Array} connections - 连接数组 * @returns {boolean} 端口是否已连接 */ export function isPortConnected(nodeId, portId, connections) { if (!connections || !Array.isArray(connections)) { return false; } return connections.some(conn => (conn.target === nodeId && conn.targetPort === portId) || // 作为输入端口 (conn.source === nodeId && conn.sourcePort === portId) // 作为输出端口 ); } /** * 获取端口的默认值(从节点的 data.inVars 中) * @param {Object} port - 端口对象 * @param {Object} node - 节点对象 * @returns {string} 端口的默认值 */ export function getPortDefaultValue(port, node) { if (!port.paramName || !node.data) { return ''; } // 找到参数在 inputs 中的索引 const paramIndex = node.inputs?.findIndex(input => input.id === port.id && input.paramName === port.paramName ); if (paramIndex !== undefined && paramIndex >= 0) { // 计算在 inVars 中的实际索引(需要跳过执行端口) const executionPortCount = node.inputs?.filter(input => input.type === 'execution').length || 0; const dataIndex = paramIndex - executionPortCount; if (dataIndex >= 0 && node.data.inVars && node.data.inVars[dataIndex] !== undefined) { const value = node.data.inVars[dataIndex]; // 如果是变量引用(如 "{varName}" 或数字),返回原始值(去掉引号) if (typeof value === 'string') { // 如果是带引号的字符串(如 "value"),去掉引号 if (value.startsWith('"') && value.endsWith('"')) { return value.slice(1, -1); } // 如果是变量引用(如 "{varName}"),返回空字符串让用户输入 if (value.startsWith('{') && value.endsWith('}')) { return ''; } return value; } // 如果是数字,直接返回字符串 return String(value); } } return ''; } /** * 处理输出端口标签显示(去掉变量名的大括号) * @param {string} label - 原始标签 * @returns {string} 处理后的标签 */ export function formatOutputLabel(label) { if (!label) return ''; if (label.startsWith('{') && label.endsWith('}')) { return label.slice(1, -1); // 去掉首尾的大括号 } return label; } /** * 分离执行端口和数据端口 * @param {Array} ports - 端口数组 * @returns {Object} {executionPorts, dataPorts} */ export function separatePorts(ports) { const executionPorts = ports.filter(p => p.type === 'execution'); const dataPorts = ports.filter(p => p.type !== 'execution'); return { executionPorts, dataPorts }; } /** * 处理节点鼠标按下事件 * @param {Event} e - 鼠标事件 * @param {Function} onNodeMouseDown - 节点鼠标按下回调 * @param {string} nodeId - 节点ID */ export function handleNodeMouseDown(e, onNodeMouseDown, nodeId) { const portElement = e.target.closest('.Blueprint-node-port'); if (portElement) { // 如果点击的是端口,也要阻止事件冒泡到画布 e.stopPropagation(); return; } // 阻止事件冒泡,防止触发画布平移 e.stopPropagation(); e.preventDefault(); onNodeMouseDown?.(e, nodeId); } /** * 处理节点双击事件 * @param {Event} e - 鼠标事件 * @param {Function} onNodeDoubleClick - 节点双击回调 * @param {string} nodeId - 节点ID */ export function handleNodeDoubleClick(e, onNodeDoubleClick, nodeId) { e.stopPropagation(); onNodeDoubleClick?.(e, nodeId); } /** * 处理端口鼠标按下事件 * @param {Event} e - 鼠标事件 * @param {Function} onPortMouseDown - 端口鼠标按下回调 * @param {string} nodeId - 节点ID * @param {string} portId - 端口ID */ export function handlePortMouseDown(e, onPortMouseDown, nodeId, portId) { e.stopPropagation(); e.preventDefault(); onPortMouseDown?.(e, nodeId, portId); } /** * 处理端口鼠标释放事件 * @param {Event} e - 鼠标事件 * @param {Function} onPortMouseUp - 端口鼠标释放回调 * @param {string} nodeId - 节点ID * @param {string} portId - 端口ID */ export function handlePortMouseUp(e, onPortMouseUp, nodeId, portId) { e.stopPropagation(); onPortMouseUp?.(e, nodeId, portId); } /** * 处理输入框变化事件 * @param {Event} e - 事件对象 * @param {Function} onPortValueChange - 端口值变化回调 * @param {string} nodeId - 节点ID * @param {string} portId - 端口ID * @param {string} paramName - 参数名称 */ export function handleInputChange(e, onPortValueChange, nodeId, portId, paramName) { e.stopPropagation(); onPortValueChange?.(nodeId, portId, paramName, e.target.value); } /** * 检查节点坐标是否有效 * @param {Object} node - 节点对象 */ export function validateNodeCoordinates(node) { if (typeof node.x !== 'number' || typeof node.y !== 'number') { console.warn('节点坐标无效:', node.id, 'x:', node.x, 'y:', node.y); } }