Feature/physics and tilemap enhancement (#247)

* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统

* feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统

* feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -0,0 +1,71 @@
{
"name": "@esengine/node-editor",
"version": "1.0.0",
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles": {
"import": "./dist/styles/index.css"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "rimraf dist tsconfig.tsbuildinfo",
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit"
},
"keywords": [
"node-editor",
"visual-programming",
"blueprint",
"shader-graph",
"state-machine",
"ecs",
"game-engine"
],
"author": "yhh",
"license": "MIT",
"peerDependencies": {
"react": "^18.3.1",
"zustand": "^4.5.2"
},
"peerDependenciesMeta": {
"react": {
"optional": false
},
"zustand": {
"optional": false
}
},
"devDependencies": {
"@types/node": "^20.19.17",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"rimraf": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^6.0.7",
"vite-plugin-dts": "^3.7.0"
},
"dependencies": {
"tslib": "^2.8.1"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/ecs-framework.git",
"directory": "packages/node-editor"
}
}

View File

@@ -0,0 +1,374 @@
import React, { useRef, useCallback, useState, useMemo } from 'react';
import { Position } from '../../domain/value-objects/Position';
export interface GraphCanvasProps {
/** Canvas width in pixels (画布宽度,像素) */
width?: number | string;
/** Canvas height in pixels (画布高度,像素) */
height?: number | string;
/** Initial pan offset (初始平移偏移) */
initialPan?: Position;
/** Initial zoom level (初始缩放级别) */
initialZoom?: number;
/** Minimum zoom level (最小缩放级别) */
minZoom?: number;
/** Maximum zoom level (最大缩放级别) */
maxZoom?: number;
/** Grid size in pixels (网格大小,像素) */
gridSize?: number;
/** Whether to show grid (是否显示网格) */
showGrid?: boolean;
/** Background color (背景颜色) */
backgroundColor?: string;
/** Grid color (网格颜色) */
gridColor?: string;
/** Major grid line interval (主网格线间隔) */
majorGridInterval?: number;
/** Major grid color (主网格颜色) */
majorGridColor?: string;
/** Pan change callback (平移变化回调) */
onPanChange?: (pan: Position) => void;
/** Zoom change callback (缩放变化回调) */
onZoomChange?: (zoom: number) => void;
/** Canvas click callback (画布点击回调) */
onClick?: (position: Position, e: React.MouseEvent) => void;
/** Canvas context menu callback (画布右键菜单回调) */
onContextMenu?: (position: Position, e: React.MouseEvent) => void;
/** Children to render (要渲染的子元素) */
children?: React.ReactNode;
}
/**
* GraphCanvas - Pannable and zoomable canvas for node graphs
* GraphCanvas - 可平移和缩放的节点图画布
*/
export const GraphCanvas: React.FC<GraphCanvasProps> = ({
width = '100%',
height = '100%',
initialPan = Position.ZERO,
initialZoom = 1,
minZoom = 0.1,
maxZoom = 2,
gridSize = 20,
showGrid = true,
backgroundColor = 'var(--ne-canvas-bg)',
gridColor = 'var(--ne-canvas-grid)',
majorGridInterval = 5,
majorGridColor = 'var(--ne-canvas-grid-major)',
onPanChange,
onZoomChange,
onClick,
onContextMenu,
children
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState(initialPan);
const [zoom, setZoom] = useState(initialZoom);
const [isPanning, setIsPanning] = useState(false);
const lastMousePos = useRef<Position>(Position.ZERO);
// Sync pan state with callback
const updatePan = useCallback((newPan: Position) => {
setPan(newPan);
onPanChange?.(newPan);
}, [onPanChange]);
// Sync zoom state with callback
const updateZoom = useCallback((newZoom: number) => {
setZoom(newZoom);
onZoomChange?.(newZoom);
}, [onZoomChange]);
/**
* Converts screen coordinates to canvas coordinates
* 将屏幕坐标转换为画布坐标
*/
const screenToCanvas = useCallback((screenX: number, screenY: number): Position => {
if (!containerRef.current) return new Position(screenX, screenY);
const rect = containerRef.current.getBoundingClientRect();
const x = (screenX - rect.left - pan.x) / zoom;
const y = (screenY - rect.top - pan.y) / zoom;
return new Position(x, y);
}, [pan, zoom]);
/**
* Handles mouse wheel for zooming
* 处理鼠标滚轮缩放
*/
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
const newZoom = Math.max(minZoom, Math.min(maxZoom, zoom * (1 + delta)));
if (newZoom !== zoom && containerRef.current) {
// Zoom towards cursor position (以光标位置为中心缩放)
const rect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomRatio = newZoom / zoom;
const newPanX = mouseX - (mouseX - pan.x) * zoomRatio;
const newPanY = mouseY - (mouseY - pan.y) * zoomRatio;
updateZoom(newZoom);
updatePan(new Position(newPanX, newPanY));
}
}, [zoom, pan, minZoom, maxZoom, updateZoom, updatePan]);
/**
* Handles mouse down for panning
* 处理鼠标按下开始平移
*/
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Middle mouse button or space + left click for panning
// 中键或空格+左键平移
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
setIsPanning(true);
lastMousePos.current = new Position(e.clientX, e.clientY);
}
}, []);
/**
* Handles mouse move for panning
* 处理鼠标移动进行平移
*/
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
const dx = e.clientX - lastMousePos.current.x;
const dy = e.clientY - lastMousePos.current.y;
lastMousePos.current = new Position(e.clientX, e.clientY);
const newPan = new Position(pan.x + dx, pan.y + dy);
updatePan(newPan);
}
}, [isPanning, pan, updatePan]);
/**
* Handles mouse up to stop panning
* 处理鼠标释放停止平移
*/
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
/**
* Handles canvas click
* 处理画布点击
*/
const handleClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current || (e.target as HTMLElement).classList.contains('ne-canvas-content')) {
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onClick?.(canvasPos, e);
}
}, [onClick, screenToCanvas]);
/**
* Handles context menu
* 处理右键菜单
*/
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onContextMenu?.(canvasPos, e);
}, [onContextMenu, screenToCanvas]);
// Grid pattern SVG
// 网格图案 SVG
const gridPattern = useMemo(() => {
if (!showGrid) return null;
const scaledGridSize = gridSize * zoom;
const majorSize = scaledGridSize * majorGridInterval;
return (
<svg
className="ne-canvas-grid"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}
>
<defs>
{/* Minor grid pattern (小网格图案) */}
<pattern
id="ne-grid-minor"
width={scaledGridSize}
height={scaledGridSize}
patternUnits="userSpaceOnUse"
x={pan.x % scaledGridSize}
y={pan.y % scaledGridSize}
>
<path
d={`M ${scaledGridSize} 0 L 0 0 0 ${scaledGridSize}`}
fill="none"
stroke={gridColor}
strokeWidth="0.5"
/>
</pattern>
{/* Major grid pattern (主网格图案) */}
<pattern
id="ne-grid-major"
width={majorSize}
height={majorSize}
patternUnits="userSpaceOnUse"
x={pan.x % majorSize}
y={pan.y % majorSize}
>
<path
d={`M ${majorSize} 0 L 0 0 0 ${majorSize}`}
fill="none"
stroke={majorGridColor}
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#ne-grid-minor)" />
<rect width="100%" height="100%" fill="url(#ne-grid-major)" />
</svg>
);
}, [showGrid, gridSize, zoom, pan, gridColor, majorGridColor, majorGridInterval]);
// Transform style for content
// 内容的变换样式
const contentStyle: React.CSSProperties = useMemo(() => ({
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
position: 'absolute' as const,
top: 0,
left: 0,
width: '100%',
height: '100%'
}), [pan, zoom]);
return (
<div
ref={containerRef}
className="ne-canvas"
style={{
width,
height,
backgroundColor,
position: 'relative',
overflow: 'hidden',
cursor: isPanning ? 'grabbing' : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
{/* Grid background (网格背景) */}
{gridPattern}
{/* Transformable content container (可变换的内容容器) */}
<div className="ne-canvas-content" style={contentStyle}>
{children}
</div>
</div>
);
};
/**
* useCanvasTransform - Hook for managing canvas transform state
* useCanvasTransform - 管理画布变换状态的 Hook
*/
export interface CanvasTransform {
pan: Position;
zoom: number;
setPan: (pan: Position) => void;
setZoom: (zoom: number) => void;
screenToCanvas: (screenX: number, screenY: number) => Position;
canvasToScreen: (canvasX: number, canvasY: number) => Position;
resetTransform: () => void;
fitToContent: (bounds: { minX: number; minY: number; maxX: number; maxY: number }, padding?: number) => void;
}
export function useCanvasTransform(
containerRef: React.RefObject<HTMLElement | null>,
initialPan = Position.ZERO,
initialZoom = 1
): CanvasTransform {
const [pan, setPan] = useState(initialPan);
const [zoom, setZoom] = useState(initialZoom);
const screenToCanvas = useCallback((screenX: number, screenY: number): Position => {
if (!containerRef.current) return new Position(screenX, screenY);
const rect = containerRef.current.getBoundingClientRect();
const x = (screenX - rect.left - pan.x) / zoom;
const y = (screenY - rect.top - pan.y) / zoom;
return new Position(x, y);
}, [containerRef, pan, zoom]);
const canvasToScreen = useCallback((canvasX: number, canvasY: number): Position => {
if (!containerRef.current) return new Position(canvasX, canvasY);
const rect = containerRef.current.getBoundingClientRect();
const x = canvasX * zoom + pan.x + rect.left;
const y = canvasY * zoom + pan.y + rect.top;
return new Position(x, y);
}, [containerRef, pan, zoom]);
const resetTransform = useCallback(() => {
setPan(initialPan);
setZoom(initialZoom);
}, [initialPan, initialZoom]);
const fitToContent = useCallback((
bounds: { minX: number; minY: number; maxX: number; maxY: number },
padding = 50
) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const contentWidth = bounds.maxX - bounds.minX + padding * 2;
const contentHeight = bounds.maxY - bounds.minY + padding * 2;
const scaleX = rect.width / contentWidth;
const scaleY = rect.height / contentHeight;
const newZoom = Math.min(scaleX, scaleY, 1);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
const newPanX = rect.width / 2 - centerX * newZoom;
const newPanY = rect.height / 2 - centerY * newZoom;
setZoom(newZoom);
setPan(new Position(newPanX, newPanY));
}, [containerRef]);
return {
pan,
zoom,
setPan,
setZoom,
screenToCanvas,
canvasToScreen,
resetTransform,
fitToContent
};
}
export default GraphCanvas;

View File

@@ -0,0 +1,6 @@
export {
GraphCanvas,
useCanvasTransform,
type GraphCanvasProps,
type CanvasTransform
} from './GraphCanvas';

View File

