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:
YHH
2026-01-05 11:23:42 +08:00
committed by GitHub
parent 45de62e453
commit 0d33cf0097
39 changed files with 1770 additions and 6487 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -4,3 +4,9 @@ export {
type GraphNodeComponentProps,
type NodeExecutionState
} from './GraphNodeComponent';
export {
GroupNodeComponent,
MemoizedGroupNodeComponent,
type GroupNodeComponentProps
} from './GroupNodeComponent';

View File

@@ -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
};
}

View 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
};
}

View File

@@ -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';

View File

@@ -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 (值对象)

View File

@@ -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;
}

View 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;
}

View File

@@ -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';