| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- /**
- * 节点渲染逻辑
- */
- 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);
- }
- }
|