Compare commits
3 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c3a24404 | ||
|
|
3bfb8a1c9b | ||
|
|
2ee8d87647 |
@@ -1,5 +1,16 @@
|
||||
# @esengine/node-editor
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [`3bfb8a1`](https://github.com/esengine/esengine/commit/3bfb8a1c9baba18373717910d29f266a71c1f63e) Thanks [@esengine](https://github.com/esengine)! - 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 to existing 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
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/node-editor",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -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<GraphCanvasProps> = ({
|
||||
onZoomChange,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onMouseDown: onMouseDownProp,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
children
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -132,22 +144,30 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
|
||||
}, [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<GraphCanvasProps> = ({
|
||||
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<GraphCanvasProps> = ({
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
|
||||
@@ -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<NodeEditorProps> = ({
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
|
||||
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
|
||||
const [boxSelectState, setBoxSelectState] = useState<BoxSelectState | null>(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<NodeEditorProps> = ({
|
||||
* 处理画布点击取消选择
|
||||
*/
|
||||
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<NodeEditorProps> = ({
|
||||
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<NodeEditorProps> = ({
|
||||
onContextMenu={handleCanvasContextMenu}
|
||||
onPanChange={handlePanChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onMouseDown={handleBoxSelectStart}
|
||||
onCanvasMouseMove={handleBoxSelectMove}
|
||||
onCanvasMouseUp={handleBoxSelectEnd}
|
||||
>
|
||||
{/* Connection layer (连接层) */}
|
||||
<ConnectionLayer
|
||||
@@ -580,6 +677,19 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Box selection overlay (框选覆盖层) */}
|
||||
{boxSelectState && (
|
||||
<div
|
||||
className="ne-selection-box"
|
||||
style={{
|
||||
left: Math.min(boxSelectState.startPos.x, boxSelectState.currentPos.x),
|
||||
top: Math.min(boxSelectState.startPos.y, boxSelectState.currentPos.y),
|
||||
width: Math.abs(boxSelectState.currentPos.x - boxSelectState.startPos.x),
|
||||
height: Math.abs(boxSelectState.currentPos.y - boxSelectState.startPos.y)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GraphCanvas>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,6 +64,8 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
|
||||
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<GraphNodeComponentProps> = ({
|
||||
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<GraphNodeComponentProps> = ({
|
||||
: 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<GraphNodeComponentProps> = ({
|
||||
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 */}
|
||||
<span className="ne-node-header-icon">
|
||||
{node.icon && renderIcon ? renderIcon(node.icon) : null}
|
||||
{hasError ? (
|
||||
<span className="ne-node-warning-icon" title={`Variable '${node.data.variableName}' not found`}>⚠</span>
|
||||
) : (
|
||||
node.icon && renderIcon ? renderIcon(node.icon) : null
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="ne-node-header-title">
|
||||
{node.title}
|
||||
{(node.data.displayTitle as string) || node.title}
|
||||
{node.subtitle && (
|
||||
<span className="ne-node-header-subtitle">
|
||||
{node.subtitle}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user