Jelajahi Sumber

连线位置偏差修复

yichael 4 bulan lalu
induk
melakukan
c0b1a585bd

+ 20 - 5
src/pages/blueprint/canvas/blueprint-canvas.jsx

@@ -2,6 +2,7 @@
  * 画布组件(主编辑区)
  */
 
+import { useEffect } from 'react';
 import './canvas.css';
 import { NodeRenderer } from '../node-renderer/node-renderer.jsx';
 import { useCanvasLogic, useCanvasEventHandlers, createEventHandlers, getConnectionClass, calculateGridStyle, handleVariableDrop, handleVariableDragOver } from './canvas.js';
@@ -34,9 +35,19 @@ export function BlueprintCanvas({
     handleMouseUp: handleMouseUpLogic,
     handleContextMenu: handleContextMenuLogic,
     handleConnectionClick: handleConnectionClickLogic,
-    getConnectionPath
+    getConnectionPath,
+    refreshPortPositionCache
   } = useCanvasLogic(connections, externalControllerRef, nodes);
   
+  // 首次渲染后刷新端口坐标缓存(等待 DOM 渲染完成)
+  useEffect(() => {
+    // 延迟执行,确保 DOM 已渲染
+    const timer = setTimeout(() => {
+      refreshPortPositionCache();
+    }, 100);
+    return () => clearTimeout(timer);
+  }, [nodes.length, connections.length]);
+  
   // 绑定事件监听
   useCanvasEventHandlers(canvasRef, transform, connectingStart, connectingEnd, handleMouseMoveLogic, handleMouseUpLogic, handleContextMenuLogic, onNodeMove, onCanvasContextMenu);
   
@@ -86,6 +97,10 @@ export function BlueprintCanvas({
       <div
         ref={canvasRef}
         className="Blueprint-canvas"
+        style={{
+          transform: `translate(${transform.translateX}px, ${transform.translateY}px) scale(${transform.scale})`,
+          transformOrigin: '0 0'
+        }}
       >
         {/* 渲染连线 */}
         <svg 
@@ -143,10 +158,10 @@ export function BlueprintCanvas({
                 connectingStart.paramType || 'string'
               )}`}
               d={createBezierPath(
-                connectingStart.x + 50000, 
-                connectingStart.y + 50000, 
-                connectingEnd.x + 50000, 
-                connectingEnd.y + 50000
+                connectingStart.x, 
+                connectingStart.y, 
+                connectingEnd.x, 
+                connectingEnd.y
               )}
             />
           )}

+ 4 - 4
src/pages/blueprint/canvas/canvas.css

@@ -41,10 +41,10 @@
 /* SVG 容器样式 */
 .Blueprint-canvas-svg {
   position: absolute;
-  top: -50000px;
-  left: -50000px;
-  width: calc(100% + 100000px);
-  height: calc(100% + 100000px);
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
   pointer-events: none;
   z-index: 1;
   overflow: visible; /* 确保连线可以超出边界显示 */

+ 188 - 187
src/pages/blueprint/canvas/canvas.js

@@ -14,6 +14,9 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
   const canvasControllerRef = useRef(null);
   const connectionManagerRef = useRef(null);
   const nodeWidthCacheRef = useRef(new Map()); // 缓存节点实际尺寸 {width, height}
+  // 端口坐标缓存:{ nodeId: { portId: { x, y }, nodeX, nodeY } }
+  // nodeX, nodeY 是缓存时节点的位置,用于计算偏差量
+  const portPositionCacheRef = useRef(new Map());
   const [transform, setTransform] = useState({ scale: 1, translateX: 0, translateY: 0 });
   const [connectingStart, setConnectingStart] = useState(null);
   const [connectingEnd, setConnectingEnd] = useState(null);
@@ -46,10 +49,162 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
     connectionManagerRef.current = new ConnectionManager(connections);
   }, [connections]);
   
-  // 当节点更新时,清除宽度缓存(节点内容可能改变导致宽度变化
+  // 当节点数量变化时,清除缓存(节点内容可能改变
   useEffect(() => {
     nodeWidthCacheRef.current.clear();
-  }, [nodes]);
+    portPositionCacheRef.current.clear();
+  }, [nodes.length]);
+  
+  /**
+   * 从 DOM 获取端口的精确坐标并缓存
+   * @param {string} nodeId - 节点ID
+   * @param {Object} node - 节点对象
+   * @returns {Object|null} 该节点所有端口的坐标 { portId: { x, y } }
+   */
+  const cacheNodePortPositions = (nodeId, node) => {
+    if (!canvasRef.current) return null;
+    
+    const nodeElement = canvasRef.current.querySelector(`[data-node-id="${nodeId}"]`);
+    if (!nodeElement) return null;
+    
+    const portPositions = {};
+    const allPorts = [...(node.inputs || []), ...(node.outputs || [])];
+    
+    allPorts.forEach(port => {
+      // 查找端口元素
+      const isInput = (node.inputs || []).some(p => p.id === port.id);
+      const isExecutionPort = port.type === 'execution';
+      
+      const portElements = nodeElement.querySelectorAll('.Blueprint-node-port');
+      const matchingPorts = Array.from(portElements).filter(portEl => {
+        const isInputPort = portEl.classList.contains('input');
+        const isOutputPort = portEl.classList.contains('output');
+        const isExecPort = portEl.classList.contains('port-execution');
+        
+        const directionMatch = (isInput && isInputPort) || (!isInput && isOutputPort);
+        const typeMatch = isExecutionPort ? isExecPort : !isExecPort;
+        
+        return directionMatch && typeMatch;
+      });
+      
+      // 获取端口在同类型中的索引
+      const ports = isInput 
+        ? (node.inputs || []).filter(p => isExecutionPort ? p.type === 'execution' : p.type !== 'execution')
+        : (node.outputs || []).filter(p => isExecutionPort ? p.type === 'execution' : p.type !== 'execution');
+      const portIndex = ports.findIndex(p => p.id === port.id);
+      
+      if (portIndex >= 0 && portIndex < matchingPorts.length) {
+        const portEl = matchingPorts[portIndex];
+        const dotElement = portEl.querySelector('.Blueprint-node-port-dot');
+        const arrowElement = portEl.querySelector('.Blueprint-node-port-arrow');
+        const targetElement = dotElement || arrowElement;
+        
+        if (targetElement) {
+          const targetRect = targetElement.getBoundingClientRect();
+          const canvasRect = canvasRef.current.getBoundingClientRect();
+          
+          // 计算相对于画布的坐标(考虑当前 transform)
+          const x = (targetRect.left + targetRect.width / 2 - canvasRect.left - transform.translateX) / transform.scale;
+          const y = (targetRect.top + targetRect.height / 2 - canvasRect.top - transform.translateY) / transform.scale;
+          
+          portPositions[port.id] = { x, y };
+        }
+      }
+    });
+    
+    // 缓存端口位置和当时的节点坐标
+    portPositionCacheRef.current.set(nodeId, {
+      ports: portPositions,
+      nodeX: node.x,
+      nodeY: node.y
+    });
+    
+    return portPositions;
+  };
+  
+  /**
+   * 获取端口坐标(优先从缓存获取,根据节点偏移量计算)
+   * @param {Object} node - 节点对象
+   * @param {Object} port - 端口对象
+   * @returns {Object|null} { x, y }
+   */
+  const getPortPosition = (node, port) => {
+    const cached = portPositionCacheRef.current.get(node.id);
+    
+    if (cached && cached.ports[port.id]) {
+      // 计算节点移动的偏差量
+      const deltaX = node.x - cached.nodeX;
+      const deltaY = node.y - cached.nodeY;
+      
+      // 返回缓存坐标 + 偏差量
+      return {
+        x: cached.ports[port.id].x + deltaX,
+        y: cached.ports[port.id].y + deltaY
+      };
+    }
+    
+    // 缓存不存在,从 DOM 获取并缓存
+    const newCache = cacheNodePortPositions(node.id, node);
+    if (newCache && newCache[port.id]) {
+      return newCache[port.id];
+    }
+    
+    // 如果 DOM 也获取不到,使用计算值作为后备
+    return getPortPositionFallback(node, port);
+  };
+  
+  /**
+   * 后备方案:纯计算端口位置(不依赖 DOM)
+   */
+  const getPortPositionFallback = (node, port) => {
+    const isInput = (node.inputs || []).some(p => p.id === port.id);
+    const isVariable = node.type === 'variable';
+    
+    // 获取节点尺寸
+    const nodeDims = nodeWidthCacheRef.current.get(node.id) || { width: 180, height: isVariable ? 40 : 140 };
+    
+    if (isVariable) {
+      const portY = node.y + nodeDims.height / 2;
+      if (isInput) {
+        return { x: node.x, y: portY };
+      } else {
+        return { x: node.x + nodeDims.width, y: portY };
+      }
+    } else {
+      const headerHeight = 40;
+      const executionHeight = 40;
+      const paramsTopPadding = 12;
+      const portGap = 8;
+      const portRowHeight = 20;
+      
+      const isExecutionPort = port.type === 'execution';
+      
+      if (isExecutionPort) {
+        const portCenterY = node.y + headerHeight + executionHeight / 2;
+        if (isInput) {
+          return { x: node.x + 20, y: portCenterY };
+        } else {
+          return { x: node.x + nodeDims.width - 5, y: portCenterY };
+        }
+      } else {
+        const dataPorts = isInput
+          ? (node.inputs || []).filter(p => p.type !== 'execution')
+          : (node.outputs || []).filter(p => p.type !== 'execution');
+        const dataPortIndex = dataPorts.findIndex(p => p.id === port.id);
+        
+        if (dataPortIndex === -1) return null;
+        
+        const paramsStartY = node.y + headerHeight + executionHeight + paramsTopPadding;
+        const portCenterY = paramsStartY + dataPortIndex * (portRowHeight + portGap) + portRowHeight / 2 + 4;
+        
+        if (isInput) {
+          return { x: node.x + 22, y: portCenterY };
+        } else {
+          return { x: node.x + nodeDims.width - 22, y: portCenterY };
+        }
+      }
+    }
+  };
   
   /**
    * 处理节点端口鼠标按下(开始连线)
@@ -307,183 +462,9 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
       console.log(`  目标节点:`, connection.target, `端口:`, connection.targetPort, `索引:`, targetPortIndex, `(共${targetNode.inputs?.length || 0}个输入端口)`);
     }
     
-    // 获取节点实际宽度和高度(从DOM或缓存)
-    const getNodeDimensions = (nodeId) => {
-      if (nodeWidthCacheRef.current.has(nodeId)) {
-        const cached = nodeWidthCacheRef.current.get(nodeId);
-        return { width: cached.width || 180, height: cached.height || 40 };
-      }
-      // 尝试从DOM获取节点实际尺寸
-      if (canvasRef.current) {
-        const nodeElement = canvasRef.current.querySelector(`[data-node-id="${nodeId}"]`) || 
-                           canvasRef.current.querySelector(`.Blueprint-node[id="${nodeId}"]`) ||
-                           canvasRef.current.querySelector(`.Blueprint-variable-node[data-node-id="${nodeId}"]`);
-        if (nodeElement) {
-          const width = nodeElement.offsetWidth || 180;
-          const height = nodeElement.offsetHeight || (nodeElement.classList.contains('Blueprint-variable-node') ? 40 : 100);
-          nodeWidthCacheRef.current.set(nodeId, { width, height });
-          return { width, height };
-        }
-      }
-      // 默认值
-      const isVariable = nodes.find(n => n.id === nodeId)?.type === 'variable';
-      return { width: 180, height: isVariable ? 40 : 100 };
-    };
-    
-    // 计算端口位置(精确对齐到端口中心)- 优先从 DOM 获取实际坐标
-    const getPortCenterPosition = (node, port, isInput) => {
-      // 尝试从 DOM 获取端口内圆点/箭头的实际位置
-      if (canvasRef.current) {
-        const nodeElement = canvasRef.current.querySelector(`[data-node-id="${node.id}"]`);
-        if (nodeElement) {
-          // 查找所有端口元素
-          const portElements = nodeElement.querySelectorAll('.Blueprint-node-port');
-          
-          // 根据端口类型筛选
-          const isExecutionPort = port.type === 'execution';
-          const matchingPorts = Array.from(portElements).filter(portEl => {
-            const isInputPort = portEl.classList.contains('input');
-            const isOutputPort = portEl.classList.contains('output');
-            const isExecPort = portEl.classList.contains('port-execution');
-            
-            // 匹配方向和类型
-            const directionMatch = (isInput && isInputPort) || (!isInput && isOutputPort);
-            const typeMatch = isExecutionPort ? isExecPort : !isExecPort;
-            
-            return directionMatch && typeMatch;
-          });
-          
-          // 根据端口索引获取对应的端口元素
-          const ports = isInput 
-            ? (node.inputs || []).filter(p => isExecutionPort ? p.type === 'execution' : p.type !== 'execution')
-            : (node.outputs || []).filter(p => isExecutionPort ? p.type === 'execution' : p.type !== 'execution');
-          const portIndex = ports.findIndex(p => p.id === port.id);
-          
-          if (portIndex >= 0 && portIndex < matchingPorts.length) {
-            const portEl = matchingPorts[portIndex];
-            // 查找端口内的圆点或箭头元素
-            const dotElement = portEl.querySelector('.Blueprint-node-port-dot');
-            const arrowElement = portEl.querySelector('.Blueprint-node-port-arrow');
-            const targetElement = dotElement || arrowElement;
-            
-            if (targetElement) {
-              // 获取圆点/箭头相对于画布的位置
-              const targetRect = targetElement.getBoundingClientRect();
-              const canvasRect = canvasRef.current.getBoundingClientRect();
-              
-              // 计算相对于画布的中心坐标,并考虑画布的 transform
-              const centerX = (targetRect.left + targetRect.width / 2 - canvasRect.left) / transform.scale - transform.translateX / transform.scale;
-              const centerY = (targetRect.top + targetRect.height / 2 - canvasRect.top) / transform.scale - transform.translateY / transform.scale;
-              
-              return { x: centerX, y: centerY };
-            }
-          }
-        }
-      }
-      
-      // 如果无法从 DOM 获取,使用计算值作为后备
-      const isVariable = node.type === 'variable';
-      const nodeDims = getNodeDimensions(node.id);
-      
-      if (isVariable) {
-        // 变量节点:端口垂直居中(top: 50%)
-        const portY = node.y + nodeDims.height / 2;
-        // 端口容器宽度16px(min-width: 16px),圆点在容器内居中(justify-content: center)
-        const portContainerWidth = 16;
-        const portContainerHalfWidth = portContainerWidth / 2; // 8px
-        
-        if (isInput) {
-          // Set 变量节点的输入端口(左侧)
-          return { x: node.x - 8 + portContainerHalfWidth, y: portY };
-        } else {
-          // Get 变量节点的输出端口(右侧)
-          return { x: node.x + nodeDims.width + 8 - portContainerHalfWidth, y: portY };
-        }
-      } else {
-        // 流程节点:三区域结构(header + execution + params)
-        const headerHeight = 40;      // 区域1:标题区域高度
-        const executionHeight = 40;   // 区域2:执行箭头区域高度(固定)
-        const paramsTopPadding = 12;  // 区域3:参数区域顶部padding
-        const portGap = 8;            // 参数之间的间距(gap: 8px)
-        const portRowHeight = 20;     // 每个参数行的高度(约20px,包含圆点和标签)
-        
-        // 分离执行端口和数据端口
-        const dataPorts = isInput
-          ? (node.inputs || []).filter(p => p.type !== 'execution')
-          : (node.outputs || []).filter(p => p.type !== 'execution');
-        
-        const isExecutionPort = port.type === 'execution';
-        let portCenterY;
-        let portCenterX;
-        
-        if (isExecutionPort) {
-          // 执行端口:在执行区域内垂直居中
-          // 执行区域起始Y = node.y + headerHeight
-          // 执行区域高度 = executionHeight
-          // 端口垂直居中 = node.y + headerHeight + executionHeight / 2
-          portCenterY = node.y + headerHeight + executionHeight / 2;
-          
-          // 箭头是 CSS border 三角形:border-width: 6px 0 6px 10px
-          // 箭头宽度10px,高度12px,箭头中心在宽度的中点(5px)
-          const arrowWidth = 10;
-          const arrowHalfWidth = arrowWidth / 2; // 5px
-          
-          if (isInput) {
-            // 执行输入端口箭头:
-            // 执行区域 padding: 0 8px,端口从 node.x + 8 开始
-            // 箭头有 margin-left: 5%(约9px,按180px宽度算)
-            // 箭头中心 ≈ node.x + 8 + 9 + 5 = node.x + 22
-            // 简化:箭头中心约在 node.x + 8 + arrowHalfWidth + 一些margin
-            portCenterX = node.x + 8 + arrowHalfWidth + 8; // 加上margin估算
-          } else {
-            // 执行输出端口箭头:
-            // 执行区域 padding: 0 8px,端口容器在右侧(margin-left: auto)
-            // 箭头有 margin-right: -8px,箭头向右伸出到节点边缘外
-            // 端口容器右边缘在 node.x + width - 8(padding)
-            // 箭头因为 margin-right: -8px 向右伸出8px
-            // 箭头宽度10px,箭头尖端在 node.x + width - 8 + 8 + 10 = node.x + width + 10
-            // 箭头中心(几何中心)在箭头尖端左边 5px 处
-            // 箭头中心 = node.x + width + 10 - 5 = node.x + width + 5
-            portCenterX = node.x + nodeDims.width + arrowHalfWidth;
-          }
-        } else {
-          // 数据端口:在参数区域内
-          // 参数区域起始Y = node.y + headerHeight + executionHeight + paramsTopPadding
-          const dataPortIndex = dataPorts.findIndex(p => p.id === port.id);
-          if (dataPortIndex === -1) {
-            return null;
-          }
-          
-          // 每个端口的Y位置 = 参数区域起始Y + 索引 * (行高 + 间距) + 行高/2
-          const paramsStartY = node.y + headerHeight + executionHeight + paramsTopPadding;
-          // 增加额外偏移量让Y坐标更准确对齐圆点中心
-          const extraYOffset = 4;
-          portCenterY = paramsStartY + dataPortIndex * (portRowHeight + portGap) + portRowHeight / 2 + extraYOffset;
-          
-          // 圆点直径12px,半径6px
-          const dotRadius = 6;
-          
-          if (isInput) {
-            // 数据输入端口:圆点在最左侧
-            // CSS: .Blueprint-node-params padding: 12px 16px,端口 position: relative
-            // 圆点在端口容器的最左边
-            portCenterX = node.x + 16 + dotRadius; // 16px是左padding,dotRadius是圆点中心
-          } else {
-            // 数据输出端口:
-            // - align-self: flex-end 让端口容器靠右
-            // - flex-direction: row-reverse 让圆点在视觉上靠右
-            // 圆点中心 = 节点右边缘 - 右padding(16px) - 圆点半径
-            portCenterX = node.x + nodeDims.width - 16 - dotRadius;
-          }
-        }
-        
-        return { x: portCenterX, y: portCenterY };
-      }
-    };
-    
-    // 计算源端口和目标端口的精确中心位置
-    const sourcePortPos = getPortCenterPosition(sourceNode, sourcePort, false);
-    const targetPortPos = getPortCenterPosition(targetNode, targetPort, true);
+    // 使用缓存方案获取端口坐标
+    const sourcePortPos = getPortPosition(sourceNode, sourcePort);
+    const targetPortPos = getPortPosition(targetNode, targetPort);
     
     
     if (!sourcePortPos || !targetPortPos) {
@@ -496,13 +477,11 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
       return null;
     }
     
-    // SVG 偏移量:SVG 的左上角在 (-50000, -50000),所以连线坐标需要加上这个偏移
-    const svgOffsetX = 50000;
-    const svgOffsetY = 50000;
-    const sourceX = sourcePortPos.x + svgOffsetX;
-    const sourceY = sourcePortPos.y + svgOffsetY;
-    const targetX = targetPortPos.x + svgOffsetX;
-    const targetY = targetPortPos.y + svgOffsetY;
+    // SVG 现在与画布坐标系一致,不需要偏移
+    const sourceX = sourcePortPos.x;
+    const sourceY = sourcePortPos.y;
+    const targetX = targetPortPos.x;
+    const targetY = targetPortPos.y;
     
     // 调试:检查坐标是否有效
     if (isNaN(sourceX) || isNaN(sourceY) || isNaN(targetX) || isNaN(targetY)) {
@@ -551,6 +530,26 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
     };
   };
   
+  /**
+   * 刷新所有节点的端口坐标缓存
+   */
+  const refreshPortPositionCache = () => {
+    portPositionCacheRef.current.clear();
+    nodes.forEach(node => {
+      cacheNodePortPositions(node.id, node);
+    });
+  };
+  
+  /**
+   * 刷新单个节点的端口坐标缓存
+   */
+  const refreshNodePortCache = (nodeId) => {
+    const node = nodes.find(n => n.id === nodeId);
+    if (node) {
+      cacheNodePortPositions(nodeId, node);
+    }
+  };
+  
   return {
     canvasRef,
     transform,
@@ -563,7 +562,9 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
     handleMouseUp,
     handleContextMenu,
     handleConnectionClick,
-    getConnectionPath
+    getConnectionPath,
+    refreshPortPositionCache,
+    refreshNodePortCache
   };
 }
 

+ 10 - 10
static/processing/测试/bp.json

@@ -1,20 +1,20 @@
 {
 	"nodePositions": {
-		"node_begin_1768720932969": {
-			"x": 150,
-			"y": 100
+		"node_begin_1768723414909": {
+			"x": 80,
+			"y": 180
 		},
 		"node_0": {
-			"x": 400,
-			"y": 180
+			"x": 340,
+			"y": 280
 		},
-		"var_get_mini_1768720932969": {
+		"var_get_mini_1768723414909": {
 			"x": 60,
-			"y": 360
+			"y": 380
 		},
-		"var_set_swipeDirection_1768720932969": {
-			"x": 500,
-			"y": 500
+		"var_set_swipeDirection_1768723414909": {
+			"x": 160,
+			"y": 520
 		}
 	}
 }