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