node-renderer.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. /**
  2. * 节点渲染逻辑
  3. */
  4. import { useRef, useState } from 'react';
  5. /**
  6. * 节点拖拽管理 Hook
  7. * 集中管理节点拖拽的状态和逻辑
  8. */
  9. export function useNodeDrag() {
  10. const [draggedNodeId, setDraggedNodeId] = useState(null);
  11. const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
  12. // 使用 ref 来存储拖拽状态,避免闭包陷阱
  13. const draggedNodeIdRef = useRef(null);
  14. const dragOffsetRef = useRef({ x: 0, y: 0 });
  15. /**
  16. * 开始拖拽节点
  17. */
  18. const startDrag = (nodeId, offset) => {
  19. setDraggedNodeId(nodeId);
  20. setDragOffset(offset);
  21. draggedNodeIdRef.current = nodeId;
  22. dragOffsetRef.current = offset;
  23. // 将 cursor 应用到 document.body,确保在边界外也保持 grabbing
  24. document.body.style.cursor = 'grabbing';
  25. document.body.style.userSelect = 'none';
  26. };
  27. /**
  28. * 结束拖拽节点
  29. */
  30. const endDrag = () => {
  31. if (draggedNodeIdRef.current) {
  32. // 恢复 document.body 的 cursor 样式
  33. document.body.style.cursor = '';
  34. document.body.style.userSelect = '';
  35. // 同时清除 state 和 ref
  36. setDraggedNodeId(null);
  37. setDragOffset({ x: 0, y: 0 });
  38. draggedNodeIdRef.current = null;
  39. dragOffsetRef.current = { x: 0, y: 0 };
  40. }
  41. };
  42. /**
  43. * 检查是否正在拖拽
  44. */
  45. const isDragging = () => {
  46. return draggedNodeIdRef.current !== null;
  47. };
  48. /**
  49. * 获取当前拖拽的节点ID
  50. */
  51. const getDraggedNodeId = () => {
  52. return draggedNodeIdRef.current;
  53. };
  54. /**
  55. * 获取当前拖拽偏移量
  56. */
  57. const getDragOffset = () => {
  58. return dragOffsetRef.current;
  59. };
  60. return {
  61. draggedNodeId,
  62. dragOffset,
  63. startDrag,
  64. endDrag,
  65. isDragging,
  66. getDraggedNodeId,
  67. getDragOffset
  68. };
  69. }
  70. /**
  71. * 处理节点拖拽开始
  72. * @param {Event} e - 鼠标事件
  73. * @param {Object} node - 节点对象
  74. * @param {Object} canvasRect - 画布矩形区域
  75. * @param {Object} transform - 画布变换(scale, translateX, translateY)
  76. * @param {Function} startDrag - 开始拖拽回调
  77. * @param {Function} onNodeSelect - 节点选择回调
  78. * @param {Array} selectedNodeIds - 已选中的节点ID列表
  79. */
  80. export function handleNodeDragStart(e, node, canvasRect, transform, startDrag, onNodeSelect, selectedNodeIds) {
  81. if (e.button !== 0) return;
  82. const canvasX = (e.clientX - canvasRect.left - transform.translateX) / transform.scale;
  83. const canvasY = (e.clientY - canvasRect.top - transform.translateY) / transform.scale;
  84. const offset = {
  85. x: canvasX - node.x,
  86. y: canvasY - node.y
  87. };
  88. startDrag(node.id, offset);
  89. if (e.ctrlKey || e.metaKey) {
  90. onNodeSelect?.(node.id, true);
  91. } else {
  92. if (!selectedNodeIds.includes(node.id)) {
  93. onNodeSelect?.(node.id, false);
  94. }
  95. }
  96. e.preventDefault();
  97. e.stopPropagation();
  98. }
  99. /**
  100. * 处理节点拖拽移动
  101. * @param {Event} e - 鼠标事件
  102. * @param {Object} canvasRect - 画布矩形区域
  103. * @param {Object} transform - 画布变换
  104. * @param {Function} getDraggedNodeId - 获取拖拽节点ID函数
  105. * @param {Function} getDragOffset - 获取拖拽偏移量函数
  106. * @param {Function} onNodeMove - 节点移动回调
  107. */
  108. export function handleNodeDragMove(e, canvasRect, transform, getDraggedNodeId, getDragOffset, onNodeMove) {
  109. const draggedNodeId = getDraggedNodeId();
  110. if (!draggedNodeId) return;
  111. const dragOffset = getDragOffset();
  112. const containerX = e.clientX - canvasRect.left;
  113. const containerY = e.clientY - canvasRect.top;
  114. const canvasX = (containerX - transform.translateX) / transform.scale - dragOffset.x;
  115. const canvasY = (containerY - transform.translateY) / transform.scale - dragOffset.y;
  116. onNodeMove?.(draggedNodeId, canvasX, canvasY);
  117. }
  118. /**
  119. * 处理节点拖拽结束
  120. * @param {Function} endDrag - 结束拖拽回调
  121. */
  122. export function handleNodeDragEnd(endDrag) {
  123. endDrag();
  124. }
  125. /**
  126. * 计算节点高度(根据端口数量)
  127. * @param {Object} node - 节点对象
  128. * @returns {number} 节点高度(像素)
  129. */
  130. export function calculateNodeHeight(node) {
  131. if (!node) {
  132. return 40;
  133. }
  134. // GET 变量节点固定高度
  135. if (node.type === 'variable' && node.varMode !== 'set') {
  136. return 40;
  137. }
  138. // SET 变量节点:标题 + 执行箭头区域 + 数据端口区域
  139. if (node.type === 'variable' && node.varMode === 'set') {
  140. const headerHeight = 32; // 标题区域
  141. const executionHeight = 32; // 执行箭头区域
  142. const dataHeight = 36; // 数据端口区域
  143. return headerHeight + executionHeight + dataHeight;
  144. }
  145. const headerHeight = 40; // 区域1:标题区域高度
  146. const executionHeight = 40; // 区域2:执行箭头区域(固定高度)
  147. const paramsPadding = 12 * 2; // 区域3:参数区域上下padding
  148. const portHeight = 28; // 每个参数端口的高度(包含间距)
  149. // 计算数据端口数量(取输入和输出端口的最大值)
  150. const dataInputs = (node.inputs || []).filter(input => input.type !== 'execution').length;
  151. const dataOutputs = (node.outputs || []).filter(output => output.type !== 'execution').length;
  152. const maxDataPorts = Math.max(dataInputs, dataOutputs);
  153. // 计算参数区域高度
  154. const paramsContentHeight = maxDataPorts > 0 ? maxDataPorts * portHeight : 20; // 至少20px
  155. const paramsHeight = paramsPadding + paramsContentHeight;
  156. // 计算总高度 = 标题 + 执行箭头区域 + 参数区域
  157. const totalHeight = headerHeight + executionHeight + paramsHeight;
  158. // 最小高度
  159. const minHeight = headerHeight + executionHeight + paramsPadding + 20;
  160. return Math.max(totalHeight, minHeight);
  161. }
  162. // 缓存 canvas 上下文以提高性能
  163. let textMeasureCanvas = null;
  164. let textMeasureContext = null;
  165. /**
  166. * 获取文本测量上下文
  167. */
  168. function getTextMeasureContext() {
  169. if (!textMeasureCanvas) {
  170. textMeasureCanvas = document.createElement('canvas');
  171. textMeasureContext = textMeasureCanvas.getContext('2d');
  172. }
  173. return textMeasureContext;
  174. }
  175. /**
  176. * 估算文本宽度(像素)
  177. * @param {string} text - 文本内容
  178. * @param {number} fontSize - 字体大小(默认11px)
  179. * @returns {number} 文本宽度(像素)
  180. */
  181. function estimateTextWidth(text, fontSize = 11) {
  182. if (!text) return 0;
  183. try {
  184. const context = getTextMeasureContext();
  185. context.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
  186. return context.measureText(text).width;
  187. } catch (e) {
  188. // 如果无法测量(例如在 SSR 环境中),使用简单估算
  189. // 中文字符约 1.2 * fontSize,英文字符约 0.6 * fontSize
  190. let width = 0;
  191. for (let i = 0; i < text.length; i++) {
  192. const char = text[i];
  193. // 简单判断:中文字符范围
  194. if (/[\u4e00-\u9fa5]/.test(char)) {
  195. width += fontSize * 1.2;
  196. } else {
  197. width += fontSize * 0.6;
  198. }
  199. }
  200. return width;
  201. }
  202. }
  203. /**
  204. * 计算节点宽度(根据参数名长度)
  205. * @param {Object} node - 节点对象
  206. * @returns {number} 节点宽度(像素)
  207. */
  208. export function calculateNodeWidth(node) {
  209. if (!node || node.type === 'variable') {
  210. // 变量节点宽度根据标签计算
  211. const label = node.label || node.varName || node.type || '';
  212. const labelWidth = estimateTextWidth(label, 13);
  213. return Math.max(140, labelWidth + 24 + 16); // 最小140px,加上padding和端口
  214. }
  215. const minWidth = 180;
  216. const bodyPadding = 16 * 2; // 左右padding
  217. const portDotWidth = 16; // 端口圆点宽度
  218. const portSpacing = 4; // 端口和标签之间的间距
  219. const inputFieldMinWidth = 60; // 输入框最小宽度
  220. const inputFieldMaxWidth = 120; // 输入框最大宽度
  221. const labelFontSize = 11;
  222. const headerFontSize = 13;
  223. let maxWidth = 0;
  224. // 计算 header 标签宽度
  225. const headerLabel = node.label || node.type || '';
  226. const headerWidth = estimateTextWidth(headerLabel, headerFontSize) + 16 * 2; // padding
  227. maxWidth = Math.max(maxWidth, headerWidth);
  228. // 计算输入端口行的宽度
  229. const dataInputs = (node.inputs || []).filter(input => input.type !== 'execution');
  230. dataInputs.forEach(input => {
  231. const label = input.label || '';
  232. const labelWidth = estimateTextWidth(label, labelFontSize);
  233. // 输入端口:左侧间距(8) + 端口圆点(16) + 间距(4) + 标签宽度
  234. let rowWidth = 8 + portDotWidth + portSpacing + labelWidth;
  235. // 如果有输入框(数据端口且未连接)
  236. if (input.type === 'data') {
  237. // 检查是否有默认值,如果有则计算其宽度
  238. let inputWidth = inputFieldMinWidth;
  239. if (node.data && node.data.inVars) {
  240. const paramIndex = node.inputs?.findIndex(inp =>
  241. inp.id === input.id && inp.paramName === input.paramName
  242. );
  243. if (paramIndex !== undefined && paramIndex >= 0) {
  244. const executionPortCount = node.inputs?.filter(inp => inp.type === 'execution').length || 0;
  245. const dataIndex = paramIndex - executionPortCount;
  246. if (dataIndex >= 0 && node.data.inVars[dataIndex] !== undefined) {
  247. const value = node.data.inVars[dataIndex];
  248. if (typeof value === 'string' && !value.startsWith('{') && !value.startsWith('"')) {
  249. // 如果有实际值,计算其宽度
  250. const valueWidth = estimateTextWidth(String(value), labelFontSize);
  251. inputWidth = Math.max(inputFieldMinWidth, Math.min(inputFieldMaxWidth, valueWidth + 12));
  252. } else {
  253. // 使用默认宽度
  254. inputWidth = inputFieldMinWidth;
  255. }
  256. }
  257. }
  258. }
  259. rowWidth += inputWidth + portSpacing; // 输入框宽度 + 间距
  260. }
  261. rowWidth += 8; // 右侧间距
  262. maxWidth = Math.max(maxWidth, rowWidth);
  263. });
  264. // 计算输出端口行的宽度
  265. const dataOutputs = (node.outputs || []).filter(output => output.type !== 'execution');
  266. dataOutputs.forEach(output => {
  267. let label = output.label || '';
  268. // 如果标签是变量名格式(如 {varName}),去掉大括号
  269. if (label.startsWith('{') && label.endsWith('}')) {
  270. label = label.slice(1, -1);
  271. }
  272. const labelWidth = estimateTextWidth(label, labelFontSize);
  273. // 输出端口:左侧间距(16) + 标签宽度 + 间距(4) + 端口圆点(16) + 右侧间距(8)
  274. const rowWidth = 16 + labelWidth + portSpacing + portDotWidth + 8;
  275. maxWidth = Math.max(maxWidth, rowWidth);
  276. });
  277. // 计算输入+输出端口同行的总宽度(如果有)
  278. const maxPorts = Math.max(dataInputs.length, dataOutputs.length);
  279. for (let i = 0; i < maxPorts; i++) {
  280. let rowWidth = 0;
  281. // 输入端口宽度
  282. if (i < dataInputs.length) {
  283. const input = dataInputs[i];
  284. const inputLabel = input.label || '';
  285. const inputLabelWidth = estimateTextWidth(inputLabel, labelFontSize);
  286. // 圆点 + 间距 + 标签 + 输入框(如果有)
  287. rowWidth += portDotWidth + portSpacing + inputLabelWidth;
  288. if (input.type === 'data') {
  289. rowWidth += portSpacing + inputFieldMinWidth;
  290. }
  291. }
  292. // 输出端口宽度
  293. if (i < dataOutputs.length) {
  294. const output = dataOutputs[i];
  295. let outputLabel = output.label || '';
  296. if (outputLabel.startsWith('{') && outputLabel.endsWith('}')) {
  297. outputLabel = outputLabel.slice(1, -1);
  298. }
  299. const outputLabelWidth = estimateTextWidth(outputLabel, labelFontSize);
  300. // 标签 + 间距 + 圆点
  301. rowWidth += outputLabelWidth + portSpacing + portDotWidth;
  302. }
  303. // 加上两边的间距和中间的间隔
  304. rowWidth += 16 + 16 + 20; // 左边距 + 右边距 + 中间间隔
  305. maxWidth = Math.max(maxWidth, rowWidth);
  306. }
  307. // 返回最大宽度,但不小于最小宽度
  308. return Math.max(minWidth, maxWidth + bodyPadding);
  309. }
  310. /**
  311. * 计算节点样式
  312. */
  313. export function getNodeStyle(node) {
  314. const height = calculateNodeHeight(node);
  315. // 宽度通过 CSS fit-content 自适应,不再通过 JS 计算
  316. return {
  317. left: `${node.x}px`,
  318. top: `${node.y}px`,
  319. height: `${height}px`
  320. // width 由 CSS 的 fit-content 自动计算
  321. };
  322. }
  323. /**
  324. * 计算端口位置
  325. * @param {number} index - 端口索引
  326. * @param {string} portType - 端口类型 ('execution' | 'data')
  327. * @param {number} executionPortCount - 执行端口数量(用于计算数据端口的起始位置)
  328. * @param {number} headerHeight - 头部高度
  329. * @param {number} bodyPadding - 主体内边距
  330. * @param {number} portHeight - 端口间距
  331. */
  332. export function calculatePortPosition(index, portType = 'data', executionPortCount = 0, headerHeight = 40, bodyPadding = 24, portHeight = 56) {
  333. if (portType === 'execution') {
  334. // 执行端口固定在最上方,紧贴header下方(参考虚幻5蓝图风格)
  335. return headerHeight + 12; // 减小间距,使箭头更靠近header
  336. } else {
  337. // 数据端口在执行端口下方
  338. const executionPortOffset = executionPortCount > 0 ? 32 : 0; // 减小偏移,使数据端口更靠近执行端口
  339. const dataPortIndex = index - executionPortCount; // 数据端口的索引(从0开始)
  340. return headerHeight + bodyPadding + executionPortOffset + dataPortIndex * portHeight;
  341. }
  342. }
  343. /**
  344. * 获取节点类名
  345. */
  346. export function getNodeClassName(isSelected, node) {
  347. let baseClass = 'Blueprint-node';
  348. if (node?.type === 'variable') {
  349. if (node.varMode === 'set') {
  350. baseClass = 'Blueprint-variable-set-node';
  351. } else {
  352. baseClass = 'Blueprint-variable-node';
  353. }
  354. }
  355. return `${baseClass} ${isSelected ? 'selected' : ''}`;
  356. }
  357. /**
  358. * 根据端口信息获取端口类型类名
  359. * @param {Object} port - 端口对象
  360. * @returns {string} 端口类型类名
  361. */
  362. export function getPortTypeClass(port) {
  363. // 执行端口保持默认蓝色
  364. if (port.type === 'execution') {
  365. return 'port-execution';
  366. }
  367. // 数据端口根据参数类型设置颜色
  368. if (port.type === 'data') {
  369. const paramType = port.paramType || 'string';
  370. if (paramType === 'int' || paramType === 'integer') {
  371. return 'port-int';
  372. } else {
  373. return 'port-string';
  374. }
  375. }
  376. // 默认返回执行端口样式
  377. return 'port-execution';
  378. }
  379. /**
  380. * 获取端口样式
  381. * @param {number} portY - 端口 Y 位置
  382. * @returns {Object} 样式对象
  383. */
  384. export function getPortStyle(portY) {
  385. return { top: `${portY}px` };
  386. }
  387. /**
  388. * 检查端口是否有连接(作为输入端口或输出端口)
  389. * @param {string} nodeId - 节点ID
  390. * @param {string} portId - 端口ID
  391. * @param {Array} connections - 连接数组
  392. * @returns {boolean} 端口是否已连接
  393. */
  394. export function isPortConnected(nodeId, portId, connections) {
  395. if (!connections || !Array.isArray(connections)) {
  396. return false;
  397. }
  398. return connections.some(conn =>
  399. (conn.target === nodeId && conn.targetPort === portId) || // 作为输入端口
  400. (conn.source === nodeId && conn.sourcePort === portId) // 作为输出端口
  401. );
  402. }
  403. /**
  404. * 获取端口的默认值(从节点的 data.inVars 中)
  405. * @param {Object} port - 端口对象
  406. * @param {Object} node - 节点对象
  407. * @returns {string} 端口的默认值
  408. */
  409. export function getPortDefaultValue(port, node) {
  410. if (!port.paramName || !node.data) {
  411. return '';
  412. }
  413. // 找到参数在 inputs 中的索引
  414. const paramIndex = node.inputs?.findIndex(input =>
  415. input.id === port.id && input.paramName === port.paramName
  416. );
  417. if (paramIndex !== undefined && paramIndex >= 0) {
  418. // 计算在 inVars 中的实际索引(需要跳过执行端口)
  419. const executionPortCount = node.inputs?.filter(input => input.type === 'execution').length || 0;
  420. const dataIndex = paramIndex - executionPortCount;
  421. if (dataIndex >= 0 && node.data.inVars && node.data.inVars[dataIndex] !== undefined) {
  422. const value = node.data.inVars[dataIndex];
  423. // 如果是变量引用(如 "{varName}" 或数字),返回原始值(去掉引号)
  424. if (typeof value === 'string') {
  425. // 如果是带引号的字符串(如 "value"),去掉引号
  426. if (value.startsWith('"') && value.endsWith('"')) {
  427. return value.slice(1, -1);
  428. }
  429. // 如果是变量引用(如 "{varName}"),返回空字符串让用户输入
  430. if (value.startsWith('{') && value.endsWith('}')) {
  431. return '';
  432. }
  433. return value;
  434. }
  435. // 如果是数字,直接返回字符串
  436. return String(value);
  437. }
  438. }
  439. return '';
  440. }
  441. /**
  442. * 处理输出端口标签显示(去掉变量名的大括号)
  443. * @param {string} label - 原始标签
  444. * @returns {string} 处理后的标签
  445. */
  446. export function formatOutputLabel(label) {
  447. if (!label) return '';
  448. if (label.startsWith('{') && label.endsWith('}')) {
  449. return label.slice(1, -1); // 去掉首尾的大括号
  450. }
  451. return label;
  452. }
  453. /**
  454. * 分离执行端口和数据端口
  455. * @param {Array} ports - 端口数组
  456. * @returns {Object} {executionPorts, dataPorts}
  457. */
  458. export function separatePorts(ports) {
  459. const executionPorts = ports.filter(p => p.type === 'execution');
  460. const dataPorts = ports.filter(p => p.type !== 'execution');
  461. return { executionPorts, dataPorts };
  462. }
  463. /**
  464. * 处理节点鼠标按下事件
  465. * @param {Event} e - 鼠标事件
  466. * @param {Function} onNodeMouseDown - 节点鼠标按下回调
  467. * @param {string} nodeId - 节点ID
  468. */
  469. export function handleNodeMouseDown(e, onNodeMouseDown, nodeId) {
  470. const portElement = e.target.closest('.Blueprint-node-port');
  471. if (portElement) {
  472. // 如果点击的是端口,也要阻止事件冒泡到画布
  473. e.stopPropagation();
  474. return;
  475. }
  476. // 阻止事件冒泡,防止触发画布平移
  477. e.stopPropagation();
  478. e.preventDefault();
  479. onNodeMouseDown?.(e, nodeId);
  480. }
  481. /**
  482. * 处理节点双击事件
  483. * @param {Event} e - 鼠标事件
  484. * @param {Function} onNodeDoubleClick - 节点双击回调
  485. * @param {string} nodeId - 节点ID
  486. */
  487. export function handleNodeDoubleClick(e, onNodeDoubleClick, nodeId) {
  488. e.stopPropagation();
  489. onNodeDoubleClick?.(e, nodeId);
  490. }
  491. /**
  492. * 处理端口鼠标按下事件
  493. * @param {Event} e - 鼠标事件
  494. * @param {Function} onPortMouseDown - 端口鼠标按下回调
  495. * @param {string} nodeId - 节点ID
  496. * @param {string} portId - 端口ID
  497. */
  498. export function handlePortMouseDown(e, onPortMouseDown, nodeId, portId) {
  499. e.stopPropagation();
  500. e.preventDefault();
  501. onPortMouseDown?.(e, nodeId, portId);
  502. }
  503. /**
  504. * 处理端口鼠标释放事件
  505. * @param {Event} e - 鼠标事件
  506. * @param {Function} onPortMouseUp - 端口鼠标释放回调
  507. * @param {string} nodeId - 节点ID
  508. * @param {string} portId - 端口ID
  509. */
  510. export function handlePortMouseUp(e, onPortMouseUp, nodeId, portId) {
  511. e.stopPropagation();
  512. onPortMouseUp?.(e, nodeId, portId);
  513. }
  514. /**
  515. * 处理输入框变化事件
  516. * @param {Event} e - 事件对象
  517. * @param {Function} onPortValueChange - 端口值变化回调
  518. * @param {string} nodeId - 节点ID
  519. * @param {string} portId - 端口ID
  520. * @param {string} paramName - 参数名称
  521. */
  522. export function handleInputChange(e, onPortValueChange, nodeId, portId, paramName) {
  523. e.stopPropagation();
  524. onPortValueChange?.(nodeId, portId, paramName, e.target.value);
  525. }
  526. /**
  527. * 检查节点坐标是否有效
  528. * @param {Object} node - 节点对象
  529. */
  530. export function validateNodeCoordinates(node) {
  531. if (typeof node.x !== 'number' || typeof node.y !== 'number') {
  532. console.warn('节点坐标无效:', node.id, 'x:', node.x, 'y:', node.y);
  533. }
  534. }