From 2ee8d87647b747f0f3744f541e12c143024365d2 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Sun, 4 Jan 2026 17:22:20 +0800 Subject: [PATCH] feat(node-editor): add box selection and variable node error states - Add box selection (drag on empty canvas to select multiple nodes) - Support Ctrl+drag for additive selection - Add error state styling for invalid variable references (red border, warning icon) - Support dynamic node title via data.displayTitle - Support hiding inputs via data.hiddenInputs array --- .../src/components/canvas/GraphCanvas.tsx | 58 +++++++-- .../src/components/editor/NodeEditor.tsx | 110 ++++++++++++++++++ .../components/nodes/GraphNodeComponent.tsx | 19 ++- .../node-editor/src/styles/GraphNode.css | 18 +++ 4 files changed, 188 insertions(+), 17 deletions(-) diff --git a/packages/devtools/node-editor/src/components/canvas/GraphCanvas.tsx b/packages/devtools/node-editor/src/components/canvas/GraphCanvas.tsx index 18892d37..c1a871ee 100644 --- a/packages/devtools/node-editor/src/components/canvas/GraphCanvas.tsx +++ b/packages/devtools/node-editor/src/components/canvas/GraphCanvas.tsx @@ -50,6 +50,15 @@ export interface GraphCanvasProps { /** Canvas context menu callback (画布右键菜单回调) */ onContextMenu?: (position: Position, e: React.MouseEvent) => void; + /** Canvas mouse down callback for box selection (画布鼠标按下回调,用于框选) */ + onMouseDown?: (position: Position, e: React.MouseEvent) => void; + + /** Canvas mouse move callback (画布鼠标移动回调) */ + onCanvasMouseMove?: (position: Position, e: React.MouseEvent) => void; + + /** Canvas mouse up callback (画布鼠标释放回调) */ + onCanvasMouseUp?: (position: Position, e: React.MouseEvent) => void; + /** Children to render (要渲染的子元素) */ children?: React.ReactNode; } @@ -75,6 +84,9 @@ export const GraphCanvas: React.FC = ({ onZoomChange, onClick, onContextMenu, + onMouseDown: onMouseDownProp, + onCanvasMouseMove, + onCanvasMouseUp, children }) => { const containerRef = useRef(null); @@ -132,22 +144,30 @@ export const GraphCanvas: React.FC = ({ }, [zoom, pan, minZoom, maxZoom, updateZoom, updatePan]); /** - * Handles mouse down for panning - * 处理鼠标按下开始平移 + * Handles mouse down for panning or box selection + * 处理鼠标按下开始平移或框选 */ const handleMouseDown = useCallback((e: React.MouseEvent) => { - // Middle mouse button or space + left click for panning - // 中键或空格+左键平移 + // Middle mouse button or Alt + left click for panning + // 中键或 Alt+左键平移 if (e.button === 1 || (e.button === 0 && e.altKey)) { e.preventDefault(); setIsPanning(true); lastMousePos.current = new Position(e.clientX, e.clientY); + } else if (e.button === 0) { + // Left click on canvas background - start box selection + // 左键点击画布背景 - 开始框选 + const target = e.target as HTMLElement; + if (target === containerRef.current || target.classList.contains('ne-canvas-content')) { + const canvasPos = screenToCanvas(e.clientX, e.clientY); + onMouseDownProp?.(canvasPos, e); + } } - }, []); + }, [screenToCanvas, onMouseDownProp]); /** - * Handles mouse move for panning - * 处理鼠标移动进行平移 + * Handles mouse move for panning or box selection + * 处理鼠标移动进行平移或框选 */ const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isPanning) { @@ -157,13 +177,27 @@ export const GraphCanvas: React.FC = ({ const newPan = new Position(pan.x + dx, pan.y + dy); updatePan(newPan); } - }, [isPanning, pan, updatePan]); + // Always call canvas mouse move for box selection + // 始终调用画布鼠标移动回调用于框选 + const canvasPos = screenToCanvas(e.clientX, e.clientY); + onCanvasMouseMove?.(canvasPos, e); + }, [isPanning, pan, updatePan, screenToCanvas, onCanvasMouseMove]); /** - * Handles mouse up to stop panning - * 处理鼠标释放停止平移 + * Handles mouse up to stop panning or box selection + * 处理鼠标释放停止平移或框选 */ - const handleMouseUp = useCallback(() => { + const handleMouseUp = useCallback((e: React.MouseEvent) => { + setIsPanning(false); + const canvasPos = screenToCanvas(e.clientX, e.clientY); + onCanvasMouseUp?.(canvasPos, e); + }, [screenToCanvas, onCanvasMouseUp]); + + /** + * Handles mouse leave to stop panning + * 处理鼠标离开停止平移 + */ + const handleMouseLeave = useCallback(() => { setIsPanning(false); }, []); @@ -276,7 +310,7 @@ export const GraphCanvas: React.FC = ({ onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + onMouseLeave={handleMouseLeave} onClick={handleClick} onContextMenu={handleContextMenu} > diff --git a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx index ce4b05b5..fd8e0f5e 100644 --- a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx +++ b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx @@ -80,6 +80,16 @@ interface ConnectionDragState { isValid?: boolean; } +/** + * Box selection state + * 框选状态 + */ +interface BoxSelectState { + startPos: Position; + currentPos: Position; + additive: boolean; +} + /** * NodeEditor - Complete node graph editor component * NodeEditor - 完整的节点图编辑器组件 @@ -126,6 +136,11 @@ export const NodeEditor: React.FC = ({ const [dragState, setDragState] = useState(null); const [connectionDrag, setConnectionDrag] = useState(null); const [hoveredPin, setHoveredPin] = useState(null); + const [boxSelectState, setBoxSelectState] = useState(null); + + // Track if box selection just ended to prevent click from clearing selection + // 跟踪框选是否刚刚结束,以防止 click 清除选择 + const boxSelectJustEndedRef = useRef(false); // Force re-render after mount to ensure connections are drawn correctly // 挂载后强制重渲染以确保连接线正确绘制 @@ -477,6 +492,12 @@ export const NodeEditor: React.FC = ({ * 处理画布点击取消选择 */ const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => { + // Skip if box selection just ended (click fires after mouseup) + // 如果框选刚刚结束则跳过(click 在 mouseup 之后触发) + if (boxSelectJustEndedRef.current) { + boxSelectJustEndedRef.current = false; + return; + } if (!readOnly) { onSelectionChange?.(new Set(), new Set()); } @@ -490,6 +511,79 @@ export const NodeEditor: React.FC = ({ onCanvasContextMenu?.(position, e); }, [onCanvasContextMenu]); + /** + * Handles box selection start + * 处理框选开始 + */ + const handleBoxSelectStart = useCallback((position: Position, e: React.MouseEvent) => { + if (readOnly) return; + setBoxSelectState({ + startPos: position, + currentPos: position, + additive: e.ctrlKey || e.metaKey + }); + }, [readOnly]); + + /** + * Handles box selection move + * 处理框选移动 + */ + const handleBoxSelectMove = useCallback((position: Position) => { + if (boxSelectState) { + setBoxSelectState(prev => prev ? { ...prev, currentPos: position } : null); + } + }, [boxSelectState]); + + /** + * Handles box selection end + * 处理框选结束 + */ + const handleBoxSelectEnd = useCallback(() => { + if (!boxSelectState) return; + + const { startPos, currentPos, additive } = boxSelectState; + + // Calculate selection box bounds + const minX = Math.min(startPos.x, currentPos.x); + const maxX = Math.max(startPos.x, currentPos.x); + const minY = Math.min(startPos.y, currentPos.y); + const maxY = Math.max(startPos.y, currentPos.y); + + // Find nodes within the selection box + const nodesInBox: string[] = []; + const nodeWidth = 200; // Approximate node width + const nodeHeight = 100; // Approximate node height + + for (const node of graph.nodes) { + const nodeLeft = node.position.x; + const nodeTop = node.position.y; + const nodeRight = nodeLeft + nodeWidth; + const nodeBottom = nodeTop + nodeHeight; + + // Check if node intersects with selection box + const intersects = !(nodeRight < minX || nodeLeft > maxX || nodeBottom < minY || nodeTop > maxY); + if (intersects) { + nodesInBox.push(node.id); + } + } + + // Update selection + if (additive) { + // Add to existing selection + const newSelection = new Set(selectedNodeIds); + nodesInBox.forEach(id => newSelection.add(id)); + onSelectionChange?.(newSelection, new Set()); + } else { + // Replace selection + onSelectionChange?.(new Set(nodesInBox), new Set()); + } + + // Mark that box selection just ended to prevent click from clearing selection + // 标记框选刚刚结束,以防止 click 清除选择 + boxSelectJustEndedRef.current = true; + setBoxSelectState(null); + }, [boxSelectState, graph.nodes, selectedNodeIds, onSelectionChange]); + /** * Handles pin value change * 处理引脚值变化 @@ -546,6 +640,9 @@ export const NodeEditor: React.FC = ({ onContextMenu={handleCanvasContextMenu} onPanChange={handlePanChange} onZoomChange={handleZoomChange} + onMouseDown={handleBoxSelectStart} + onCanvasMouseMove={handleBoxSelectMove} + onCanvasMouseUp={handleBoxSelectEnd} > {/* Connection layer (连接层) */} = ({ onToggleCollapse={handleToggleCollapse} /> ))} + + {/* Box selection overlay (框选覆盖层) */} + {boxSelectState && ( +
+ )}
); diff --git a/packages/devtools/node-editor/src/components/nodes/GraphNodeComponent.tsx b/packages/devtools/node-editor/src/components/nodes/GraphNodeComponent.tsx index c29e8a87..2c0372c4 100644 --- a/packages/devtools/node-editor/src/components/nodes/GraphNodeComponent.tsx +++ b/packages/devtools/node-editor/src/components/nodes/GraphNodeComponent.tsx @@ -64,6 +64,8 @@ export const GraphNodeComponent: React.FC = ({ return draggingFromPin.canConnectTo(pin); }, [draggingFromPin]); + const hasError = Boolean(node.data.invalidVariable); + const classNames = useMemo(() => { const classes = ['ne-node']; if (isSelected) classes.push('selected'); @@ -71,8 +73,9 @@ export const GraphNodeComponent: React.FC = ({ if (node.isCollapsed) classes.push('collapsed'); if (node.category === 'comment') classes.push('comment'); if (executionState !== 'idle') classes.push(executionState); + if (hasError) classes.push('has-error'); return classes.join(' '); - }, [isSelected, isDragging, node.isCollapsed, node.category, executionState]); + }, [isSelected, isDragging, node.isCollapsed, node.category, executionState, hasError]); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; @@ -102,8 +105,10 @@ export const GraphNodeComponent: React.FC = ({ : undefined; // Separate exec pins from data pins + // Also filter out pins listed in data.hiddenInputs + const hiddenInputs = (node.data.hiddenInputs as string[]) || []; const inputExecPins = node.inputPins.filter(p => p.isExec && !p.hidden); - const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden); + const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden && !hiddenInputs.includes(p.name)); const outputExecPins = node.outputPins.filter(p => p.isExec && !p.hidden); const outputDataPins = node.outputPins.filter(p => !p.isExec && !p.hidden); @@ -129,13 +134,17 @@ export const GraphNodeComponent: React.FC = ({ className={`ne-node-header ${node.category}`} style={headerStyle} > - {/* Diamond icon for event nodes, or custom icon */} + {/* Warning icon for invalid nodes, or diamond/custom icon */} - {node.icon && renderIcon ? renderIcon(node.icon) : null} + {hasError ? ( + + ) : ( + node.icon && renderIcon ? renderIcon(node.icon) : null + )} - {node.title} + {(node.data.displayTitle as string) || node.title} {node.subtitle && ( {node.subtitle} diff --git a/packages/devtools/node-editor/src/styles/GraphNode.css b/packages/devtools/node-editor/src/styles/GraphNode.css index 01fb0c1f..ec16aaa1 100644 --- a/packages/devtools/node-editor/src/styles/GraphNode.css +++ b/packages/devtools/node-editor/src/styles/GraphNode.css @@ -38,6 +38,24 @@ z-index: var(--ne-z-dragging); } +/* Error state for invalid variable references */ +.ne-node.has-error { + border-color: #e74c3c; + box-shadow: 0 0 0 1px #e74c3c, + 0 0 12px rgba(231, 76, 60, 0.4), + 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.ne-node.has-error .ne-node-header { + background: linear-gradient(180deg, #c0392b 0%, #962d22 100%) !important; +} + +.ne-node-warning-icon { + color: #f1c40f; + font-size: 14px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); +} + /* ==================== Node Header 节点头部 ==================== */ .ne-node-header { display: flex;