feat(node-editor, blueprint): add group box and math/logic nodes (#438)
* feat(node-editor, blueprint): add group box and math/logic nodes node-editor: - Add visual group box for organizing nodes - Dynamic bounds calculation based on node pin counts - Groups auto-resize to wrap contained nodes - Dragging group header moves all nodes together blueprint: - Add comprehensive math nodes (modulo, power, sqrt, trig, etc.) - Add logic nodes (comparison, boolean, select) docs: - Update nodes.md with new math and logic nodes - Add group feature documentation to editor-guide.md * chore: remove unused debug and test scripts Remove FBX animation debug scripts that are no longer needed: - analyze-fbx, debug-*, test-*, verify-*, check-*, compare-*, trace-*, simple-fbx-test Remove unused kill-dev-server.js from editor-app
This commit is contained in:
@@ -4,8 +4,10 @@ import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
import { Pin } from '../../domain/models/Pin';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { NodeGroup, computeGroupBounds, estimateNodeHeight } from '../../domain/models/NodeGroup';
|
||||
import { GraphCanvas } from '../canvas/GraphCanvas';
|
||||
import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent';
|
||||
import { MemoizedGroupNodeComponent } from '../nodes/GroupNodeComponent';
|
||||
import { ConnectionLayer } from '../connections/ConnectionLine';
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,12 @@ export interface NodeEditorProps {
|
||||
|
||||
/** Connection context menu callback (连接右键菜单回调) */
|
||||
onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void;
|
||||
|
||||
/** Group context menu callback (组右键菜单回调) */
|
||||
onGroupContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
|
||||
|
||||
/** Group double click callback - typically used to expand group (组双击回调 - 通常用于展开组) */
|
||||
onGroupDoubleClick?: (group: NodeGroup) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +120,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onNodeDoubleClick: _onNodeDoubleClick,
|
||||
onCanvasContextMenu,
|
||||
onNodeContextMenu,
|
||||
onConnectionContextMenu
|
||||
onConnectionContextMenu,
|
||||
onGroupContextMenu,
|
||||
onGroupDoubleClick
|
||||
}) => {
|
||||
// Silence unused variable warnings (消除未使用变量警告)
|
||||
void _templates;
|
||||
@@ -152,6 +162,64 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(',');
|
||||
}, [graph.nodes]);
|
||||
|
||||
// Groups are now simple visual boxes - no node hiding
|
||||
// 组现在是简单的可视化框 - 不隐藏节点
|
||||
|
||||
// Track selected group IDs (local state, managed similarly to nodes)
|
||||
// 跟踪选中的组ID(本地状态,类似节点管理方式)
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Group drag state - includes initial positions of nodes in the group
|
||||
// 组拖拽状态 - 包含组内节点的初始位置
|
||||
const [groupDragState, setGroupDragState] = useState<{
|
||||
groupId: string;
|
||||
startGroupPosition: Position;
|
||||
startMouse: { x: number; y: number };
|
||||
nodeStartPositions: Map<string, Position>;
|
||||
} | null>(null);
|
||||
|
||||
// Key for tracking group changes
|
||||
const groupsKey = useMemo(() => {
|
||||
return graph.groups.map(g => `${g.id}:${g.position.x}:${g.position.y}`).join(',');
|
||||
}, [graph.groups]);
|
||||
|
||||
// Compute dynamic group bounds based on current node positions and sizes
|
||||
// 根据当前节点位置和尺寸动态计算组边界
|
||||
const groupsWithDynamicBounds = useMemo(() => {
|
||||
const defaultNodeWidth = 200;
|
||||
|
||||
return graph.groups.map(group => {
|
||||
// Get current bounds of all nodes in this group
|
||||
const nodeBounds = group.nodeIds
|
||||
.map(nodeId => graph.getNode(nodeId))
|
||||
.filter((node): node is GraphNode => node !== undefined)
|
||||
.map(node => ({
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: defaultNodeWidth,
|
||||
height: estimateNodeHeight(
|
||||
node.inputPins.length,
|
||||
node.outputPins.length,
|
||||
node.isCollapsed
|
||||
)
|
||||
}));
|
||||
|
||||
if (nodeBounds.length === 0) {
|
||||
// No nodes found, use stored position/size as fallback
|
||||
return group;
|
||||
}
|
||||
|
||||
// Calculate dynamic bounds based on actual node sizes
|
||||
const { position, size } = computeGroupBounds(nodeBounds);
|
||||
|
||||
return {
|
||||
...group,
|
||||
position,
|
||||
size
|
||||
};
|
||||
});
|
||||
}, [graph.groups, graph.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to wait for DOM to be fully rendered
|
||||
// 使用 requestAnimationFrame 等待 DOM 完全渲染
|
||||
@@ -159,7 +227,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [graph.id, collapsedNodesKey]);
|
||||
}, [graph.id, collapsedNodesKey, groupsKey]);
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
@@ -181,6 +249,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
*
|
||||
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
|
||||
* 当节点收缩时,返回节点头部的位置
|
||||
* 当节点在折叠组中时,返回组节点的位置
|
||||
*/
|
||||
const getPinPosition = useCallback((pinId: string): Position | undefined => {
|
||||
// First, find which node this pin belongs to
|
||||
@@ -319,6 +388,22 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onGraphChange?.(newGraph);
|
||||
}
|
||||
|
||||
// Group dragging - moves all nodes inside (group bounds are dynamic)
|
||||
// 组拖拽 - 移动组内所有节点(组边界是动态计算的)
|
||||
if (groupDragState) {
|
||||
const dx = mousePos.x - groupDragState.startMouse.x;
|
||||
const dy = mousePos.y - groupDragState.startMouse.y;
|
||||
|
||||
// Only move nodes - group bounds will auto-recalculate
|
||||
let newGraph = graph;
|
||||
for (const [nodeId, startPos] of groupDragState.nodeStartPositions) {
|
||||
const newNodePos = new Position(startPos.x + dx, startPos.y + dy);
|
||||
newGraph = newGraph.moveNode(nodeId, newNodePos);
|
||||
}
|
||||
|
||||
onGraphChange?.(newGraph);
|
||||
}
|
||||
|
||||
// Connection dragging (连接拖拽)
|
||||
if (connectionDrag) {
|
||||
const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined;
|
||||
@@ -330,7 +415,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
isValid
|
||||
} : null);
|
||||
}
|
||||
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
|
||||
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
|
||||
|
||||
/**
|
||||
* Handles mouse up to end dragging
|
||||
@@ -342,6 +427,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
setDragState(null);
|
||||
}
|
||||
|
||||
// End group dragging (结束组拖拽)
|
||||
if (groupDragState) {
|
||||
setGroupDragState(null);
|
||||
}
|
||||
|
||||
// End connection dragging (结束连接拖拽)
|
||||
if (connectionDrag) {
|
||||
// Use hoveredPin directly instead of relying on async state update
|
||||
@@ -376,7 +466,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
|
||||
setConnectionDrag(null);
|
||||
}
|
||||
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
|
||||
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]);
|
||||
|
||||
/**
|
||||
* Handles pin mouse down
|
||||
@@ -487,6 +577,81 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
}
|
||||
}, [graph, onConnectionContextMenu]);
|
||||
|
||||
/**
|
||||
* Handles group selection
|
||||
* 处理组选择
|
||||
*/
|
||||
const handleGroupSelect = useCallback((groupId: string, additive: boolean) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const newSelection = new Set(selectedGroupIds);
|
||||
|
||||
if (additive) {
|
||||
if (newSelection.has(groupId)) {
|
||||
newSelection.delete(groupId);
|
||||
} else {
|
||||
newSelection.add(groupId);
|
||||
}
|
||||
} else {
|
||||
newSelection.clear();
|
||||
newSelection.add(groupId);
|
||||
}
|
||||
|
||||
setSelectedGroupIds(newSelection);
|
||||
// Clear node and connection selection when selecting groups
|
||||
onSelectionChange?.(new Set(), new Set());
|
||||
}, [selectedGroupIds, readOnly, onSelectionChange]);
|
||||
|
||||
/**
|
||||
* Handles group drag start
|
||||
* 处理组拖拽开始
|
||||
*
|
||||
* Captures initial positions of both the group and all nodes inside it
|
||||
* 捕获组和组内所有节点的初始位置
|
||||
*/
|
||||
const handleGroupDragStart = useCallback((groupId: string, startMouse: { x: number; y: number }) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const group = graph.getGroup(groupId);
|
||||
if (!group) return;
|
||||
|
||||
// Convert screen coordinates to canvas coordinates (same as node dragging)
|
||||
// 将屏幕坐标转换为画布坐标(与节点拖拽相同)
|
||||
const canvasPos = screenToCanvas(startMouse.x, startMouse.y);
|
||||
|
||||
// Capture initial positions of all nodes in the group
|
||||
const nodeStartPositions = new Map<string, Position>();
|
||||
for (const nodeId of group.nodeIds) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node) {
|
||||
nodeStartPositions.set(nodeId, node.position);
|
||||
}
|
||||
}
|
||||
|
||||
setGroupDragState({
|
||||
groupId,
|
||||
startGroupPosition: group.position,
|
||||
startMouse: { x: canvasPos.x, y: canvasPos.y },
|
||||
nodeStartPositions
|
||||
});
|
||||
}, [graph, readOnly, screenToCanvas]);
|
||||
|
||||
/**
|
||||
* Handles group context menu
|
||||
* 处理组右键菜单
|
||||
*/
|
||||
const handleGroupContextMenu = useCallback((group: NodeGroup, e: React.MouseEvent) => {
|
||||
onGroupContextMenu?.(group, e);
|
||||
}, [onGroupContextMenu]);
|
||||
|
||||
/**
|
||||
* Handles group double click
|
||||
* 处理组双击
|
||||
*/
|
||||
const handleGroupDoubleClick = useCallback((group: NodeGroup) => {
|
||||
onGroupDoubleClick?.(group);
|
||||
}, [onGroupDoubleClick]);
|
||||
|
||||
/**
|
||||
* Handles canvas click to deselect
|
||||
* 处理画布点击取消选择
|
||||
@@ -500,6 +665,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
}
|
||||
if (!readOnly) {
|
||||
onSelectionChange?.(new Set(), new Set());
|
||||
setSelectedGroupIds(new Set());
|
||||
}
|
||||
}, [readOnly, onSelectionChange]);
|
||||
|
||||
@@ -644,6 +810,21 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onCanvasMouseMove={handleBoxSelectMove}
|
||||
onCanvasMouseUp={handleBoxSelectEnd}
|
||||
>
|
||||
{/* Group boxes - rendered first so they appear behind nodes (组框 - 先渲染,这样显示在节点后面) */}
|
||||
{/* Use dynamically calculated bounds so groups auto-resize to fit nodes */}
|
||||
{groupsWithDynamicBounds.map(group => (
|
||||
<MemoizedGroupNodeComponent
|
||||
key={group.id}
|
||||
group={group}
|
||||
isSelected={selectedGroupIds.has(group.id)}
|
||||
isDragging={groupDragState?.groupId === group.id}
|
||||
onSelect={handleGroupSelect}
|
||||
onDragStart={handleGroupDragStart}
|
||||
onContextMenu={handleGroupContextMenu}
|
||||
onDoubleClick={handleGroupDoubleClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connection layer (连接层) */}
|
||||
<ConnectionLayer
|
||||
connections={graph.connections}
|
||||
@@ -655,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onConnectionContextMenu={handleConnectionContextMenu}
|
||||
/>
|
||||
|
||||
{/* Nodes (节点) */}
|
||||
{/* All Nodes (所有节点) */}
|
||||
{graph.nodes.map(node => (
|
||||
<MemoizedGraphNodeComponent
|
||||
key={node.id}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { NodeGroup } from '../../domain/models/NodeGroup';
|
||||
import '../../styles/GroupNode.css';
|
||||
|
||||
export interface GroupNodeComponentProps {
|
||||
/** The group to render */
|
||||
group: NodeGroup;
|
||||
|
||||
/** Whether the group is selected */
|
||||
isSelected?: boolean;
|
||||
|
||||
/** Whether the group is being dragged */
|
||||
isDragging?: boolean;
|
||||
|
||||
/** Selection handler */
|
||||
onSelect?: (groupId: string, additive: boolean) => void;
|
||||
|
||||
/** Drag start handler */
|
||||
onDragStart?: (groupId: string, startPosition: { x: number; y: number }) => void;
|
||||
|
||||
/** Context menu handler */
|
||||
onContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
|
||||
|
||||
/** Double click handler for editing name */
|
||||
onDoubleClick?: (group: NodeGroup) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GroupNodeComponent - Renders a visual group box around nodes
|
||||
* GroupNodeComponent - 渲染节点周围的可视化组框
|
||||
*
|
||||
* This is a simple background box that provides visual organization.
|
||||
* 这是一个简单的背景框,提供视觉组织功能。
|
||||
*/
|
||||
export const GroupNodeComponent: React.FC<GroupNodeComponentProps> = ({
|
||||
group,
|
||||
isSelected = false,
|
||||
isDragging = false,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
onContextMenu,
|
||||
onDoubleClick
|
||||
}) => {
|
||||
const groupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
// Only handle clicks on the header or border, not on the content area
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.ne-group-box-header') && !target.classList.contains('ne-group-box')) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
|
||||
const additive = e.ctrlKey || e.metaKey;
|
||||
onSelect?.(group.id, additive);
|
||||
onDragStart?.(group.id, { x: e.clientX, y: e.clientY });
|
||||
}, [group.id, onSelect, onDragStart]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(group, e);
|
||||
}, [group, onContextMenu]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
// Only handle double-click on the header
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.ne-group-box-header')) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(group);
|
||||
}, [group, onDoubleClick]);
|
||||
|
||||
const classNames = useMemo(() => {
|
||||
const classes = ['ne-group-box'];
|
||||
if (isSelected) classes.push('selected');
|
||||
if (isDragging) classes.push('dragging');
|
||||
return classes.join(' ');
|
||||
}, [isSelected, isDragging]);
|
||||
|
||||
const style: React.CSSProperties = useMemo(() => ({
|
||||
left: group.position.x,
|
||||
top: group.position.y,
|
||||
width: group.size.width,
|
||||
height: group.size.height,
|
||||
'--group-color': group.color || 'rgba(100, 149, 237, 0.15)'
|
||||
} as React.CSSProperties), [group.position.x, group.position.y, group.size.width, group.size.height, group.color]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={groupRef}
|
||||
className={classNames}
|
||||
style={style}
|
||||
data-group-id={group.id}
|
||||
onMouseDown={handleMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="ne-group-box-header">
|
||||
<span className="ne-group-box-title">{group.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Memoized version for performance
|
||||
*/
|
||||
export const MemoizedGroupNodeComponent = React.memo(GroupNodeComponent, (prev, next) => {
|
||||
if (prev.group.id !== next.group.id) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
if (prev.isDragging !== next.isDragging) return false;
|
||||
if (prev.group.position.x !== next.group.position.x ||
|
||||
prev.group.position.y !== next.group.position.y) return false;
|
||||
if (prev.group.size.width !== next.group.size.width ||
|
||||
prev.group.size.height !== next.group.size.height) return false;
|
||||
if (prev.group.name !== next.group.name) return false;
|
||||
if (prev.group.color !== next.group.color) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
export default GroupNodeComponent;
|
||||
@@ -4,3 +4,9 @@ export {
|
||||
type GraphNodeComponentProps,
|
||||
type NodeExecutionState
|
||||
} from './GraphNodeComponent';
|
||||
|
||||
export {
|
||||
GroupNodeComponent,
|
||||
MemoizedGroupNodeComponent,
|
||||
type GroupNodeComponentProps
|
||||
} from './GroupNodeComponent';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GraphNode } from './GraphNode';
|
||||
import { Connection } from './Connection';
|
||||
import { Pin } from './Pin';
|
||||
import { Position } from '../value-objects/Position';
|
||||
import { NodeGroup, serializeNodeGroup } from './NodeGroup';
|
||||
|
||||
/**
|
||||
* Graph - Aggregate root for the node graph
|
||||
@@ -15,6 +16,7 @@ export class Graph {
|
||||
private readonly _name: string;
|
||||
private readonly _nodes: Map<string, GraphNode>;
|
||||
private readonly _connections: Connection[];
|
||||
private readonly _groups: NodeGroup[];
|
||||
private readonly _metadata: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
@@ -22,12 +24,14 @@ export class Graph {
|
||||
name: string,
|
||||
nodes: GraphNode[] = [],
|
||||
connections: Connection[] = [],
|
||||
metadata: Record<string, unknown> = {}
|
||||
metadata: Record<string, unknown> = {},
|
||||
groups: NodeGroup[] = []
|
||||
) {
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._nodes = new Map(nodes.map(n => [n.id, n]));
|
||||
this._connections = [...connections];
|
||||
this._groups = [...groups];
|
||||
this._metadata = { ...metadata };
|
||||
}
|
||||
|
||||
@@ -59,6 +63,29 @@ export class Graph {
|
||||
return this._connections.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all groups (节点组)
|
||||
*/
|
||||
get groups(): NodeGroup[] {
|
||||
return [...this._groups];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a group by ID
|
||||
* 通过ID获取组
|
||||
*/
|
||||
getGroup(groupId: string): NodeGroup | undefined {
|
||||
return this._groups.find(g => g.id === groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the group containing a specific node
|
||||
* 获取包含特定节点的组
|
||||
*/
|
||||
getNodeGroup(nodeId: string): NodeGroup | undefined {
|
||||
return this._groups.find(g => g.nodeIds.includes(nodeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a node by ID
|
||||
* 通过ID获取节点
|
||||
@@ -112,7 +139,7 @@ export class Graph {
|
||||
throw new Error(`Node with ID "${node.id}" already exists`);
|
||||
}
|
||||
const newNodes = [...this.nodes, node];
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +152,14 @@ export class Graph {
|
||||
}
|
||||
const newNodes = this.nodes.filter(n => n.id !== nodeId);
|
||||
const newConnections = this._connections.filter(c => !c.involvesNode(nodeId));
|
||||
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata);
|
||||
// Also remove the node from any groups it belongs to
|
||||
const newGroups = this._groups.map(g => {
|
||||
if (g.nodeIds.includes(nodeId)) {
|
||||
return { ...g, nodeIds: g.nodeIds.filter(id => id !== nodeId) };
|
||||
}
|
||||
return g;
|
||||
}).filter(g => g.nodeIds.length > 0); // Remove empty groups
|
||||
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata, newGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +172,7 @@ export class Graph {
|
||||
|
||||
const updatedNode = updater(node);
|
||||
const newNodes = this.nodes.map(n => n.id === nodeId ? updatedNode : n);
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +218,7 @@ export class Graph {
|
||||
}
|
||||
|
||||
newConnections.push(connection);
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +230,7 @@ export class Graph {
|
||||
if (newConnections.length === this._connections.length) {
|
||||
return this;
|
||||
}
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +242,47 @@ export class Graph {
|
||||
if (newConnections.length === this._connections.length) {
|
||||
return this;
|
||||
}
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
// ========== Group Operations (组操作) ==========
|
||||
|
||||
/**
|
||||
* Adds a new group (immutable)
|
||||
* 添加新组(不可变)
|
||||
*/
|
||||
addGroup(group: NodeGroup): Graph {
|
||||
if (this._groups.some(g => g.id === group.id)) {
|
||||
throw new Error(`Group with ID "${group.id}" already exists`);
|
||||
}
|
||||
const newGroups = [...this._groups, group];
|
||||
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a group (immutable)
|
||||
* 移除组(不可变)
|
||||
*/
|
||||
removeGroup(groupId: string): Graph {
|
||||
const newGroups = this._groups.filter(g => g.id !== groupId);
|
||||
if (newGroups.length === this._groups.length) {
|
||||
return this;
|
||||
}
|
||||
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a group (immutable)
|
||||
* 更新组(不可变)
|
||||
*/
|
||||
updateGroup(groupId: string, updater: (group: NodeGroup) => NodeGroup): Graph {
|
||||
const groupIndex = this._groups.findIndex(g => g.id === groupId);
|
||||
if (groupIndex === -1) return this;
|
||||
|
||||
const updatedGroup = updater(this._groups[groupIndex]);
|
||||
const newGroups = [...this._groups];
|
||||
newGroups[groupIndex] = updatedGroup;
|
||||
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +293,7 @@ export class Graph {
|
||||
return new Graph(this._id, this._name, this.nodes, this._connections, {
|
||||
...this._metadata,
|
||||
...metadata
|
||||
});
|
||||
}, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +301,7 @@ export class Graph {
|
||||
* 创建具有更新名称的新图(不可变)
|
||||
*/
|
||||
rename(newName: string): Graph {
|
||||
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata);
|
||||
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata, this._groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,6 +331,7 @@ export class Graph {
|
||||
name: this._name,
|
||||
nodes: this.nodes.map(n => n.toJSON()),
|
||||
connections: this._connections.map(c => c.toJSON()),
|
||||
groups: this._groups.map(g => serializeNodeGroup(g)),
|
||||
metadata: this._metadata
|
||||
};
|
||||
}
|
||||
|
||||
146
packages/devtools/node-editor/src/domain/models/NodeGroup.ts
Normal file
146
packages/devtools/node-editor/src/domain/models/NodeGroup.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Position } from '../value-objects/Position';
|
||||
|
||||
/**
|
||||
* NodeGroup - Represents a visual group box around nodes
|
||||
* NodeGroup - 表示节点周围的可视化组框
|
||||
*
|
||||
* Groups are purely visual organization - they don't affect runtime execution.
|
||||
* 组是纯视觉组织 - 不影响运行时执行。
|
||||
*/
|
||||
export interface NodeGroup {
|
||||
/** Unique identifier for the group */
|
||||
id: string;
|
||||
|
||||
/** Display name of the group */
|
||||
name: string;
|
||||
|
||||
/** IDs of nodes contained in this group */
|
||||
nodeIds: string[];
|
||||
|
||||
/** Position of the group box (top-left corner) */
|
||||
position: Position;
|
||||
|
||||
/** Size of the group box */
|
||||
size: { width: number; height: number };
|
||||
|
||||
/** Optional color for the group box */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NodeGroup with the given properties
|
||||
*/
|
||||
export function createNodeGroup(
|
||||
id: string,
|
||||
name: string,
|
||||
nodeIds: string[],
|
||||
position: Position,
|
||||
size: { width: number; height: number },
|
||||
color?: string
|
||||
): NodeGroup {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
nodeIds: [...nodeIds],
|
||||
position,
|
||||
size,
|
||||
color
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Node bounds info for group calculation
|
||||
* 用于组计算的节点边界信息
|
||||
*/
|
||||
export interface NodeBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates node height based on pin count
|
||||
* 根据引脚数量估算节点高度
|
||||
*/
|
||||
export function estimateNodeHeight(inputPinCount: number, outputPinCount: number, isCollapsed: boolean = false): number {
|
||||
if (isCollapsed) {
|
||||
return 32; // Just header
|
||||
}
|
||||
const headerHeight = 32;
|
||||
const pinHeight = 26;
|
||||
const bottomPadding = 12;
|
||||
const maxPins = Math.max(inputPinCount, outputPinCount);
|
||||
return headerHeight + maxPins * pinHeight + bottomPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the bounding box for a group based on its nodes
|
||||
* Returns position (top-left) and size with padding
|
||||
*
|
||||
* @param nodeBounds - Array of node bounds (position + size)
|
||||
* @param padding - Padding around the group box
|
||||
*/
|
||||
export function computeGroupBounds(
|
||||
nodeBounds: NodeBounds[],
|
||||
padding: number = 30
|
||||
): { position: Position; size: { width: number; height: number } } {
|
||||
if (nodeBounds.length === 0) {
|
||||
return {
|
||||
position: new Position(0, 0),
|
||||
size: { width: 250, height: 150 }
|
||||
};
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const node of nodeBounds) {
|
||||
minX = Math.min(minX, node.x);
|
||||
minY = Math.min(minY, node.y);
|
||||
maxX = Math.max(maxX, node.x + node.width);
|
||||
maxY = Math.max(maxY, node.y + node.height);
|
||||
}
|
||||
|
||||
// Add padding and header space for group title
|
||||
const groupHeaderHeight = 28;
|
||||
return {
|
||||
position: new Position(minX - padding, minY - padding - groupHeaderHeight),
|
||||
size: {
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY - minY + padding * 2 + groupHeaderHeight
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a NodeGroup for JSON storage
|
||||
*/
|
||||
export function serializeNodeGroup(group: NodeGroup): Record<string, unknown> {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
nodeIds: [...group.nodeIds],
|
||||
position: { x: group.position.x, y: group.position.y },
|
||||
size: { width: group.size.width, height: group.size.height },
|
||||
color: group.color
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a NodeGroup from JSON
|
||||
*/
|
||||
export function deserializeNodeGroup(data: Record<string, unknown>): NodeGroup {
|
||||
const pos = data.position as { x: number; y: number } | undefined;
|
||||
const size = data.size as { width: number; height: number } | undefined;
|
||||
return {
|
||||
id: data.id as string,
|
||||
name: data.name as string,
|
||||
nodeIds: (data.nodeIds as string[]) || [],
|
||||
position: new Position(pos?.x || 0, pos?.y || 0),
|
||||
size: size || { width: 250, height: 150 },
|
||||
color: data.color as string | undefined
|
||||
};
|
||||
}
|
||||
@@ -2,3 +2,12 @@ export { Pin, type PinDefinition } from './Pin';
|
||||
export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode';
|
||||
export { Connection } from './Connection';
|
||||
export { Graph } from './Graph';
|
||||
export {
|
||||
type NodeGroup,
|
||||
type NodeBounds,
|
||||
createNodeGroup,
|
||||
computeGroupBounds,
|
||||
estimateNodeHeight,
|
||||
serializeNodeGroup,
|
||||
deserializeNodeGroup
|
||||
} from './NodeGroup';
|
||||
|
||||
@@ -23,7 +23,15 @@ export {
|
||||
// Types
|
||||
type NodeTemplate,
|
||||
type NodeCategory,
|
||||
type PinDefinition
|
||||
type PinDefinition,
|
||||
// NodeGroup
|
||||
type NodeGroup,
|
||||
type NodeBounds,
|
||||
createNodeGroup,
|
||||
computeGroupBounds,
|
||||
estimateNodeHeight,
|
||||
serializeNodeGroup,
|
||||
deserializeNodeGroup
|
||||
} from './domain/models';
|
||||
|
||||
// Value objects (值对象)
|
||||
|
||||
@@ -281,3 +281,32 @@
|
||||
0 0 20px rgba(255, 167, 38, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Group Node 组节点 ==================== */
|
||||
.ne-node.ne-group-node {
|
||||
border: 2px dashed rgba(100, 149, 237, 0.6);
|
||||
background: rgba(40, 60, 90, 0.85);
|
||||
}
|
||||
|
||||
.ne-node.ne-group-node:hover {
|
||||
border-color: rgba(100, 149, 237, 0.8);
|
||||
}
|
||||
|
||||
.ne-node.ne-group-node.selected {
|
||||
border-color: #6495ed;
|
||||
box-shadow: 0 0 0 1px #6495ed,
|
||||
0 0 16px rgba(100, 149, 237, 0.5),
|
||||
0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.ne-group-header {
|
||||
background: linear-gradient(90deg, #3a5f8a 0%, #2a4a6a 100%) !important;
|
||||
}
|
||||
|
||||
.ne-group-hint {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #8899aa;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
60
packages/devtools/node-editor/src/styles/GroupNode.css
Normal file
60
packages/devtools/node-editor/src/styles/GroupNode.css
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Group Box Styles
|
||||
* 组框样式
|
||||
*/
|
||||
|
||||
/* ==================== Group Box Container 组框容器 ==================== */
|
||||
.ne-group-box {
|
||||
position: absolute;
|
||||
background: var(--group-color, rgba(100, 149, 237, 0.15));
|
||||
border: 2px dashed rgba(100, 149, 237, 0.5);
|
||||
border-radius: 8px;
|
||||
pointer-events: auto;
|
||||
z-index: 0;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.ne-group-box:hover {
|
||||
border-color: rgba(100, 149, 237, 0.7);
|
||||
}
|
||||
|
||||
.ne-group-box.selected {
|
||||
border-color: var(--ne-node-border-selected, #e5a020);
|
||||
box-shadow: 0 0 0 1px var(--ne-node-border-selected, #e5a020),
|
||||
0 0 12px rgba(229, 160, 32, 0.3);
|
||||
}
|
||||
|
||||
.ne-group-box.dragging {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ==================== Group Box Header 组框头部 ==================== */
|
||||
.ne-group-box-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
background: rgba(100, 149, 237, 0.3);
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ne-group-box-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ne-group-box-title {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
@import './variables.css';
|
||||
@import './Canvas.css';
|
||||
@import './GraphNode.css';
|
||||
@import './GroupNode.css';
|
||||
@import './NodePin.css';
|
||||
@import './Connection.css';
|
||||
@import './ContextMenu.css';
|
||||
|
||||
Reference in New Issue
Block a user