@@ -0,0 +1,225 @@
import React, { useMemo } from 'react';
import { Connection } from '../../domain/models/Connection';
import { PinCategory } from '../../domain/value-objects/PinType';
import { Position } from '../../domain/value-objects/Position';
export interface ConnectionLineProps {
/** Connection data (连接数据) */
connection: Connection;
/** Start position (起点位置) */
from: Position;
/** End position (终点位置) */
to: Position;
/** Whether the connection is selected (连接是否被选中) */
isSelected?: boolean;
/** Whether to show flow animation for exec connections (是否显示执行连接的流动动画) */
animated?: boolean;
/** Click handler (点击处理) */
onClick?: (connectionId: string, e: React.MouseEvent) => void;
/** Context menu handler (右键菜单处理) */
onContextMenu?: (connectionId: string, e: React.MouseEvent) => void;
}
/**
* Calculates bezier curve control points for smooth connection
* 计算平滑连接的贝塞尔曲线控制点
*/
function calculateBezierPath(from: Position, to: Position): string {
const dx = to.x - from.x;
// Calculate control point offset based on distance
// 根据距离计算控制点偏移
const curvature = Math.min(Math.abs(dx) * 0.5, 150);
// Horizontal bezier curve (水平贝塞尔曲线)
const cp1x = from.x + curvature;
const cp1y = from.y;
const cp2x = to.x - curvature;
const cp2y = to.y;
return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`;
}
/**
* ConnectionLine - SVG bezier curve connection between pins
* ConnectionLine - 引脚之间的 SVG 贝塞尔曲线连接
*/
export const ConnectionLine: React.FC<ConnectionLineProps> = ({
connection,
from,
to,
isSelected = false,
animated = false,
onClick,
onContextMenu
}) => {
const pathD = useMemo(() => calculateBezierPath(from, to), [from, to]);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(connection.id, e);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(connection.id, e);
};
const classNames = useMemo(() => {
const classes = ['ne-connection', connection.category];
if (isSelected) classes.push('selected');
if (animated && connection.isExec) classes.push('animated');
return classes.join(' ');
}, [connection.category, connection.isExec, isSelected, animated]);
return (
<g>
{/* Hit area for easier selection (更容易选择的点击区域) */}
<path
className="ne-connection-hit"
d={pathD}
onClick={handleClick}
onContextMenu={handleContextMenu}
/>
{/* Glow effect (发光效果) */}
<path
className={`ne-connection-glow ${connection.category}`}
d={pathD}
/>
{/* Main connection line (主连接线) */}
<path
className={classNames}
d={pathD}
onClick={handleClick}
onContextMenu={handleContextMenu}
/>
</g>
);
};
export interface ConnectionPreviewProps {
/** Start position (起点位置) */
from: Position;
/** End position (current mouse position) (终点位置,当前鼠标位置) */
to: Position;
/** Pin category for coloring (引脚类型用于着色) */
category: PinCategory;
/** Whether the target is valid (目标是否有效) */
isValid?: boolean;
}
/**
* ConnectionPreview - Preview line shown while dragging a connection
* ConnectionPreview - 拖拽连接时显示的预览线
*/
export const ConnectionPreview: React.FC<ConnectionPreviewProps> = ({
from,
to,
category,
isValid
}) => {
const pathD = useMemo(() => calculateBezierPath(from, to), [from, to]);
const classNames = useMemo(() => {
const classes = ['ne-connection-preview', category];
if (isValid === true) classes.push('valid');
if (isValid === false) classes.push('invalid');
return classes.join(' ');
}, [category, isValid]);
return (
<path
className={classNames}
d={pathD}
/>
);
};
export interface ConnectionLayerProps {
/** All connections to render (要渲染的所有连接) */
connections: Connection[];
/** Function to get pin position by ID (通过ID获取引脚位置的函数) */
getPinPosition: (pinId: string) => Position | undefined;
/** Currently selected connection IDs (当前选中的连接ID) */
selectedConnectionIds?: Set<string>;
/** Whether to animate exec connections (是否动画化执行连接) */
animateExec?: boolean;
/** Preview connection while dragging (拖拽时的预览连接) */
preview?: {
from: Position;
to: Position;
category: PinCategory;
isValid?: boolean;
};
/** Connection click handler (连接点击处理) */
onConnectionClick?: (connectionId: string, e: React.MouseEvent) => void;
/** Connection context menu handler (连接右键菜单处理) */
onConnectionContextMenu?: (connectionId: string, e: React.MouseEvent) => void;
}
/**
* ConnectionLayer - SVG layer containing all connection lines
* ConnectionLayer - 包含所有连接线的 SVG 层
*/
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,
getPinPosition,
selectedConnectionIds,
animateExec = false,
preview,
onConnectionClick,
onConnectionContextMenu
}) => {
return (
<svg className="ne-connection-layer">
{/* Render all connections (渲染所有连接) */}
{connections.map(connection => {
const from = getPinPosition(connection.fromPinId);
const to = getPinPosition(connection.toPinId);
if (!from || !to) return null;
return (
<ConnectionLine
key={connection.id}
connection={connection}
from={from}
to={to}
isSelected={selectedConnectionIds?.has(connection.id)}
animated={animateExec}
onClick={onConnectionClick}
onContextMenu={onConnectionContextMenu}
/>
);
})}
{/* Render preview connection (渲染预览连接) */}
{preview && (
<ConnectionPreview
from={preview.from}
to={preview.to}
category={preview.category}
isValid={preview.isValid}
/>
)}
</svg>
);
};
export default ConnectionLine;

View File

@@ -0,0 +1,8 @@
export {
ConnectionLine,
ConnectionPreview,
ConnectionLayer,
type ConnectionLineProps,
type ConnectionPreviewProps,
type ConnectionLayerProps
} from './ConnectionLine';

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useCallback } from 'react';
export interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
type = 'danger',
onConfirm,
onCancel
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen && confirmBtnRef.current) {
confirmBtnRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
} else if (e.key === 'Enter') {
onConfirm();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onConfirm, onCancel]);
const handleConfirm = useCallback(() => {
onConfirm();
}, [onConfirm]);
const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);
if (!isOpen) return null;
return (
<div className="ne-dialog-overlay">
<div ref={dialogRef} className={`ne-dialog ne-dialog-${type}`}>
<div className="ne-dialog-header">
<span className="ne-dialog-title">{title}</span>
</div>
<div className="ne-dialog-body">
<p className="ne-dialog-message">{message}</p>
</div>
<div className="ne-dialog-footer">
<button
className="ne-dialog-btn ne-dialog-btn-cancel"
onClick={handleCancel}
>
{cancelText}
</button>
<button
ref={confirmBtnRef}
className={`ne-dialog-btn ne-dialog-btn-confirm ne-dialog-btn-${type}`}
onClick={handleConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -0,0 +1 @@
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';

View File

@@ -0,0 +1,539 @@
import React, { useRef, useCallback, useState, useMemo } from 'react';
import { Graph } from '../../domain/models/Graph';
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 { GraphCanvas } from '../canvas/GraphCanvas';
import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent';
import { ConnectionLayer } from '../connections/ConnectionLine';
/**
* Node execution states map
* 节点执行状态映射
*/
export type NodeExecutionStates = Map<string, NodeExecutionState>;
export interface NodeEditorProps {
/** Graph data (图数据) */
graph: Graph;
/** Available node templates (可用的节点模板) */
templates?: NodeTemplate[];
/** Currently selected node IDs (当前选中的节点ID) */
selectedNodeIds?: Set<string>;
/** Currently selected connection IDs (当前选中的连接ID) */
selectedConnectionIds?: Set<string>;
/** Node execution states for visual feedback (节点执行状态用于视觉反馈) */
executionStates?: NodeExecutionStates;
/** Whether to animate exec connections (是否动画化执行连接) */
animateExecConnections?: boolean;
/** Read-only mode (只读模式) */
readOnly?: boolean;
/** Icon renderer (图标渲染器) */
renderIcon?: (iconName: string) => React.ReactNode;
/** Graph change callback (图变化回调) */
onGraphChange?: (graph: Graph) => void;
/** Selection change callback (选择变化回调) */
onSelectionChange?: (nodeIds: Set<string>, connectionIds: Set<string>) => void;
/** Node double click callback (节点双击回调) */
onNodeDoubleClick?: (node: GraphNode) => void;
/** Canvas context menu callback (画布右键菜单回调) */
onCanvasContextMenu?: (position: Position, e: React.MouseEvent) => void;
/** Node context menu callback (节点右键菜单回调) */
onNodeContextMenu?: (node: GraphNode, e: React.MouseEvent) => void;
/** Connection context menu callback (连接右键菜单回调) */
onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void;
}
/**
* Dragging state for node movement
* 节点移动的拖拽状态
*/
interface DragState {
nodeIds: string[];
startPositions: Map<string, Position>;
startMouse: Position;
}
/**
* Connection dragging state
* 连接拖拽状态
*/
interface ConnectionDragState {
fromPin: Pin;
fromPosition: Position;
currentPosition: Position;
targetPin?: Pin;
isValid?: boolean;
}
/**
* NodeEditor - Complete node graph editor component
* NodeEditor - 完整的节点图编辑器组件
*/
export const NodeEditor: React.FC<NodeEditorProps> = ({
graph,
// templates is reserved for future node palette feature
// templates 保留用于未来的节点面板功能
templates: _templates = [],
selectedNodeIds = new Set(),
selectedConnectionIds = new Set(),
executionStates,
animateExecConnections = false,
readOnly = false,
renderIcon,
onGraphChange,
onSelectionChange,
// onNodeDoubleClick is reserved for future double-click handling
// onNodeDoubleClick 保留用于未来的双击处理
onNodeDoubleClick: _onNodeDoubleClick,
onCanvasContextMenu,
onNodeContextMenu,
onConnectionContextMenu
}) => {
// Silence unused variable warnings (消除未使用变量警告)
void _templates;
void _onNodeDoubleClick;
const containerRef = useRef<HTMLDivElement>(null);
// Canvas transform state - use ref to always have latest values
// 画布变换状态 - 使用 ref 保证总是能获取最新值
const transformRef = useRef({ pan: Position.ZERO, zoom: 1 });
// Callbacks for GraphCanvas to sync transform state
const handlePanChange = useCallback((newPan: Position) => {
transformRef.current.pan = newPan;
}, []);
const handleZoomChange = useCallback((newZoom: number) => {
transformRef.current.zoom = newZoom;
}, []);
// Local state for dragging (拖拽的本地状态)
const [dragState, setDragState] = useState<DragState | null>(null);
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
/**
* Converts screen coordinates to canvas coordinates
* 将屏幕坐标转换为画布坐标
* 使用 ref 中的最新值,避免闭包捕获旧状态
*/
const screenToCanvas = useCallback((screenX: number, screenY: number): Position => {
if (!containerRef.current) return new Position(screenX, screenY);
const rect = containerRef.current.getBoundingClientRect();
const { pan, zoom } = transformRef.current;
const x = (screenX - rect.left - pan.x) / zoom;
const y = (screenY - rect.top - pan.y) / zoom;
return new Position(x, y);
}, []);
/**
* Gets pin position in canvas coordinates
* 获取引脚在画布坐标系中的位置
*
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// Find the pin element and its parent node
const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement;
if (!pinElement) return undefined;
const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement;
if (!nodeElement) return undefined;
const nodeId = nodeElement.getAttribute('data-node-id');
if (!nodeId) return undefined;
const node = graph.getNode(nodeId);
if (!node) return undefined;
// Get pin position relative to node element (in unscaled pixels)
const nodeRect = nodeElement.getBoundingClientRect();
const pinRect = pinElement.getBoundingClientRect();
// Calculate relative position within the node (accounting for zoom)
const { zoom } = transformRef.current;
const relativeX = (pinRect.left + pinRect.width / 2 - nodeRect.left) / zoom;
const relativeY = (pinRect.top + pinRect.height / 2 - nodeRect.top) / zoom;
// Final position = node position + relative position
return new Position(
node.position.x + relativeX,
node.position.y + relativeY
);
}, [graph]);
/**
* Handles node selection
* 处理节点选择
*/
const handleNodeSelect = useCallback((nodeId: string, additive: boolean) => {
if (readOnly) return;
const newSelection = new Set(selectedNodeIds);
if (additive) {
if (newSelection.has(nodeId)) {
newSelection.delete(nodeId);
} else {
newSelection.add(nodeId);
}
} else {
newSelection.clear();
newSelection.add(nodeId);
}
onSelectionChange?.(newSelection, new Set());
}, [selectedNodeIds, readOnly, onSelectionChange]);
/**
* Handles node drag start
* 处理节点拖拽开始
*/
const handleNodeDragStart = useCallback((nodeId: string, e: React.MouseEvent) => {
if (readOnly) return;
// Get all nodes to drag (selected or just this one)
// 获取要拖拽的所有节点(选中的或仅此节点)
const nodesToDrag = selectedNodeIds.has(nodeId)
? Array.from(selectedNodeIds)
: [nodeId];
// Store starting positions (存储起始位置)
const startPositions = new Map<string, Position>();
nodesToDrag.forEach(id => {
const node = graph.getNode(id);
if (node) {
startPositions.set(id, node.position);
}
});
const mousePos = screenToCanvas(e.clientX, e.clientY);
setDragState({
nodeIds: nodesToDrag,
startPositions,
startMouse: mousePos
});
}, [graph, selectedNodeIds, readOnly, screenToCanvas]);
/**
* Handles mouse move for dragging
* 处理拖拽的鼠标移动
*/
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const mousePos = screenToCanvas(e.clientX, e.clientY);
// Node dragging (节点拖拽)
if (dragState) {
const dx = mousePos.x - dragState.startMouse.x;
const dy = mousePos.y - dragState.startMouse.y;
let newGraph = graph;
dragState.nodeIds.forEach(nodeId => {
const startPos = dragState.startPositions.get(nodeId);
if (startPos) {
const newPos = new Position(startPos.x + dx, startPos.y + dy);
newGraph = newGraph.moveNode(nodeId, newPos);
}
});
onGraphChange?.(newGraph);
}
// Connection dragging (连接拖拽)
if (connectionDrag) {
const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined;
setConnectionDrag(prev => prev ? {
...prev,
currentPosition: mousePos,
targetPin: hoveredPin ?? undefined,
isValid
} : null);
}
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
/**
* Handles mouse up to end dragging
* 处理鼠标释放结束拖拽
*/
const handleMouseUp = useCallback(() => {
// End node dragging (结束节点拖拽)
if (dragState) {
setDragState(null);
}
// End connection dragging (结束连接拖拽)
if (connectionDrag) {
// Use hoveredPin directly instead of relying on async state update
const targetPin = hoveredPin;
if (targetPin && connectionDrag.fromPin.canConnectTo(targetPin)) {
// Create connection (创建连接)
const fromPin = connectionDrag.fromPin;
const toPin = targetPin;
// Determine direction (确定方向)
const [outputPin, inputPin] = fromPin.isOutput
? [fromPin, toPin]
: [toPin, fromPin];
const connection = new Connection(
Connection.createId(outputPin.id, inputPin.id),
outputPin.nodeId,
outputPin.id,
inputPin.nodeId,
inputPin.id,
outputPin.category
);
try {
const newGraph = graph.addConnection(connection);
onGraphChange?.(newGraph);
} catch (error) {
console.error('Failed to create connection:', error);
}
}
setConnectionDrag(null);
}
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
/**
* Handles pin mouse down
* 处理引脚鼠标按下
*/
const handlePinMouseDown = useCallback((e: React.MouseEvent, pin: Pin) => {
if (readOnly) return;
e.stopPropagation();
const position = getPinPosition(pin.id);
if (position) {
setConnectionDrag({
fromPin: pin,
fromPosition: position,
currentPosition: position
});
}
}, [readOnly, getPinPosition]);
/**
* Handles pin mouse up
* 处理引脚鼠标释放
*/
const handlePinMouseUp = useCallback((_e: React.MouseEvent, pin: Pin) => {
if (connectionDrag && connectionDrag.fromPin.canConnectTo(pin)) {
const fromPin = connectionDrag.fromPin;
const toPin = pin;
const [outputPin, inputPin] = fromPin.isOutput
? [fromPin, toPin]
: [toPin, fromPin];
const connection = new Connection(
Connection.createId(outputPin.id, inputPin.id),
outputPin.nodeId,
outputPin.id,
inputPin.nodeId,
inputPin.id,
outputPin.category
);
try {
const newGraph = graph.addConnection(connection);
onGraphChange?.(newGraph);
} catch (error) {
console.error('Failed to create connection:', error);
}
setConnectionDrag(null);
}
}, [connectionDrag, graph, onGraphChange]);
/**
* Handles pin hover
* 处理引脚悬停
*/
const handlePinMouseEnter = useCallback((pin: Pin) => {
setHoveredPin(pin);
}, []);
const handlePinMouseLeave = useCallback(() => {
setHoveredPin(null);
}, []);
/**
* Handles node context menu
* 处理节点右键菜单
*/
const handleNodeContextMenu = useCallback((nodeId: string, e: React.MouseEvent) => {
const node = graph.getNode(nodeId);
if (node) {
onNodeContextMenu?.(node, e);
}
}, [graph, onNodeContextMenu]);
/**
* Handles connection click
* 处理连接点击
*/
const handleConnectionClick = useCallback((connectionId: string, e: React.MouseEvent) => {
if (readOnly) return;
const newSelection = new Set<string>();
if (e.ctrlKey || e.metaKey) {
if (selectedConnectionIds.has(connectionId)) {
selectedConnectionIds.forEach(id => {
if (id !== connectionId) newSelection.add(id);
});
} else {
selectedConnectionIds.forEach(id => newSelection.add(id));
newSelection.add(connectionId);
}
} else {
newSelection.add(connectionId);
}
onSelectionChange?.(new Set(), newSelection);
}, [selectedConnectionIds, readOnly, onSelectionChange]);
/**
* Handles connection context menu
* 处理连接右键菜单
*/
const handleConnectionContextMenu = useCallback((connectionId: string, e: React.MouseEvent) => {
const connection = graph.connections.find(c => c.id === connectionId);
if (connection) {
onConnectionContextMenu?.(connection, e);
}
}, [graph, onConnectionContextMenu]);
/**
* Handles canvas click to deselect
* 处理画布点击取消选择
*/
const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => {
if (!readOnly) {
onSelectionChange?.(new Set(), new Set());
}
}, [readOnly, onSelectionChange]);
/**
* Handles canvas context menu
* 处理画布右键菜单
*/
const handleCanvasContextMenu = useCallback((position: Position, e: React.MouseEvent) => {
onCanvasContextMenu?.(position, e);
}, [onCanvasContextMenu]);
/**
* Handles pin value change
* 处理引脚值变化
*/
const handlePinValueChange = useCallback((nodeId: string, pinId: string, value: unknown) => {
if (readOnly) return;
const node = graph.getNode(nodeId);
if (node) {
// Find pin name from pin id
// 从引脚ID查找引脚名称
const pin = node.getPin(pinId);
if (pin) {
const newData = { ...node.data, [pin.name]: value };
const newGraph = graph.updateNode(nodeId, n => n.updateData(newData));
onGraphChange?.(newGraph);
}
}
}, [graph, readOnly, onGraphChange]);
/**
* Handles node collapse toggle
* 处理节点折叠切换
*/
const handleToggleCollapse = useCallback((nodeId: string) => {
const newGraph = graph.updateNode(nodeId, n => n.toggleCollapse());
onGraphChange?.(newGraph);
}, [graph, onGraphChange]);
// Build connection preview for drag state
// 为拖拽状态构建连接预览
const connectionPreview = useMemo(() => {
if (!connectionDrag) return undefined;
return {
from: connectionDrag.fromPosition,
to: connectionDrag.currentPosition,
category: connectionDrag.fromPin.category,
isValid: connectionDrag.isValid
};
}, [connectionDrag]);
return (
<div
ref={containerRef}
className="ne-editor"
style={{ width: '100%', height: '100%' }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<GraphCanvas
onClick={handleCanvasClick}
onContextMenu={handleCanvasContextMenu}
onPanChange={handlePanChange}
onZoomChange={handleZoomChange}
>
{/* Connection layer (连接层) */}
<ConnectionLayer
connections={graph.connections}
getPinPosition={getPinPosition}
selectedConnectionIds={selectedConnectionIds}
animateExec={animateExecConnections}
preview={connectionPreview}
onConnectionClick={handleConnectionClick}
onConnectionContextMenu={handleConnectionContextMenu}
/>
{/* Nodes (节点) */}
{graph.nodes.map(node => (
<MemoizedGraphNodeComponent
key={node.id}
node={node}
isSelected={selectedNodeIds.has(node.id)}
isDragging={dragState?.nodeIds.includes(node.id) ?? false}
executionState={executionStates?.get(node.id)}
connections={graph.connections}
draggingFromPin={connectionDrag?.fromPin}
renderIcon={renderIcon}
onSelect={handleNodeSelect}
onDragStart={handleNodeDragStart}
onContextMenu={handleNodeContextMenu}
onPinMouseDown={handlePinMouseDown}
onPinMouseUp={handlePinMouseUp}
onPinMouseEnter={handlePinMouseEnter}
onPinMouseLeave={handlePinMouseLeave}
onPinValueChange={handlePinValueChange}
onToggleCollapse={handleToggleCollapse}
/>
))}
</GraphCanvas>
</div>
);
};
export default NodeEditor;

View File

@@ -0,0 +1,5 @@
export {
NodeEditor,
type NodeEditorProps,
type NodeExecutionStates
} from './NodeEditor';

View File

@@ -0,0 +1,7 @@
export * from './canvas';
export * from './editor';
export * from './nodes';
export * from './pins';
export * from './connections';
export * from './menu';
export * from './dialog';

View File

@@ -0,0 +1,347 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { NodeTemplate, NodeCategory } from '../../domain/models/GraphNode';
import { Position } from '../../domain/value-objects/Position';
export interface NodeContextMenuProps {
position: { x: number; y: number };
canvasPosition: Position;
templates: NodeTemplate[];
isOpen: boolean;
onSelectTemplate: (template: NodeTemplate, position: Position) => void;
onClose: () => void;
}
interface CategoryGroup {
category: NodeCategory;
label: string;
templates: NodeTemplate[];
}
const CATEGORY_LABELS: Record<NodeCategory, string> = {
event: 'Event',
function: 'Function',
pure: 'Pure',
flow: 'Flow Control',
variable: 'Variable',
literal: 'Literal',
comment: 'Comment',
custom: 'Custom'
};
const CATEGORY_ORDER: NodeCategory[] = [
'event',
'function',
'pure',
'flow',
'variable',
'literal',
'comment',
'custom'
];
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return text;
return (
<>
{text.slice(0, index)}
<span className="highlight">{text.slice(index, index + query.length)}</span>
{text.slice(index + query.length)}
</>
);
}
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
position,
canvasPosition,
templates,
isOpen,
onSelectTemplate,
onClose
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<NodeCategory>>(
new Set(['event', 'function', 'pure', 'flow'])
);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [contextSensitive, setContextSensitive] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
setSearchQuery('');
setHighlightedIndex(0);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
const filteredTemplates = useMemo(() => {
if (!searchQuery.trim()) return templates;
const query = searchQuery.toLowerCase();
return templates.filter(t =>
t.title.toLowerCase().includes(query) ||
t.id.toLowerCase().includes(query)
);
}, [templates, searchQuery]);
const categoryGroups = useMemo((): CategoryGroup[] => {
const groups = new Map<NodeCategory, NodeTemplate[]>();
filteredTemplates.forEach(template => {
const category = template.category || 'custom';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(template);
});
return CATEGORY_ORDER
.filter(cat => groups.has(cat))
.map(cat => ({
category: cat,
label: CATEGORY_LABELS[cat],
templates: groups.get(cat)!
}));
}, [filteredTemplates]);
const flatItems = useMemo(() => {
if (searchQuery.trim()) {
return filteredTemplates;
}
return categoryGroups.flatMap(g =>
expandedCategories.has(g.category) ? g.templates : []
);
}, [searchQuery, categoryGroups, expandedCategories, filteredTemplates]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < flatItems.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : flatItems.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (flatItems[highlightedIndex]) {
onSelectTemplate(flatItems[highlightedIndex], canvasPosition);
onClose();
}
break;
}
}, [flatItems, highlightedIndex, canvasPosition, onSelectTemplate, onClose]);
const toggleCategory = useCallback((category: NodeCategory) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
}, []);
const handleItemClick = useCallback((template: NodeTemplate) => {
onSelectTemplate(template, canvasPosition);
onClose();
}, [canvasPosition, onSelectTemplate, onClose]);
const adjustedPosition = useMemo(() => {
const menuWidth = 320;
const menuHeight = 450;
const padding = 10;
let x = position.x;
let y = position.y;
if (typeof window !== 'undefined') {
if (x + menuWidth > window.innerWidth - padding) {
x = window.innerWidth - menuWidth - padding;
}
if (y + menuHeight > window.innerHeight - padding) {
y = window.innerHeight - menuHeight - padding;
}
}
return { x: Math.max(padding, x), y: Math.max(padding, y) };
}, [position]);
if (!isOpen) return null;
const showCategorized = !searchQuery.trim();
return (
<div
ref={menuRef}
className="ne-context-menu"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y
}}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="ne-context-menu-header">
<span className="ne-context-menu-title">All Possible Actions</span>
<div className="ne-context-menu-options">
<label className="ne-context-menu-checkbox">
<input
type="checkbox"
checked={contextSensitive}
onChange={e => setContextSensitive(e.target.checked)}
/>
Context Sensitive
</label>
</div>
</div>
{/* Search Box */}
<div className="ne-context-menu-search">
<div className="ne-context-menu-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="ne-context-menu-search-input"
placeholder=""
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setHighlightedIndex(0);
}}
/>
{searchQuery && (
<button
className="ne-context-menu-search-clear"
onClick={() => setSearchQuery('')}
>
×
</button>
)}
</div>
</div>
{/* Content */}
<div className="ne-context-menu-content">
{filteredTemplates.length === 0 ? (
<div className="ne-context-menu-empty">
No matching nodes
</div>
) : showCategorized ? (
categoryGroups.map(group => (
<div
key={group.category}
className={`ne-context-menu-category ${expandedCategories.has(group.category) ? 'expanded' : ''}`}
>
<div
className="ne-context-menu-category-header"
onClick={() => toggleCategory(group.category)}
>
<span className="ne-context-menu-category-chevron"></span>
<span className="ne-context-menu-category-title">
{group.label}
</span>
</div>
<div className="ne-context-menu-category-items">
{group.templates.map((template) => {
const flatIndex = flatItems.indexOf(template);
return (
<div
key={template.id}
className={`ne-context-menu-item ${flatIndex === highlightedIndex ? 'highlighted' : ''}`}
onClick={() => handleItemClick(template)}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<div className="ne-context-menu-item-icon">
{template.category === 'function' || template.category === 'pure' ? (
<span className="ne-context-menu-item-icon-func">f</span>
) : (
<div className={`ne-context-menu-item-icon-dot ${template.category}`} />
)}
</div>
<div className="ne-context-menu-item-info">
<div className="ne-context-menu-item-title">
{template.title}
</div>
</div>
</div>
);
})}
</div>
</div>
))
) : (
filteredTemplates.map((template, idx) => (
<div
key={template.id}
className={`ne-context-menu-item ${idx === highlightedIndex ? 'highlighted' : ''}`}
onClick={() => handleItemClick(template)}
onMouseEnter={() => setHighlightedIndex(idx)}
>
<div className="ne-context-menu-item-icon">
{template.category === 'function' || template.category === 'pure' ? (
<span className="ne-context-menu-item-icon-func">f</span>
) : (
<div className={`ne-context-menu-item-icon-dot ${template.category}`} />
)}
</div>
<div className="ne-context-menu-item-info">
<div className="ne-context-menu-item-title">
{highlightMatch(template.title, searchQuery)}
</div>
</div>
</div>
))
)}
</div>
{/* Footer */}
<div className="ne-context-menu-footer">
<span><kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
);
};
export default NodeContextMenu;

View File

@@ -0,0 +1 @@
export * from './NodeContextMenu';

View File

@@ -0,0 +1,275 @@
import React, { useCallback, useMemo } from 'react';
import { GraphNode } from '../../domain/models/GraphNode';
import { Pin } from '../../domain/models/Pin';
import { Connection } from '../../domain/models/Connection';
import { PinRow } from '../pins/NodePin';
/**
* Node execution state for visual feedback
* 节点执行状态用于视觉反馈
*/
export type NodeExecutionState = 'idle' | 'running' | 'success' | 'error';
export interface GraphNodeComponentProps {
node: GraphNode;
isSelected: boolean;
isDragging: boolean;
dragOffset?: { x: number; y: number };
executionState?: NodeExecutionState;
connections: Connection[];
draggingFromPin?: Pin;
renderIcon?: (iconName: string) => React.ReactNode;
onSelect?: (nodeId: string, additive: boolean) => void;
onDragStart?: (nodeId: string, e: React.MouseEvent) => void;
onContextMenu?: (nodeId: string, e: React.MouseEvent) => void;
onPinMouseDown?: (e: React.MouseEvent, pin: Pin) => void;
onPinMouseUp?: (e: React.MouseEvent, pin: Pin) => void;
onPinMouseEnter?: (pin: Pin) => void;
onPinMouseLeave?: (pin: Pin) => void;
onPinValueChange?: (nodeId: string, pinId: string, value: unknown) => void;
onToggleCollapse?: (nodeId: string) => void;
}
/**
* GraphNodeComponent - Visual representation of a graph node
*/
export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
node,
isSelected,
isDragging,
dragOffset,
executionState = 'idle',
connections,
draggingFromPin,
renderIcon,
onSelect,
onDragStart,
onContextMenu,
onPinMouseDown,
onPinMouseUp,
onPinMouseEnter,
onPinMouseLeave,
onPinValueChange,
onToggleCollapse
}) => {
const posX = node.position.x + (isDragging && dragOffset ? dragOffset.x : 0);
const posY = node.position.y + (isDragging && dragOffset ? dragOffset.y : 0);
const isPinConnected = useCallback((pinId: string): boolean => {
return connections.some(c => c.fromPinId === pinId || c.toPinId === pinId);
}, [connections]);
const isPinCompatible = useCallback((pin: Pin): boolean => {
if (!draggingFromPin) return false;
return draggingFromPin.canConnectTo(pin);
}, [draggingFromPin]);
const classNames = useMemo(() => {
const classes = ['ne-node'];
if (isSelected) classes.push('selected');
if (isDragging) classes.push('dragging');
if (node.isCollapsed) classes.push('collapsed');
if (node.category === 'comment') classes.push('comment');
if (executionState !== 'idle') classes.push(executionState);
return classes.join(' ');
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey;
onSelect?.(node.id, additive);
onDragStart?.(node.id, e);
}, [node.id, onSelect, onDragStart]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(node.id, e);
}, [node.id, onContextMenu]);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onToggleCollapse?.(node.id);
}, [node.id, onToggleCollapse]);
const handlePinValueChange = useCallback((pinId: string, value: unknown) => {
onPinValueChange?.(node.id, pinId, value);
}, [node.id, onPinValueChange]);
const headerStyle = node.headerColor
? { background: `linear-gradient(180deg, ${node.headerColor} 0%, ${adjustColor(node.headerColor, -30)} 100%)` }
: undefined;
// Separate exec pins from data pins
const inputExecPins = node.inputPins.filter(p => p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden);
const outputExecPins = node.outputPins.filter(p => p.isExec && !p.hidden);
const outputDataPins = node.outputPins.filter(p => !p.isExec && !p.hidden);
// For event nodes, show first exec output in header
const headerExecPin = node.category === 'event' && outputExecPins.length > 0 ? outputExecPins[0] : null;
const remainingOutputExecPins = headerExecPin ? outputExecPins.slice(1) : outputExecPins;
return (
<div
className={classNames}
data-node-id={node.id}
style={{
left: posX,
top: posY,
zIndex: isDragging ? 100 : isSelected ? 10 : 1
}}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
>
{/* Header */}
<div
className={`ne-node-header ${node.category}`}
style={headerStyle}
>
{/* Diamond icon for event nodes, or custom icon */}
<span className="ne-node-header-icon">
{node.icon && renderIcon ? renderIcon(node.icon) : null}
</span>
<span className="ne-node-header-title">
{node.title}
{node.subtitle && (
<span className="ne-node-header-subtitle">
{node.subtitle}
</span>
)}
</span>
{/* Exec output pin in header for event nodes */}
{headerExecPin && (
<div
className={`ne-node-header-exec ${isPinConnected(headerExecPin.id) ? 'connected' : ''}`}
data-pin-id={headerExecPin.id}
onMouseDown={(e) => {
e.stopPropagation();
onPinMouseDown?.(e, headerExecPin);
}}
onMouseUp={(e) => onPinMouseUp?.(e, headerExecPin)}
onMouseEnter={() => onPinMouseEnter?.(headerExecPin)}
onMouseLeave={() => onPinMouseLeave?.(headerExecPin)}
title="Execution Output"
/>
)}
</div>
{/* Body */}
{!node.isCollapsed && (
<div className="ne-node-body">
<div className="ne-node-content">
{/* Input exec pins */}
{inputExecPins.map(pin => (
<PinRow
key={pin.id}
pin={pin}
isConnected={isPinConnected(pin.id)}
isCompatible={isPinCompatible(pin)}
isDropTarget={draggingFromPin?.canConnectTo(pin)}
showLabel={true}
showValue={false}
onMouseDown={onPinMouseDown}
onMouseUp={onPinMouseUp}
onMouseEnter={onPinMouseEnter}
onMouseLeave={onPinMouseLeave}
/>
))}
{/* Input data pins */}
{inputDataPins.map(pin => (
<PinRow
key={pin.id}
pin={pin}
isConnected={isPinConnected(pin.id)}
isCompatible={isPinCompatible(pin)}
isDropTarget={draggingFromPin?.canConnectTo(pin)}
showLabel={true}
showValue={true}
value={node.data[pin.name]}
onMouseDown={onPinMouseDown}
onMouseUp={onPinMouseUp}
onMouseEnter={onPinMouseEnter}
onMouseLeave={onPinMouseLeave}
onValueChange={handlePinValueChange}
/>
))}
{/* Output data pins */}
{outputDataPins.map(pin => (
<PinRow
key={pin.id}
pin={pin}
isConnected={isPinConnected(pin.id)}
isCompatible={isPinCompatible(pin)}
isDraggingFrom={draggingFromPin?.id === pin.id}
showLabel={true}
showValue={false}
onMouseDown={onPinMouseDown}
onMouseUp={onPinMouseUp}
onMouseEnter={onPinMouseEnter}
onMouseLeave={onPinMouseLeave}
/>
))}
{/* Remaining exec output pins */}
{remainingOutputExecPins.map(pin => (
<PinRow
key={pin.id}
pin={pin}
isConnected={isPinConnected(pin.id)}
isCompatible={isPinCompatible(pin)}
isDraggingFrom={draggingFromPin?.id === pin.id}
showLabel={true}
showValue={false}
onMouseDown={onPinMouseDown}
onMouseUp={onPinMouseUp}
onMouseEnter={onPinMouseEnter}
onMouseLeave={onPinMouseLeave}
/>
))}
</div>
{node.comment && (
<div className="ne-node-comment">
{node.comment}
</div>
)}
</div>
)}
</div>
);
};
function adjustColor(hex: string, amount: number): string {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, (num >> 16) + amount));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount));
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
export const MemoizedGraphNodeComponent = React.memo(GraphNodeComponent, (prev, next) => {
if (prev.node.id !== next.node.id) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isDragging !== next.isDragging) return false;
if (prev.executionState !== next.executionState) return false;
if (prev.node.isCollapsed !== next.node.isCollapsed) return false;
if (!prev.node.position.equals(next.node.position)) return false;
if (next.isDragging) {
if (prev.dragOffset?.x !== next.dragOffset?.x ||
prev.dragOffset?.y !== next.dragOffset?.y) {
return false;
}
}
if (prev.draggingFromPin !== next.draggingFromPin) return false;
if (JSON.stringify(prev.node.data) !== JSON.stringify(next.node.data)) return false;
return true;
});
export default GraphNodeComponent;

View File

@@ -0,0 +1,6 @@
export {
GraphNodeComponent,
MemoizedGraphNodeComponent,
type GraphNodeComponentProps,
type NodeExecutionState
} from './GraphNodeComponent';

View File

@@ -0,0 +1,310 @@
import React, { useCallback, useMemo } from 'react';
import { Pin } from '../../domain/models/Pin';
import { PinCategory, PinShape } from '../../domain/value-objects/PinType';
/**
* Pin color mapping by category
* 引脚类型颜色映射
*/
const PIN_COLORS: Record<PinCategory, string> = {
exec: 'var(--ne-pin-exec)',
bool: 'var(--ne-pin-bool)',
int: 'var(--ne-pin-int)',
float: 'var(--ne-pin-float)',
string: 'var(--ne-pin-string)',
vector2: 'var(--ne-pin-vector2)',
vector3: 'var(--ne-pin-vector3)',
vector4: 'var(--ne-pin-vector4)',
color: 'var(--ne-pin-color)',
object: 'var(--ne-pin-object)',
array: 'var(--ne-pin-array)',
map: 'var(--ne-pin-map)',
struct: 'var(--ne-pin-struct)',
enum: 'var(--ne-pin-enum)',
delegate: 'var(--ne-pin-delegate)',
any: 'var(--ne-pin-any)'
};
export interface NodePinProps {
/** Pin data (引脚数据) */
pin: Pin;
/** Whether the pin is connected (引脚是否已连接) */
isConnected: boolean;
/** Whether this pin is a valid drop target during drag (拖拽时此引脚是否是有效目标) */
isCompatible?: boolean;
/** Whether currently dragging from this pin (是否正在从此引脚拖拽) */
isDraggingFrom?: boolean;
/** Whether this pin is highlighted as drop target (此引脚是否高亮为放置目标) */
isDropTarget?: boolean;
/** Mouse down handler for starting connection (开始连接的鼠标按下处理) */
onMouseDown?: (e: React.MouseEvent, pin: Pin) => void;
/** Mouse up handler for completing connection (完成连接的鼠标释放处理) */
onMouseUp?: (e: React.MouseEvent, pin: Pin) => void;
/** Mouse enter handler (鼠标进入处理) */
onMouseEnter?: (pin: Pin) => void;
/** Mouse leave handler (鼠标离开处理) */
onMouseLeave?: (pin: Pin) => void;
}
/**
* Pin shape SVG component
* 引脚形状 SVG 组件
*/
const PinShapeSVG: React.FC<{
shape: PinShape;
isConnected: boolean;
color: string;
isInput: boolean;
}> = ({ shape, isConnected, color, isInput }) => {
const size = 12;
const half = size / 2;
const svgStyle: React.CSSProperties = { pointerEvents: 'none', display: 'block' };
const fillColor = isConnected ? color : 'transparent';
const strokeWidth = 2;
switch (shape) {
case 'triangle':
// Execution pin - arrow shape
const triPoints = isInput
? `1,1 ${size - 1},${half} 1,${size - 1}`
: `1,1 ${size - 1},${half} 1,${size - 1}`;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={svgStyle}>
<polygon
points={triPoints}
fill={fillColor}
stroke={color}
strokeWidth={strokeWidth}
strokeLinejoin="round"
/>
</svg>
);
case 'diamond':
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={svgStyle}>
<rect
x={half - 3}
y={half - 3}
width={6}
height={6}
fill={fillColor}
stroke={color}
strokeWidth={strokeWidth}
transform={`rotate(45 ${half} ${half})`}
/>
</svg>
);
case 'square':
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={svgStyle}>
<rect
x={2}
y={2}
width={size - 4}
height={size - 4}
rx={1}
fill={fillColor}
stroke={color}
strokeWidth={strokeWidth}
/>
</svg>
);
case 'circle':
default:
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={svgStyle}>
<circle
cx={half}
cy={half}
r={half - 2}
fill={fillColor}
stroke={color}
strokeWidth={strokeWidth}
/>
</svg>
);
}
};
/**
* NodePin - Pin connection point component
* NodePin - 引脚连接点组件
*/
export const NodePin: React.FC<NodePinProps> = ({
pin,
isConnected,
isCompatible,
isDraggingFrom,
isDropTarget,
onMouseDown,
onMouseUp,
onMouseEnter,
onMouseLeave
}) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onMouseDown?.(e, pin);
}, [onMouseDown, pin]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onMouseUp?.(e, pin);
}, [onMouseUp, pin]);
const handleMouseEnter = useCallback(() => {
onMouseEnter?.(pin);
}, [onMouseEnter, pin]);
const handleMouseLeave = useCallback(() => {
onMouseLeave?.(pin);
}, [onMouseLeave, pin]);
const color = pin.color || PIN_COLORS[pin.category];
const shape = pin.type.shape;
const classNames = useMemo(() => {
const classes = ['ne-pin', pin.category];
if (isConnected) classes.push('connected');
if (isCompatible) classes.push('compatible');
if (isDraggingFrom) classes.push('dragging-from');
if (isDropTarget) classes.push('drop-target');
if (pin.isInput) classes.push('input');
if (pin.isOutput) classes.push('output');
return classes.join(' ');
}, [pin.category, pin.isInput, pin.isOutput, isConnected, isCompatible, isDraggingFrom, isDropTarget]);
return (
<div
className={classNames}
data-pin-id={pin.id}
data-pin-direction={pin.direction}
data-pin-category={pin.category}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ color }}
>
<PinShapeSVG
shape={shape}
isConnected={isConnected}
color={color}
isInput={pin.isInput}
/>
</div>
);
};
/**
* PinRow - A row containing a pin and its label
* PinRow - 包含引脚及其标签的行
*/
export interface PinRowProps extends NodePinProps {
/** Whether to show the label (是否显示标签) */
showLabel?: boolean;
/** Whether to show default value input (是否显示默认值输入) */
showValue?: boolean;
/** Current value for the pin (引脚当前值) */
value?: unknown;
/** Value change handler (值变更处理) */
onValueChange?: (pinId: string, value: unknown) => void;
}
export const PinRow: React.FC<PinRowProps> = ({
pin,
showLabel = true,
showValue = false,
value,
onValueChange,
...pinProps
}) => {
const isInput = pin.isInput;
const showValueInput = showValue && isInput && !pinProps.isConnected;
const handleValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = pin.category === 'bool'
? e.target.checked
: pin.category === 'int'
? parseInt(e.target.value, 10)
: pin.category === 'float'
? parseFloat(e.target.value)
: e.target.value;
onValueChange?.(pin.id, newValue);
}, [pin.id, pin.category, onValueChange]);
const renderValueInput = () => {
if (!showValueInput) return null;
const displayValue = value ?? pin.defaultValue;
switch (pin.category) {
case 'bool':
return (
<input
type="checkbox"
className="ne-pin-value-checkbox"
checked={Boolean(displayValue)}
onChange={handleValueChange}
/>
);
case 'color':
return (
<input
type="color"
className="ne-pin-value-color"
value={String(displayValue || '#ffffff')}
onChange={handleValueChange}
/>
);
case 'int':
case 'float':
return (
<input
type="number"
className="ne-pin-value-input"
value={displayValue as number ?? 0}
step={pin.category === 'float' ? 0.1 : 1}
onChange={handleValueChange}
/>
);
case 'string':
return (
<input
type="text"
className="ne-pin-value-input"
value={String(displayValue ?? '')}
onChange={handleValueChange}
placeholder="..."
/>
);
default:
return null;
}
};
return (
<div className={`ne-pin-row ${isInput ? 'input' : 'output'}`}>
<NodePin pin={pin} {...pinProps} />
{showLabel && pin.displayName && (
<span className="ne-pin-label">{pin.displayName}</span>
)}
{renderValueInput()}
</div>
);
};
export default NodePin;

View File

@@ -0,0 +1 @@
export { NodePin, PinRow, type NodePinProps, type PinRowProps } from './NodePin';

View File

@@ -0,0 +1,2 @@
export * from './models';
export * from './value-objects';

View File

@@ -0,0 +1,156 @@
import { PinCategory } from '../value-objects/PinType';
/**
* Connection - Represents a link between two pins
* 连接 - 表示两个引脚之间的链接
*/
export class Connection {
private readonly _id: string;
private readonly _fromNodeId: string;
private readonly _fromPinId: string;
private readonly _toNodeId: string;
private readonly _toPinId: string;
private readonly _category: PinCategory;
constructor(
id: string,
fromNodeId: string,
fromPinId: string,
toNodeId: string,
toPinId: string,
category: PinCategory
) {
this._id = id;
this._fromNodeId = fromNodeId;
this._fromPinId = fromPinId;
this._toNodeId = toNodeId;
this._toPinId = toPinId;
this._category = category;
}
get id(): string {
return this._id;
}
/**
* Source node ID (output side)
* 源节点ID输出端
*/
get fromNodeId(): string {
return this._fromNodeId;
}
/**
* Source pin ID (output side)
* 源引脚ID输出端
*/
get fromPinId(): string {
return this._fromPinId;
}
/**
* Target node ID (input side)
* 目标节点ID输入端
*/
get toNodeId(): string {
return this._toNodeId;
}
/**
* Target pin ID (input side)
* 目标引脚ID输入端
*/
get toPinId(): string {
return this._toPinId;
}
/**
* Connection category determines the wire color
* 连接类别决定连线颜色
*/
get category(): PinCategory {
return this._category;
}
/**
* Whether this is an execution flow connection
* 是否是执行流连接
*/
get isExec(): boolean {
return this._category === 'exec';
}
/**
* Checks if this connection involves a specific node
* 检查此连接是否涉及特定节点
*/
involvesNode(nodeId: string): boolean {
return this._fromNodeId === nodeId || this._toNodeId === nodeId;
}
/**
* Checks if this connection involves a specific pin
* 检查此连接是否涉及特定引脚
*/
involvesPin(pinId: string): boolean {
return this._fromPinId === pinId || this._toPinId === pinId;
}
/**
* Checks if this connection matches the given endpoints
* 检查此连接是否匹配给定的端点
*/
matches(fromPinId: string, toPinId: string): boolean {
return this._fromPinId === fromPinId && this._toPinId === toPinId;
}
/**
* Checks equality with another connection
* 检查与另一个连接是否相等
*/
equals(other: Connection): boolean {
return (
this._fromNodeId === other._fromNodeId &&
this._fromPinId === other._fromPinId &&
this._toNodeId === other._toNodeId &&
this._toPinId === other._toPinId
);
}
toJSON(): Record<string, unknown> {
return {
id: this._id,
fromNodeId: this._fromNodeId,
fromPinId: this._fromPinId,
toNodeId: this._toNodeId,
toPinId: this._toPinId,
category: this._category
};
}
static fromJSON(json: {
id: string;
fromNodeId: string;
fromPinId: string;
toNodeId: string;
toPinId: string;
category: PinCategory;
}): Connection {
return new Connection(
json.id,
json.fromNodeId,
json.fromPinId,
json.toNodeId,
json.toPinId,
json.category
);
}
/**
* Creates a connection ID from pin IDs
* 从引脚ID创建连接ID
*/
static createId(fromPinId: string, toPinId: string): string {
return `${fromPinId}->${toPinId}`;
}
}

View File

@@ -0,0 +1,271 @@
import { GraphNode } from './GraphNode';
import { Connection } from './Connection';
import { Pin } from './Pin';
import { Position } from '../value-objects/Position';
/**
* Graph - Aggregate root for the node graph
* 图 - 节点图的聚合根
*
* This class is immutable - all modification methods return new instances.
* 此类是不可变的 - 所有修改方法返回新实例
*/
export class Graph {
private readonly _id: string;
private readonly _name: string;
private readonly _nodes: Map<string, GraphNode>;
private readonly _connections: Connection[];
private readonly _metadata: Record<string, unknown>;
constructor(
id: string,
name: string,
nodes: GraphNode[] = [],
connections: Connection[] = [],
metadata: Record<string, unknown> = {}
) {
this._id = id;
this._name = name;
this._nodes = new Map(nodes.map(n => [n.id, n]));
this._connections = [...connections];
this._metadata = { ...metadata };
}
get id(): string {
return this._id;
}
get name(): string {
return this._name;
}
get nodes(): GraphNode[] {
return Array.from(this._nodes.values());
}
get connections(): Connection[] {
return [...this._connections];
}
get metadata(): Record<string, unknown> {
return { ...this._metadata };
}
get nodeCount(): number {
return this._nodes.size;
}
get connectionCount(): number {
return this._connections.length;
}
/**
* Gets a node by ID
* 通过ID获取节点
*/
getNode(nodeId: string): GraphNode | undefined {
return this._nodes.get(nodeId);
}
/**
* Gets a pin by its full ID
* 通过完整ID获取引脚
*/
getPin(pinId: string): Pin | undefined {
for (const node of this._nodes.values()) {
const pin = node.getPin(pinId);
if (pin) return pin;
}
return undefined;
}
/**
* Gets all connections involving a node
* 获取涉及某节点的所有连接
*/
getNodeConnections(nodeId: string): Connection[] {
return this._connections.filter(c => c.involvesNode(nodeId));
}
/**
* Gets all connections to/from a specific pin
* 获取特定引脚的所有连接
*/
getPinConnections(pinId: string): Connection[] {
return this._connections.filter(c => c.involvesPin(pinId));
}
/**
* Checks if a pin is connected
* 检查引脚是否已连接
*/
isPinConnected(pinId: string): boolean {
return this._connections.some(c => c.involvesPin(pinId));
}
/**
* Adds a new node to the graph (immutable)
* 向图中添加新节点(不可变)
*/
addNode(node: GraphNode): Graph {
if (this._nodes.has(node.id)) {
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);
}
/**
* Removes a node and its connections (immutable)
* 移除节点及其连接(不可变)
*/
removeNode(nodeId: string): Graph {
if (!this._nodes.has(nodeId)) {
return this;
}
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);
}
/**
* Updates a node (immutable)
* 更新节点(不可变)
*/
updateNode(nodeId: string, updater: (node: GraphNode) => GraphNode): Graph {
const node = this._nodes.get(nodeId);
if (!node) return this;
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);
}
/**
* Moves a node to a new position (immutable)
* 移动节点到新位置(不可变)
*/
moveNode(nodeId: string, newPosition: Position): Graph {
return this.updateNode(nodeId, node => node.moveTo(newPosition));
}
/**
* Adds a connection between two pins (immutable)
* 在两个引脚之间添加连接(不可变)
*/
addConnection(connection: Connection): Graph {
// Validate connection
// 验证连接
const fromPin = this.getPin(connection.fromPinId);
const toPin = this.getPin(connection.toPinId);
if (!fromPin || !toPin) {
throw new Error('Invalid connection: pin not found');
}
if (!fromPin.canConnectTo(toPin)) {
throw new Error('Invalid connection: incompatible pin types');
}
// Check for duplicate connections
// 检查重复连接
const exists = this._connections.some(c =>
c.matches(connection.fromPinId, connection.toPinId)
);
if (exists) {
return this;
}
// Remove existing connection to input pin if it doesn't allow multiple
// 如果输入引脚不允许多连接,移除现有连接
let newConnections = [...this._connections];
if (!toPin.allowMultiple) {
newConnections = newConnections.filter(c => c.toPinId !== connection.toPinId);
}
newConnections.push(connection);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
}
/**
* Removes a connection (immutable)
* 移除连接(不可变)
*/
removeConnection(connectionId: string): Graph {
const newConnections = this._connections.filter(c => c.id !== connectionId);
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
}
/**
* Removes all connections to/from a pin (immutable)
* 移除引脚的所有连接(不可变)
*/
disconnectPin(pinId: string): Graph {
const newConnections = this._connections.filter(c => !c.involvesPin(pinId));
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
}
/**
* Updates graph metadata (immutable)
* 更新图元数据(不可变)
*/
setMetadata(metadata: Record<string, unknown>): Graph {
return new Graph(this._id, this._name, this.nodes, this._connections, {
...this._metadata,
...metadata
});
}
/**
* Creates a new graph with updated name (immutable)
* 创建具有更新名称的新图(不可变)
*/
rename(newName: string): Graph {
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata);
}
/**
* Validates the graph structure
* 验证图结构
*/
validate(): string[] {
const errors: string[] = [];
// Check for orphan connections
// 检查孤立连接
for (const conn of this._connections) {
if (!this.getPin(conn.fromPinId)) {
errors.push(`Connection "${conn.id}" references non-existent source pin`);
}
if (!this.getPin(conn.toPinId)) {
errors.push(`Connection "${conn.id}" references non-existent target pin`);
}
}
return errors;
}
toJSON(): Record<string, unknown> {
return {
id: this._id,
name: this._name,
nodes: this.nodes.map(n => n.toJSON()),
connections: this._connections.map(c => c.toJSON()),
metadata: this._metadata
};
}
/**
* Creates an empty graph
* 创建空图
*/
static empty(id: string, name: string): Graph {
return new Graph(id, name, [], [], {});
}
}

View File

@@ -0,0 +1,267 @@
import { Position } from '../value-objects/Position';
import { Pin, PinDefinition } from './Pin';
/**
* Node category determines the visual style of the node header
* 节点类别决定节点头部的视觉样式
*/
export type NodeCategory =
| 'event' // Event node - triggers execution (事件节点 - 触发执行)
| 'function' // Function call node (函数调用节点)
| 'pure' // Pure function - no execution pins (纯函数 - 无执行引脚)
| 'flow' // Flow control - branch, loop, etc. (流程控制 - 分支、循环等)
| 'variable' // Variable get/set (变量读写)
| 'literal' // Literal value input (字面量输入)
| 'comment' // Comment node (注释节点)
| 'custom'; // Custom category with user-defined color (自定义类别)
/**
* Node template definition for creating nodes
* 用于创建节点的节点模板定义
*/
export interface NodeTemplate {
/** Unique template identifier (唯一模板标识符) */
id: string;
/** Display title (显示标题) */
title: string;
/** Optional subtitle (可选副标题) */
subtitle?: string;
/** Node category for styling (节点类别用于样式) */
category: NodeCategory;
/** Custom header color override (自定义头部颜色覆盖) */
headerColor?: string;
/** Icon name from icon library (图标库中的图标名称) */
icon?: string;
/** Input pin definitions (输入引脚定义) */
inputPins: Omit<PinDefinition, 'direction'>[];
/** Output pin definitions (输出引脚定义) */
outputPins: Omit<PinDefinition, 'direction'>[];
/** Whether the node can be collapsed (节点是否可折叠) */
collapsible?: boolean;
/** Whether to show the title bar (是否显示标题栏) */
showHeader?: boolean;
/** Minimum width in pixels (最小宽度,像素) */
minWidth?: number;
/** Category path for node palette (节点面板的分类路径) */
path?: string[];
/** Search keywords (搜索关键词) */
keywords?: string[];
/** Description for documentation (文档描述) */
description?: string;
}
/**
* GraphNode - Represents a node instance in the graph
* 图节点 - 表示图中的节点实例
*/
export class GraphNode {
private readonly _id: string;
private readonly _templateId: string;
private _position: Position;
private readonly _category: NodeCategory;
private readonly _title: string;
private readonly _subtitle?: string;
private readonly _icon?: string;
private readonly _headerColor?: string;
private readonly _inputPins: Pin[];
private readonly _outputPins: Pin[];
private _isCollapsed: boolean;
private _comment?: string;
private _data: Record<string, unknown>;
constructor(
id: string,
template: NodeTemplate,
position: Position,
data: Record<string, unknown> = {}
) {
this._id = id;
this._templateId = template.id;
this._position = position;
this._category = template.category;
this._title = template.title;
this._subtitle = template.subtitle;
this._icon = template.icon;
this._headerColor = template.headerColor;
this._isCollapsed = false;
this._data = { ...data };
// Create input pins (创建输入引脚)
this._inputPins = template.inputPins.map((def, index) =>
new Pin(
`${id}_in_${index}`,
id,
{ ...def, direction: 'input' }
)
);
// Create output pins (创建输出引脚)
this._outputPins = template.outputPins.map((def, index) =>
new Pin(
`${id}_out_${index}`,
id,
{ ...def, direction: 'output' }
)
);
}
get id(): string {
return this._id;
}
get templateId(): string {
return this._templateId;
}
get position(): Position {
return this._position;
}
get category(): NodeCategory {
return this._category;
}
get title(): string {
return this._title;
}
get subtitle(): string | undefined {
return this._subtitle;
}
get icon(): string | undefined {
return this._icon;
}
get headerColor(): string | undefined {
return this._headerColor;
}
get inputPins(): readonly Pin[] {
return this._inputPins;
}
get outputPins(): readonly Pin[] {
return this._outputPins;
}
get allPins(): readonly Pin[] {
return [...this._inputPins, ...this._outputPins];
}
get isCollapsed(): boolean {
return this._isCollapsed;
}
get comment(): string | undefined {
return this._comment;
}
get data(): Record<string, unknown> {
return { ...this._data };
}
/**
* Gets a pin by its ID
* 通过ID获取引脚
*/
getPin(pinId: string): Pin | undefined {
return this.allPins.find(p => p.id === pinId);
}
/**
* Gets a pin by its name
* 通过名称获取引脚
*/
getPinByName(name: string, direction: 'input' | 'output'): Pin | undefined {
const pins = direction === 'input' ? this._inputPins : this._outputPins;
return pins.find(p => p.name === name);
}
/**
* Gets the execution input pin if exists
* 获取执行输入引脚(如果存在)
*/
getExecInput(): Pin | undefined {
return this._inputPins.find(p => p.isExec);
}
/**
* Gets all execution output pins
* 获取所有执行输出引脚
*/
getExecOutputs(): Pin[] {
return this._outputPins.filter(p => p.isExec);
}
/**
* Creates a new node with updated position (immutable)
* 创建具有更新位置的新节点(不可变)
*/
moveTo(newPosition: Position): GraphNode {
const node = this.clone();
node._position = newPosition;
return node;
}
/**
* Creates a new node with collapse state toggled (immutable)
* 创建切换折叠状态的新节点(不可变)
*/
toggleCollapse(): GraphNode {
const node = this.clone();
node._isCollapsed = !node._isCollapsed;
return node;
}
/**
* Creates a new node with updated comment (immutable)
* 创建具有更新注释的新节点(不可变)
*/
setComment(comment: string | undefined): GraphNode {
const node = this.clone();
node._comment = comment;
return node;
}
/**
* Creates a new node with updated data (immutable)
* 创建具有更新数据的新节点(不可变)
*/
updateData(data: Record<string, unknown>): GraphNode {
const node = this.clone();
node._data = { ...node._data, ...data };
return node;
}
private clone(): GraphNode {
const cloned = Object.create(GraphNode.prototype) as GraphNode;
Object.assign(cloned, this);
cloned._data = { ...this._data };
return cloned;
}
toJSON(): Record<string, unknown> {
return {
id: this._id,
templateId: this._templateId,
position: this._position.toJSON(),
isCollapsed: this._isCollapsed,
comment: this._comment,
data: this._data
};
}
}

View File

@@ -0,0 +1,187 @@
import { PinType, PinDirection, PinCategory, PinShape } from '../value-objects/PinType';
/**
* Pin definition for node templates
* 节点模板的引脚定义
*/
export interface PinDefinition {
/** Unique identifier within the node (节点内的唯一标识符) */
name: string;
/** Display name shown in UI (UI中显示的名称) */
displayName: string;
/** Pin direction (引脚方向) */
direction: PinDirection;
/** Pin data type category (引脚数据类型分类) */
category: PinCategory;
/** Subtype for struct/enum (结构体/枚举的子类型) */
subType?: string;
/** Whether this pin accepts array type (是否接受数组类型) */
isArray?: boolean;
/** Default value when not connected (未连接时的默认值) */
defaultValue?: unknown;
/** Whether multiple connections are allowed (是否允许多个连接) */
allowMultiple?: boolean;
/** Whether this pin is hidden by default (是否默认隐藏) */
hidden?: boolean;
/** Custom color override (自定义颜色覆盖) */
color?: string;
}
/**
* Pin - Represents a connection point on a node
* 引脚 - 表示节点上的连接点
*/
export class Pin {
private readonly _id: string;
private readonly _nodeId: string;
private readonly _name: string;
private readonly _displayName: string;
private readonly _direction: PinDirection;
private readonly _type: PinType;
private readonly _defaultValue: unknown;
private readonly _allowMultiple: boolean;
private readonly _hidden: boolean;
private readonly _color?: string;
constructor(
id: string,
nodeId: string,
definition: PinDefinition
) {
this._id = id;
this._nodeId = nodeId;
this._name = definition.name;
this._displayName = definition.displayName;
this._direction = definition.direction;
this._type = new PinType(
definition.category,
definition.subType,
definition.isArray ?? false
);
this._defaultValue = definition.defaultValue;
this._allowMultiple = definition.allowMultiple ?? (definition.category === 'exec' && definition.direction === 'output');
this._hidden = definition.hidden ?? false;
this._color = definition.color;
}
get id(): string {
return this._id;
}
get nodeId(): string {
return this._nodeId;
}
get name(): string {
return this._name;
}
get displayName(): string {
return this._displayName;
}
get direction(): PinDirection {
return this._direction;
}
get type(): PinType {
return this._type;
}
get category(): PinCategory {
return this._type.category;
}
get shape(): PinShape {
return this._type.shape;
}
get defaultValue(): unknown {
return this._defaultValue;
}
/**
* Whether multiple connections are allowed
* 是否允许多个连接
*/
get allowMultiple(): boolean {
return this._allowMultiple;
}
get hidden(): boolean {
return this._hidden;
}
get color(): string | undefined {
return this._color;
}
/**
* Whether this is an execution flow pin
* 是否是执行流引脚
*/
get isExec(): boolean {
return this._type.category === 'exec';
}
/**
* Whether this is an input pin
* 是否是输入引脚
*/
get isInput(): boolean {
return this._direction === 'input';
}
/**
* Whether this is an output pin
* 是否是输出引脚
*/
get isOutput(): boolean {
return this._direction === 'output';
}
/**
* Checks if this pin can connect to another pin
* 检查此引脚是否可以连接到另一个引脚
*/
canConnectTo(other: Pin): boolean {
// Cannot connect to self (不能连接到自己)
if (this._nodeId === other._nodeId) {
return false;
}
// Must be opposite directions (必须是相反方向)
if (this._direction === other._direction) {
return false;
}
// Check type compatibility (检查类型兼容性)
return this._type.canConnectTo(other._type);
}
toJSON(): PinDefinition & { id: string; nodeId: string } {
return {
id: this._id,
nodeId: this._nodeId,
name: this._name,
displayName: this._displayName,
direction: this._direction,
category: this._type.category,
subType: this._type.subType,
isArray: this._type.isArray,
defaultValue: this._defaultValue,
allowMultiple: this._allowMultiple,
hidden: this._hidden,
color: this._color
};
}
}

View File

@@ -0,0 +1,4 @@
export { Pin, type PinDefinition } from './Pin';
export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode';
export { Connection } from './Connection';
export { Graph } from './Graph';

View File

@@ -0,0 +1,175 @@
/**
* Pin direction - input or output
* 引脚方向 - 输入或输出
*/
export type PinDirection = 'input' | 'output';
/**
* Pin data type categories for visual programming
* 可视化编程的引脚数据类型分类
*
* These types cover common use cases in:
* 这些类型涵盖以下常见用例:
* - Blueprint visual scripting (蓝图可视化脚本)
* - Shader graph editors (着色器图编辑器)
* - State machine editors (状态机编辑器)
* - Animation graph editors (动画图编辑器)
*/
export type PinCategory =
| 'exec' // Execution flow pin (执行流引脚)
| 'bool' // Boolean value (布尔值)
| 'int' // Integer number (整数)
| 'float' // Floating point number (浮点数)
| 'string' // Text string (字符串)
| 'vector2' // 2D vector (二维向量)
| 'vector3' // 3D vector (三维向量)
| 'vector4' // 4D vector / Color (四维向量/颜色)
| 'color' // RGBA color (RGBA颜色)
| 'object' // Object reference (对象引用)
| 'array' // Array of values (数组)
| 'map' // Key-value map (键值映射)
| 'struct' // Custom struct (自定义结构体)
| 'enum' // Enumeration (枚举)
| 'delegate' // Event delegate (事件委托)
| 'any'; // Wildcard type (通配符类型)
/**
* Pin shape for rendering
* 引脚渲染形状
*/
export type PinShape =
| 'circle' // Standard data pin (标准数据引脚)
| 'triangle' // Execution flow pin (执行流引脚)
| 'diamond' // Array/special pin (数组/特殊引脚)
| 'square'; // Struct pin (结构体引脚)
/**
* Gets the default shape for a pin category
* 获取引脚类型的默认形状
*/
export function getDefaultPinShape(category: PinCategory): PinShape {
switch (category) {
case 'exec':
return 'triangle';
case 'array':
case 'map':
return 'diamond';
case 'struct':
return 'square';
default:
return 'circle';
}
}
/**
* Pin type value object with validation
* 带验证的引脚类型值对象
*/
export class PinType {
private readonly _category: PinCategory;
private readonly _subType?: string;
private readonly _isArray: boolean;
constructor(category: PinCategory, subType?: string, isArray = false) {
this._category = category;
this._subType = subType;
this._isArray = isArray;
}
get category(): PinCategory {
return this._category;
}
/**
* Subtype for complex types like struct or enum
* 复杂类型(如结构体或枚举)的子类型
*/
get subType(): string | undefined {
return this._subType;
}
get isArray(): boolean {
return this._isArray;
}
get shape(): PinShape {
if (this._isArray) return 'diamond';
return getDefaultPinShape(this._category);
}
/**
* Checks if this type can connect to another type
* 检查此类型是否可以连接到另一个类型
*/
canConnectTo(other: PinType): boolean {
// Any type can connect to anything
// any 类型可以连接任何类型
if (this._category === 'any' || other._category === 'any') {
return true;
}
// Exec pins can only connect to exec pins
// exec 引脚只能连接 exec 引脚
if (this._category === 'exec' || other._category === 'exec') {
return this._category === other._category;
}
// Same category can connect
// 相同类型可以连接
if (this._category === other._category) {
// For struct/enum, subtype must match
// 对于结构体/枚举,子类型必须匹配
if (this._category === 'struct' || this._category === 'enum') {
return this._subType === other._subType;
}
return true;
}
// Numeric type coercion (数值类型转换)
const numericTypes: PinCategory[] = ['int', 'float'];
if (numericTypes.includes(this._category) && numericTypes.includes(other._category)) {
return true;
}
// Vector type coercion (向量类型转换)
const vectorTypes: PinCategory[] = ['vector2', 'vector3', 'vector4', 'color'];
if (vectorTypes.includes(this._category) && vectorTypes.includes(other._category)) {
return true;
}
return false;
}
equals(other: PinType): boolean {
return (
this._category === other._category &&
this._subType === other._subType &&
this._isArray === other._isArray
);
}
toJSON(): { category: PinCategory; subType?: string; isArray: boolean } {
return {
category: this._category,
subType: this._subType,
isArray: this._isArray
};
}
static fromJSON(json: { category: PinCategory; subType?: string; isArray?: boolean }): PinType {
return new PinType(json.category, json.subType, json.isArray ?? false);
}
// Common type constants (常用类型常量)
static readonly EXEC = new PinType('exec');
static readonly BOOL = new PinType('bool');
static readonly INT = new PinType('int');
static readonly FLOAT = new PinType('float');
static readonly STRING = new PinType('string');
static readonly VECTOR2 = new PinType('vector2');
static readonly VECTOR3 = new PinType('vector3');
static readonly VECTOR4 = new PinType('vector4');
static readonly COLOR = new PinType('color');
static readonly OBJECT = new PinType('object');
static readonly ANY = new PinType('any');
}

View File

@@ -0,0 +1,93 @@
/**
* Position - Immutable 2D position value object
* 位置 - 不可变的二维位置值对象
*/
export class Position {
private readonly _x: number;
private readonly _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
get x(): number {
return this._x;
}
get y(): number {
return this._y;
}
/**
* Creates a new Position by adding offset
* 通过添加偏移量创建新的位置
*/
add(offset: Position): Position {
return new Position(this._x + offset._x, this._y + offset._y);
}
/**
* Creates a new Position by subtracting another position
* 通过减去另一个位置创建新的位置
*/
subtract(other: Position): Position {
return new Position(this._x - other._x, this._y - other._y);
}
/**
* Creates a new Position by scaling
* 通过缩放创建新的位置
*/
scale(factor: number): Position {
return new Position(this._x * factor, this._y * factor);
}
/**
* Calculates distance to another position
* 计算到另一个位置的距离
*/
distanceTo(other: Position): number {
const dx = this._x - other._x;
const dy = this._y - other._y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Checks equality with another position
* 检查与另一个位置是否相等
*/
equals(other: Position): boolean {
return this._x === other._x && this._y === other._y;
}
/**
* Creates a copy of this position
* 创建此位置的副本
*/
clone(): Position {
return new Position(this._x, this._y);
}
/**
* Converts to plain object for serialization
* 转换为普通对象用于序列化
*/
toJSON(): { x: number; y: number } {
return { x: this._x, y: this._y };
}
/**
* Creates Position from plain object
* 从普通对象创建位置
*/
static fromJSON(json: { x: number; y: number }): Position {
return new Position(json.x, json.y);
}
/**
* Zero position constant
* 零位置常量
*/
static readonly ZERO = new Position(0, 0);
}

View File

@@ -0,0 +1,8 @@
export { Position } from './Position';
export {
PinType,
type PinDirection,
type PinCategory,
type PinShape,
getDefaultPinShape
} from './PinType';

View File

@@ -0,0 +1,70 @@
/**
* @esengine/node-editor
*
* Universal node-based visual editor for blueprint, shader graph, and state machine.
* 通用节点式可视化编辑器,用于蓝图、着色器图和状态机
*
* @packageDocumentation
*/
// Import styles (导入样式)
import './styles/index.css';
// Domain models (领域模型)
export {
// Models
Graph,
GraphNode,
Pin,
Connection,
// Types
type NodeTemplate,
type NodeCategory,
type PinDefinition
} from './domain/models';
// Value objects (值对象)
export {
Position,
PinType,
type PinDirection,
type PinCategory,
type PinShape,
getDefaultPinShape
} from './domain/value-objects';
// Components (组件)
export {
// Main editor component
NodeEditor,
type NodeEditorProps,
type NodeExecutionStates,
// Canvas components
GraphCanvas,
useCanvasTransform,
type GraphCanvasProps,
type CanvasTransform,
// Node components
GraphNodeComponent,
MemoizedGraphNodeComponent,
type GraphNodeComponentProps,
type NodeExecutionState,
// Pin components
NodePin,
PinRow,
type NodePinProps,
type PinRowProps,
// Connection components
ConnectionLine,
ConnectionPreview,
ConnectionLayer,
type ConnectionLineProps,
type ConnectionPreviewProps,
type ConnectionLayerProps,
// Menu components
NodeContextMenu,
type NodeContextMenuProps,
// Dialog components
ConfirmDialog,
type ConfirmDialogProps
} from './components';

View File

@@ -0,0 +1,262 @@
/**
* Canvas Styles
* 画布样式
*/
/* ==================== Canvas Container 画布容器 ==================== */
.ne-canvas {
background: var(--ne-canvas-bg);
touch-action: none;
}
.ne-canvas:focus {
outline: none;
}
/* ==================== Canvas Content 画布内容 ==================== */
.ne-canvas-content {
will-change: transform;
}
/* ==================== Canvas Grid 画布网格 ==================== */
.ne-canvas-grid {
z-index: var(--ne-z-grid);
}
/* ==================== Selection Box 选择框 ==================== */
.ne-selection-box {
position: absolute;
border: 1px dashed var(--ne-node-border-selected);
background: rgba(0, 120, 212, 0.1);
pointer-events: none;
z-index: var(--ne-z-dragging);
}
/* ==================== Minimap 小地图 ==================== */
.ne-minimap {
position: absolute;
right: 16px;
bottom: 16px;
width: 200px;
height: 150px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid var(--ne-node-border);
border-radius: 4px;
overflow: hidden;
z-index: var(--ne-z-menu);
}
.ne-minimap-viewport {
position: absolute;
border: 2px solid var(--ne-node-border-selected);
background: rgba(0, 120, 212, 0.2);
cursor: move;
}
.ne-minimap-node {
position: absolute;
background: var(--ne-node-bg);
border-radius: 2px;
}
/* ==================== Zoom Controls 缩放控制 ==================== */
.ne-zoom-controls {
position: absolute;
right: 16px;
top: 16px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: var(--ne-z-menu);
}
.ne-zoom-btn {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--ne-node-border);
background: var(--ne-node-bg);
color: var(--ne-text-primary);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: background var(--ne-transition-fast),
border-color var(--ne-transition-fast);
}
.ne-zoom-btn:hover {
background: var(--ne-node-bg-hover);
border-color: var(--ne-node-border-selected);
}
.ne-zoom-btn:active {
background: var(--ne-node-bg-selected);
}
.ne-zoom-level {
padding: 4px 8px;
font-size: var(--ne-font-size-small);
color: var(--ne-text-secondary);
text-align: center;
background: var(--ne-node-bg);
border: 1px solid var(--ne-node-border);
border-radius: 4px;
}
/* ==================== Context Menu 右键菜单 ==================== */
.ne-context-menu {
position: fixed;
min-width: 180px;
background: var(--ne-node-bg);
border: 1px solid var(--ne-node-border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 4px 0;
z-index: var(--ne-z-menu);
font-family: var(--ne-font-family);
font-size: var(--ne-font-size-body);
}
.ne-context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: var(--ne-text-primary);
cursor: pointer;
transition: background var(--ne-transition-fast);
}
.ne-context-menu-item:hover {
background: var(--ne-node-bg-hover);
}
.ne-context-menu-item.disabled {
color: var(--ne-text-muted);
cursor: not-allowed;
}
.ne-context-menu-item.disabled:hover {
background: transparent;
}
.ne-context-menu-item-icon {
width: 16px;
height: 16px;
opacity: 0.8;
}
.ne-context-menu-item-label {
flex: 1;
}
.ne-context-menu-item-shortcut {
color: var(--ne-text-muted);
font-size: var(--ne-font-size-small);
}
.ne-context-menu-separator {
height: 1px;
background: var(--ne-node-border);
margin: 4px 0;
}
.ne-context-menu-submenu {
position: relative;
}
.ne-context-menu-submenu::after {
content: '▶';
position: absolute;
right: 8px;
font-size: 10px;
color: var(--ne-text-muted);
}
/* ==================== Search Popup 搜索弹窗 ==================== */
.ne-search-popup {
position: fixed;
width: 300px;
max-height: 400px;
background: var(--ne-node-bg);
border: 1px solid var(--ne-node-border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: var(--ne-z-menu);
overflow: hidden;
}
.ne-search-input {
width: 100%;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--ne-node-border);
background: transparent;
color: var(--ne-text-primary);
font-size: var(--ne-font-size-title);
font-family: var(--ne-font-family);
outline: none;
}
.ne-search-input::placeholder {
color: var(--ne-text-muted);
}
.ne-search-results {
max-height: 320px;
overflow-y: auto;
}
.ne-search-category {
padding: 8px 16px 4px;
color: var(--ne-text-muted);
font-size: var(--ne-font-size-small);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ne-search-result {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
cursor: pointer;
transition: background var(--ne-transition-fast);
}
.ne-search-result:hover,
.ne-search-result.selected {
background: var(--ne-node-bg-hover);
}
.ne-search-result-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.ne-search-result-info {
flex: 1;
}
.ne-search-result-name {
color: var(--ne-text-primary);
font-weight: 500;
}
.ne-search-result-path {
color: var(--ne-text-muted);
font-size: var(--ne-font-size-small);
}
.ne-search-empty {
padding: 24px 16px;
text-align: center;
color: var(--ne-text-muted);
}

View File

@@ -0,0 +1,179 @@
/**
* Connection Line Styles
* 连接线样式
*/
/* ==================== Connection Layer 连接层 ==================== */
.ne-connection-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--ne-z-connections);
overflow: visible;
}
/* ==================== Connection Path 连接路径 ==================== */
.ne-connection {
fill: none;
stroke-width: var(--ne-connection-width);
stroke-linecap: round;
pointer-events: stroke;
cursor: pointer;
transition: stroke-width var(--ne-transition-fast);
}
.ne-connection:hover {
stroke-width: var(--ne-connection-width-hover);
}
.ne-connection.selected {
stroke: var(--ne-connection-selected);
stroke-width: var(--ne-connection-width-hover);
filter: drop-shadow(0 0 4px var(--ne-connection-selected));
}
/* ==================== Connection Type Colors 连接类型颜色 ==================== */
.ne-connection.exec {
stroke: var(--ne-pin-exec);
stroke-width: var(--ne-connection-width-exec);
}
.ne-connection.bool { stroke: var(--ne-pin-bool); }
.ne-connection.int { stroke: var(--ne-pin-int); }
.ne-connection.float { stroke: var(--ne-pin-float); }
.ne-connection.string { stroke: var(--ne-pin-string); }
.ne-connection.vector2 { stroke: var(--ne-pin-vector2); }
.ne-connection.vector3 { stroke: var(--ne-pin-vector3); }
.ne-connection.vector4 { stroke: var(--ne-pin-vector4); }
.ne-connection.color { stroke: var(--ne-pin-color); }
.ne-connection.object { stroke: var(--ne-pin-object); }
.ne-connection.array { stroke: var(--ne-pin-array); }
.ne-connection.map { stroke: var(--ne-pin-map); }
.ne-connection.struct { stroke: var(--ne-pin-struct); }
.ne-connection.enum { stroke: var(--ne-pin-enum); }
.ne-connection.delegate { stroke: var(--ne-pin-delegate); }
.ne-connection.any { stroke: var(--ne-pin-any); }
/* ==================== Connection Preview 连接预览 ==================== */
.ne-connection-preview {
fill: none;
stroke: var(--ne-connection-preview);
stroke-width: 3px;
stroke-dasharray: 8 4;
pointer-events: none;
animation: ne-connection-dash 0.3s linear infinite;
filter: drop-shadow(0 0 4px currentColor);
}
.ne-connection-preview.valid {
stroke: var(--ne-state-success);
stroke-dasharray: none;
stroke-width: 3px;
filter: drop-shadow(0 0 8px var(--ne-state-success));
}
.ne-connection-preview.invalid {
stroke: var(--ne-connection-invalid);
filter: drop-shadow(0 0 6px var(--ne-connection-invalid));
}
/* Preview type colors */
.ne-connection-preview.exec { stroke: var(--ne-pin-exec); }
.ne-connection-preview.bool { stroke: var(--ne-pin-bool); }
.ne-connection-preview.int { stroke: var(--ne-pin-int); }
.ne-connection-preview.float { stroke: var(--ne-pin-float); }
.ne-connection-preview.string { stroke: var(--ne-pin-string); }
.ne-connection-preview.vector2 { stroke: var(--ne-pin-vector2); }
.ne-connection-preview.vector3 { stroke: var(--ne-pin-vector3); }
.ne-connection-preview.vector4 { stroke: var(--ne-pin-vector4); }
.ne-connection-preview.color { stroke: var(--ne-pin-color); }
.ne-connection-preview.object { stroke: var(--ne-pin-object); }
.ne-connection-preview.array { stroke: var(--ne-pin-array); }
.ne-connection-preview.map { stroke: var(--ne-pin-map); }
.ne-connection-preview.struct { stroke: var(--ne-pin-struct); }
.ne-connection-preview.enum { stroke: var(--ne-pin-enum); }
.ne-connection-preview.delegate { stroke: var(--ne-pin-delegate); }
.ne-connection-preview.any { stroke: var(--ne-pin-any); }
@keyframes ne-connection-dash {
to {
stroke-dashoffset: -12;
}
}
/* ==================== Connection Hit Area 连接点击区域 ==================== */
.ne-connection-hit {
fill: none;
stroke: transparent;
stroke-width: 20px;
pointer-events: stroke;
cursor: pointer;
}
.ne-connection-hit:hover + .ne-connection-glow + .ne-connection {
stroke-width: var(--ne-connection-width-hover);
}
/* ==================== Connection Glow 连接发光 ==================== */
.ne-connection-glow {
fill: none;
stroke-width: 8px;
opacity: 0;
filter: blur(4px);
pointer-events: none;
transition: opacity var(--ne-transition-fast);
}
.ne-connection-glow.exec { stroke: var(--ne-pin-exec); }
.ne-connection-glow.bool { stroke: var(--ne-pin-bool); }
.ne-connection-glow.int { stroke: var(--ne-pin-int); }
.ne-connection-glow.float { stroke: var(--ne-pin-float); }
.ne-connection-glow.string { stroke: var(--ne-pin-string); }
.ne-connection-glow.vector2 { stroke: var(--ne-pin-vector2); }
.ne-connection-glow.vector3 { stroke: var(--ne-pin-vector3); }
.ne-connection-glow.vector4 { stroke: var(--ne-pin-vector4); }
.ne-connection-glow.color { stroke: var(--ne-pin-color); }
.ne-connection-glow.object { stroke: var(--ne-pin-object); }
.ne-connection-glow.array { stroke: var(--ne-pin-array); }
.ne-connection-glow.map { stroke: var(--ne-pin-map); }
.ne-connection-glow.struct { stroke: var(--ne-pin-struct); }
.ne-connection-glow.enum { stroke: var(--ne-pin-enum); }
.ne-connection-glow.delegate { stroke: var(--ne-pin-delegate); }
.ne-connection-glow.any { stroke: var(--ne-pin-any); }
.ne-connection-hit:hover + .ne-connection-glow {
opacity: 0.5;
}
/* ==================== Connection Flow Animation 连接流动动画 ==================== */
.ne-connection.exec.animated {
stroke-dasharray: 4 8;
animation: ne-exec-flow 0.4s linear infinite;
}
@keyframes ne-exec-flow {
to {
stroke-dashoffset: -12;
}
}
/* ==================== Reroute Node 重路由节点 ==================== */
.ne-reroute {
fill: var(--ne-node-bg);
stroke: currentColor;
stroke-width: 2px;
cursor: move;
transition: transform var(--ne-transition-fast);
}
.ne-reroute:hover {
transform: scale(1.2);
}
.ne-reroute.selected {
stroke: var(--ne-connection-selected);
filter: drop-shadow(0 0 4px var(--ne-connection-selected));
}

View File

@@ -0,0 +1,290 @@
/**
* Context Menu Styles
* 上下文菜单样式
*/
/* ==================== Menu Container 菜单容器 ==================== */
.ne-context-menu {
position: fixed;
z-index: var(--ne-z-menu);
min-width: 300px;
max-width: 400px;
max-height: 500px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
font-family: var(--ne-font-family);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ==================== Menu Header 菜单头部 ==================== */
.ne-context-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #252525;
border-bottom: 1px solid #3a3a3a;
}
.ne-context-menu-title {
font-size: 12px;
font-weight: 500;
color: #c0c0c0;
}
.ne-context-menu-options {
display: flex;
align-items: center;
gap: 4px;
}
.ne-context-menu-checkbox {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #909090;
cursor: pointer;
}
.ne-context-menu-checkbox input {
width: 12px;
height: 12px;
margin: 0;
}
/* ==================== Search Box 搜索框 ==================== */
.ne-context-menu-search {
padding: 6px 8px;
border-bottom: 1px solid #303030;
background: #1e1e1e;
}
.ne-context-menu-search-wrapper {
position: relative;
display: flex;
align-items: center;
}
.ne-context-menu-search-input {
width: 100%;
padding: 6px 28px 6px 8px;
font-size: 12px;
font-family: var(--ne-font-family);
background: #0a0a0a;
border: 1px solid #404040;
border-radius: 2px;
color: #e0e0e0;
outline: none;
box-sizing: border-box;
}
.ne-context-menu-search-input::placeholder {
color: #606060;
}
.ne-context-menu-search-input:focus {
border-color: #5080c0;
}
.ne-context-menu-search-clear {
position: absolute;
right: 6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #606060;
cursor: pointer;
font-size: 14px;
padding: 0;
}
.ne-context-menu-search-clear:hover {
color: #a0a0a0;
}
/* ==================== Content Area 内容区域 ==================== */
.ne-context-menu-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.ne-context-menu-content::-webkit-scrollbar {
width: 8px;
}
.ne-context-menu-content::-webkit-scrollbar-track {
background: #1a1a1a;
}
.ne-context-menu-content::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.ne-context-menu-content::-webkit-scrollbar-thumb:hover {
background: #505050;
}
/* ==================== Category Section 分类区域 ==================== */
.ne-context-menu-category {
user-select: none;
}
.ne-context-menu-category-header {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
color: #808080;
cursor: pointer;
}
.ne-context-menu-category-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.ne-context-menu-category-chevron {
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
transition: transform 0.15s ease;
}
.ne-context-menu-category.expanded .ne-context-menu-category-chevron {
transform: rotate(90deg);
}
.ne-context-menu-category-title {
flex: 1;
}
.ne-context-menu-category-items {
display: none;
}
.ne-context-menu-category.expanded .ne-context-menu-category-items {
display: block;
}
/* ==================== Menu Item 菜单项 ==================== */
.ne-context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 24px;
cursor: pointer;
transition: background 0.1s ease;
}
.ne-context-menu-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.ne-context-menu-item.highlighted {
background: #264f78;
}
.ne-context-menu-item-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ne-context-menu-item-icon-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ne-context-menu-item-icon-dot.event { background: var(--ne-category-event); }
.ne-context-menu-item-icon-dot.function { background: var(--ne-category-function); }
.ne-context-menu-item-icon-dot.pure { background: var(--ne-category-pure); }
.ne-context-menu-item-icon-dot.flow { background: var(--ne-category-flow); }
.ne-context-menu-item-icon-dot.variable { background: var(--ne-category-variable); }
.ne-context-menu-item-icon-dot.literal { background: var(--ne-category-literal); }
.ne-context-menu-item-icon-dot.comment { background: var(--ne-category-comment); }
.ne-context-menu-item-icon-dot.custom { background: var(--ne-category-custom); }
/* Function icon - f */
.ne-context-menu-item-icon-func {
font-size: 11px;
font-weight: bold;
font-style: italic;
color: var(--ne-category-function);
}
.ne-context-menu-item-info {
flex: 1;
min-width: 0;
}
.ne-context-menu-item-title {
font-size: 12px;
color: #d0d0d0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ne-context-menu-item-title .highlight {
background: #4a6a2a;
color: #90ff90;
padding: 0 1px;
border-radius: 1px;
}
.ne-context-menu-item-description {
font-size: 10px;
color: #707070;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
/* ==================== Empty State 空状态 ==================== */
.ne-context-menu-empty {
padding: 20px;
text-align: center;
color: #606060;
font-size: 12px;
}
/* ==================== Footer 底部 ==================== */
.ne-context-menu-footer {
padding: 6px 10px;
border-top: 1px solid #303030;
background: #1e1e1e;
font-size: 10px;
color: #606060;
display: flex;
gap: 12px;
}
.ne-context-menu-footer kbd {
display: inline-block;
padding: 1px 4px;
font-family: var(--ne-font-family-mono);
font-size: 9px;
background: #303030;
border-radius: 2px;
margin-right: 2px;
}

View File

@@ -0,0 +1,191 @@
/**
* Dialog Styles
* 对话框样式
*/
/* ==================== Overlay 遮罩层 ==================== */
.ne-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
z-index: var(--ne-z-dialog);
display: flex;
align-items: center;
justify-content: center;
animation: ne-dialog-fade-in 0.15s ease;
}
@keyframes ne-dialog-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ==================== Dialog Container 对话框容器 ==================== */
.ne-dialog {
min-width: 320px;
max-width: 450px;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
font-family: var(--ne-font-family);
animation: ne-dialog-slide-in 0.15s ease;
overflow: hidden;
}
@keyframes ne-dialog-slide-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* ==================== Dialog Header 对话框头部 ==================== */
.ne-dialog-header {
padding: 12px 16px;
background: #252525;
border-bottom: 1px solid #3a3a3a;
}
.ne-dialog-title {
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
/* Type-specific header colors */
.ne-dialog-danger .ne-dialog-header {
background: linear-gradient(90deg, #4a1a1a 0%, #252525 100%);
}
.ne-dialog-warning .ne-dialog-header {
background: linear-gradient(90deg, #4a3a1a 0%, #252525 100%);
}
.ne-dialog-info .ne-dialog-header {
background: linear-gradient(90deg, #1a3a4a 0%, #252525 100%);
}
/* ==================== Dialog Body 对话框主体 ==================== */
.ne-dialog-body {
padding: 16px;
}
.ne-dialog-message {
margin: 0;
font-size: 13px;
color: #b0b0b0;
line-height: 1.5;
}
/* ==================== Dialog Footer 对话框底部 ==================== */
.ne-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
background: #1a1a1a;
border-top: 1px solid #303030;
}
/* ==================== Dialog Buttons 对话框按钮 ==================== */
.ne-dialog-btn {
padding: 6px 16px;
font-size: 12px;
font-family: var(--ne-font-family);
font-weight: 500;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.ne-dialog-btn:focus {
outline: none;
}
.ne-dialog-btn-cancel {
background: #2a2a2a;
border-color: #404040;
color: #b0b0b0;
}
.ne-dialog-btn-cancel:hover {
background: #353535;
border-color: #505050;
color: #d0d0d0;
}
.ne-dialog-btn-cancel:active {
background: #252525;
}
.ne-dialog-btn-confirm {
color: #fff;
}
.ne-dialog-btn-danger {
background: #8b2a2a;
border-color: #a03030;
}
.ne-dialog-btn-danger:hover {
background: #9b3535;
border-color: #b04040;
}
.ne-dialog-btn-danger:active {
background: #7b2525;
}
.ne-dialog-btn-danger:focus {
box-shadow: 0 0 0 2px rgba(160, 48, 48, 0.4);
}
.ne-dialog-btn-warning {
background: #8b6a2a;
border-color: #a08030;
}
.ne-dialog-btn-warning:hover {
background: #9b7535;
border-color: #b09040;
}
.ne-dialog-btn-warning:active {
background: #7b5a25;
}
.ne-dialog-btn-warning:focus {
box-shadow: 0 0 0 2px rgba(160, 128, 48, 0.4);
}
.ne-dialog-btn-info {
background: #2a5a8b;
border-color: #3070a0;
}
.ne-dialog-btn-info:hover {
background: #356a9b;
border-color: #4080b0;
}
.ne-dialog-btn-info:active {
background: #254a7b;
}
.ne-dialog-btn-info:focus {
box-shadow: 0 0 0 2px rgba(48, 112, 160, 0.4);
}

View File

@@ -0,0 +1,265 @@
/**
* Graph Node Styles
* 图节点样式
*/
/* ==================== Node Container 节点容器 ==================== */
.ne-node {
position: absolute;
min-width: var(--ne-node-min-width);
max-width: var(--ne-node-max-width);
background: var(--ne-node-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--ne-node-border);
border-radius: var(--ne-node-border-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
font-family: var(--ne-font-family);
user-select: none;
cursor: grab;
transition: box-shadow var(--ne-transition-normal),
border-color var(--ne-transition-normal);
}
.ne-node:hover {
border-color: rgba(80, 80, 100, 0.6);
}
.ne-node.selected {
border-color: var(--ne-node-border-selected);
box-shadow: 0 0 0 1px var(--ne-node-border-selected),
0 0 16px rgba(229, 160, 32, 0.4),
0 4px 12px rgba(0, 0, 0, 0.5);
}
.ne-node.dragging {
cursor: grabbing;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
z-index: var(--ne-z-dragging);
}
/* ==================== Node Header 节点头部 ==================== */
.ne-node-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
min-height: var(--ne-node-header-height);
border-radius: calc(var(--ne-node-border-radius) - 1px) calc(var(--ne-node-border-radius) - 1px) 0 0;
color: var(--ne-text-title);
font-size: var(--ne-font-size-title);
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
position: relative;
overflow: hidden;
}
/* Gradient overlay for header */
.ne-node-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.15;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%);
pointer-events: none;
}
/* Node category header colors */
.ne-node-header.event {
background: linear-gradient(90deg, var(--ne-category-event) 0%, var(--ne-category-event-dark) 100%);
}
.ne-node-header.function {
background: linear-gradient(90deg, var(--ne-category-function) 0%, var(--ne-category-function-dark) 100%);
}
.ne-node-header.pure {
background: linear-gradient(90deg, var(--ne-category-pure) 0%, var(--ne-category-pure-dark) 100%);
}
.ne-node-header.flow {
background: linear-gradient(90deg, var(--ne-category-flow) 0%, var(--ne-category-flow-dark) 100%);
}
.ne-node-header.variable {
background: linear-gradient(90deg, var(--ne-category-variable) 0%, var(--ne-category-variable-dark) 100%);
}
.ne-node-header.literal {
background: linear-gradient(90deg, var(--ne-category-literal) 0%, var(--ne-category-literal-dark) 100%);
}
.ne-node-header.comment {
background: linear-gradient(90deg, var(--ne-category-comment) 0%, var(--ne-category-comment-dark) 100%);
}
.ne-node-header.custom {
background: linear-gradient(90deg, var(--ne-category-custom) 0%, var(--ne-category-custom-dark) 100%);
}
/* Header icon - diamond shape for events */
.ne-node-header-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.ne-node-header.event .ne-node-header-icon::before {
content: '';
width: 8px;
height: 8px;
background: #fff;
transform: rotate(45deg);
border-radius: 1px;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
}
.ne-node-header-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.2px;
}
.ne-node-header-subtitle {
font-size: var(--ne-font-size-small);
font-weight: 400;
opacity: 0.7;
margin-left: 4px;
}
/* Header exec pin */
.ne-node-header-exec {
width: 10px;
height: 10px;
background: transparent;
border: 2px solid #a04040;
cursor: pointer;
transition: all var(--ne-transition-fast);
margin-left: auto;
flex-shrink: 0;
}
.ne-node-header-exec:hover {
border-color: #ff6060;
box-shadow: 0 0 6px rgba(255, 96, 96, 0.6);
}
.ne-node-header-exec.connected {
background: #c04040;
border-color: #c04040;
}
/* ==================== Node Body 节点主体 ==================== */
.ne-node-body {
padding: 6px 0;
background: var(--ne-node-bg-body);
border-radius: 0 0 calc(var(--ne-node-border-radius) - 1px) calc(var(--ne-node-border-radius) - 1px);
}
.ne-node-content {
display: flex;
flex-direction: column;
gap: 1px;
}
/* ==================== Pin Row 引脚行 ==================== */
.ne-pin-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
padding: 1px 0;
position: relative;
width: 100%;
box-sizing: border-box;
}
.ne-pin-row.input {
flex-direction: row;
justify-content: flex-start;
padding-left: 10px;
padding-right: 16px;
}
.ne-pin-row.output {
flex-direction: row-reverse;
justify-content: flex-start;
padding-right: 10px;
padding-left: 16px;
}
.ne-pin-label {
font-size: 11px;
color: #b0b0b0;
white-space: nowrap;
line-height: 1;
}
.ne-pin-row.output .ne-pin-label {
text-align: right;
}
/* ==================== Collapsed State 折叠状态 ==================== */
.ne-node.collapsed .ne-node-body {
display: none;
}
.ne-node.collapsed .ne-node-header {
border-radius: calc(var(--ne-node-border-radius) - 1px);
}
/* ==================== Comment Node 注释节点 ==================== */
.ne-node.comment {
background: rgba(50, 70, 50, 0.4);
border: 1px dashed rgba(100, 140, 100, 0.6);
min-width: 200px;
min-height: 100px;
}
.ne-node.comment .ne-node-header {
background: transparent;
color: #90a090;
}
.ne-node.comment .ne-node-body {
background: transparent;
color: #80a080;
font-style: italic;
}
/* ==================== Execution States 执行状态 ==================== */
.ne-node.running {
border-color: var(--ne-state-running);
box-shadow: 0 0 0 1px var(--ne-state-running),
0 0 12px rgba(255, 167, 38, 0.5);
animation: ne-pulse 1s ease-in-out infinite;
}
.ne-node.success {
border-color: var(--ne-state-success);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
.ne-node.error {
border-color: var(--ne-state-error);
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
}
@keyframes ne-pulse {
0%, 100% {
box-shadow: 0 0 0 1px var(--ne-state-running),
0 0 12px rgba(255, 167, 38, 0.5);
}
50% {
box-shadow: 0 0 0 2px var(--ne-state-running),
0 0 20px rgba(255, 167, 38, 0.7);
}
}

View File

@@ -0,0 +1,104 @@
/**
* Node Pin Styles
* 节点引脚样式
*/
/* ==================== Pin Base 引脚基础 ==================== */
.ne-pin {
position: relative;
width: 12px;
height: 12px;
cursor: pointer;
transition: transform 0.1s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.ne-pin:hover {
transform: scale(1.2);
}
.ne-pin.compatible {
animation: ne-pin-pulse 0.5s ease-in-out infinite;
}
/* ==================== Pin Type Colors 引脚类型颜色 ==================== */
.ne-pin.exec { color: var(--ne-pin-exec); }
.ne-pin.bool { color: var(--ne-pin-bool); }
.ne-pin.int { color: var(--ne-pin-int); }
.ne-pin.float { color: var(--ne-pin-float); }
.ne-pin.string { color: var(--ne-pin-string); }
.ne-pin.vector2 { color: var(--ne-pin-vector2); }
.ne-pin.vector3 { color: var(--ne-pin-vector3); }
.ne-pin.vector4 { color: var(--ne-pin-vector4); }
.ne-pin.color { color: var(--ne-pin-color); }
.ne-pin.object { color: var(--ne-pin-object); }
.ne-pin.array { color: var(--ne-pin-array); }
.ne-pin.map { color: var(--ne-pin-map); }
.ne-pin.struct { color: var(--ne-pin-struct); }
.ne-pin.enum { color: var(--ne-pin-enum); }
.ne-pin.delegate { color: var(--ne-pin-delegate); }
.ne-pin.any { color: var(--ne-pin-any); }
/* ==================== Pin States 引脚状态 ==================== */
.ne-pin.dragging-from {
transform: scale(1.3);
}
.ne-pin.drop-target {
transform: scale(1.4);
}
.ne-pin.invalid-target {
opacity: 0.3;
}
/* ==================== Pin Animations 引脚动画 ==================== */
@keyframes ne-pin-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.25);
}
}
/* ==================== Pin Value Input 引脚值输入 ==================== */
.ne-pin-value {
display: flex;
align-items: center;
gap: 4px;
}
.ne-pin-value-input {
width: 50px;
padding: 2px 4px;
font-size: 10px;
font-family: var(--ne-font-family-mono);
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 2px;
color: #c0c0c0;
outline: none;
}
.ne-pin-value-input:focus {
border-color: rgba(255, 255, 255, 0.3);
}
.ne-pin-value-checkbox {
width: 12px;
height: 12px;
accent-color: var(--ne-pin-bool);
}
.ne-pin-value-color {
width: 20px;
height: 12px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 2px;
cursor: pointer;
}

View File

@@ -0,0 +1,12 @@
/**
* Node Editor Styles Entry Point
* 节点编辑器样式入口
*/
@import './variables.css';
@import './Canvas.css';
@import './GraphNode.css';
@import './NodePin.css';
@import './Connection.css';
@import './ContextMenu.css';
@import './Dialog.css';

View File

@@ -0,0 +1,136 @@
/**
* Node Editor Theme Variables
* 节点编辑器主题变量
*/
:root {
/* ==================== Background Colors 背景颜色 ==================== */
--ne-canvas-bg: #141419;
--ne-canvas-grid: #1a1a22;
--ne-canvas-grid-major: #222230;
--ne-node-bg: rgba(12, 12, 16, 0.75);
--ne-node-bg-body: rgba(8, 8, 12, 0.85);
--ne-node-border: rgba(40, 40, 50, 0.6);
--ne-node-border-selected: #e5a020;
/* ==================== Node Category Colors 节点类别颜色 ==================== */
/* Event - Red gradient (like UE BeginOverlap) */
--ne-category-event: #b81c1c;
--ne-category-event-dark: #6b1010;
/* Function - Blue gradient */
--ne-category-function: #1b6eb5;
--ne-category-function-dark: #0d3d66;
/* Pure - Green/Teal gradient */
--ne-category-pure: #3d8b3d;
--ne-category-pure-dark: #1f5a1f;
/* Flow - Gray gradient */
--ne-category-flow: #4a4a4a;
--ne-category-flow-dark: #2a2a2a;
/* Variable - Purple gradient */
--ne-category-variable: #7b3d9b;
--ne-category-variable-dark: #4a1f66;
/* Literal - Gold/Orange gradient */
--ne-category-literal: #9a7020;
--ne-category-literal-dark: #5a4010;
/* Comment - Green gradient */
--ne-category-comment: #3d6b3d;
--ne-category-comment-dark: #2a4a2a;
/* Custom - Dark Gray gradient */
--ne-category-custom: #3a3a3a;
--ne-category-custom-dark: #1a1a1a;
/* ==================== Pin Type Colors 引脚类型颜色 ==================== */
--ne-pin-exec: #ffffff;
--ne-pin-bool: #8c0000;
--ne-pin-int: #1cc4c4;
--ne-pin-float: #7ecd32;
--ne-pin-string: #e060e0;
--ne-pin-vector2: #f0c030;
--ne-pin-vector3: #f0c030;
--ne-pin-vector4: #f0c030;
--ne-pin-color: #e08030;
--ne-pin-object: #00a0e0;
--ne-pin-array: #7030c0;
--ne-pin-map: #e060a0;
--ne-pin-struct: #3060c0;
--ne-pin-enum: #10a090;
--ne-pin-delegate: #c01030;
--ne-pin-any: #707070;
/* ==================== Connection Colors 连接线颜色 ==================== */
--ne-connection-exec: #ffffff;
--ne-connection-data: #888888;
--ne-connection-hover: #00aaff;
--ne-connection-selected: #ffcc00;
--ne-connection-invalid: #ff4444;
--ne-connection-preview: rgba(255, 255, 255, 0.5);
/* ==================== Text Colors 文字颜色 ==================== */
--ne-text-primary: #c0c0c0;
--ne-text-secondary: #909090;
--ne-text-muted: #606060;
--ne-text-title: #ffffff;
--ne-text-subtitle: #cccccc;
/* ==================== State Colors 状态颜色 ==================== */
--ne-state-success: #4caf50;
--ne-state-warning: #ff9800;
--ne-state-error: #f44336;
--ne-state-info: #2196f3;
--ne-state-running: #ffa726;
/* ==================== Shadow 阴影 ==================== */
--ne-shadow-node: 0 4px 12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 0, 0, 0.8),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
--ne-shadow-node-hover: 0 6px 16px rgba(0, 0, 0, 0.6);
--ne-shadow-node-selected: 0 0 0 2px var(--ne-node-border-selected),
0 0 20px rgba(245, 166, 35, 0.3);
--ne-shadow-pin: 0 0 4px rgba(0, 0, 0, 0.5);
--ne-shadow-pin-hover: 0 0 8px currentColor;
/* ==================== Sizing 尺寸 ==================== */
--ne-node-min-width: 200px;
--ne-node-max-width: 350px;
--ne-node-border-radius: 6px;
--ne-node-header-height: 28px;
--ne-node-padding: 8px 0;
--ne-pin-size: 10px;
--ne-pin-size-exec: 12px;
--ne-pin-spacing: 22px;
--ne-pin-hit-area: 24px;
--ne-connection-width: 2px;
--ne-connection-width-hover: 3px;
--ne-connection-width-exec: 3px;
/* ==================== Typography 字体 ==================== */
--ne-font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
--ne-font-family-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
--ne-font-size-title: 12px;
--ne-font-size-body: 11px;
--ne-font-size-small: 10px;
/* ==================== Animation 动画 ==================== */
--ne-transition-fast: 0.1s ease;
--ne-transition-normal: 0.15s ease;
--ne-transition-slow: 0.3s ease;
/* ==================== Z-Index 层级 ==================== */
--ne-z-grid: 0;
--ne-z-connections: 1;
--ne-z-nodes: 2;
--ne-z-dragging: 100;
--ne-z-menu: 1000;
--ne-z-tooltip: 1001;
--ne-z-dialog: 2000;
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,87 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import react from '@vitejs/plugin-react';
/**
* Custom plugin: Convert CSS to self-executing style injection code
* 自定义插件:将 CSS 转换为自执行的样式注入代码
*/
function injectCSSPlugin(): any {
let cssCounter = 0;
return {
name: 'inject-css-plugin',
enforce: 'post' as const,
generateBundle(_options: any, bundle: any) {
const bundleKeys = Object.keys(bundle);
// Find all CSS files (找到所有 CSS 文件)
const cssFiles = bundleKeys.filter(key => key.endsWith('.css'));
for (const cssFile of cssFiles) {
const cssChunk = bundle[cssFile];
if (!cssChunk || !cssChunk.source) continue;
const cssContent = cssChunk.source;
const styleId = `esengine-node-editor-style-${cssCounter++}`;
// Generate style injection code (生成样式注入代码)
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${JSON.stringify(cssContent)};document.head.appendChild(s);}}})();`;
// Inject into index.js (注入到 index.js)
for (const jsKey of bundleKeys) {
if (!jsKey.endsWith('.js')) continue;
const jsChunk = bundle[jsKey];
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
if (jsKey === 'index.js') {
jsChunk.code = injectCode + '\n' + jsChunk.code;
}
}
// Remove standalone CSS file (删除独立的 CSS 文件)
delete bundle[cssFile];
}
}
};
}
export default defineConfig({
plugins: [
react(),
dts({
include: ['src'],
outDir: 'dist',
rollupTypes: false
}),
injectCSSPlugin()
],
esbuild: {
jsx: 'automatic',
},
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts')
},
formats: ['es'],
fileName: (format, entryName) => `${entryName}.js`
},
rollupOptions: {
external: [
'react',
'react/jsx-runtime',
'zustand',
/^@esengine\//
],
output: {
exports: 'named',
preserveModules: false
}
},
target: 'es2020',
minify: false,
sourcemap: true
}
});