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:
374
packages/node-editor/src/components/canvas/GraphCanvas.tsx
Normal file
374
packages/node-editor/src/components/canvas/GraphCanvas.tsx
Normal 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;
|
||||
6
packages/node-editor/src/components/canvas/index.ts
Normal file
6
packages/node-editor/src/components/canvas/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
GraphCanvas,
|
||||
useCanvasTransform,
|
||||
type GraphCanvasProps,
|
||||
type CanvasTransform
|
||||
} from './GraphCanvas';
|
||||
@@ -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;
|
||||
8
packages/node-editor/src/components/connections/index.ts
Normal file
8
packages/node-editor/src/components/connections/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
ConnectionLine,
|
||||
ConnectionPreview,
|
||||
ConnectionLayer,
|
||||
type ConnectionLineProps,
|
||||
type ConnectionPreviewProps,
|
||||
type ConnectionLayerProps
|
||||
} from './ConnectionLine';
|
||||
98
packages/node-editor/src/components/dialog/ConfirmDialog.tsx
Normal file
98
packages/node-editor/src/components/dialog/ConfirmDialog.tsx
Normal 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;
|
||||
1
packages/node-editor/src/components/dialog/index.ts
Normal file
1
packages/node-editor/src/components/dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
|
||||
539
packages/node-editor/src/components/editor/NodeEditor.tsx
Normal file
539
packages/node-editor/src/components/editor/NodeEditor.tsx
Normal 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;
|
||||
5
packages/node-editor/src/components/editor/index.ts
Normal file
5
packages/node-editor/src/components/editor/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
NodeEditor,
|
||||
type NodeEditorProps,
|
||||
type NodeExecutionStates
|
||||
} from './NodeEditor';
|
||||
7
packages/node-editor/src/components/index.ts
Normal file
7
packages/node-editor/src/components/index.ts
Normal 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';
|
||||
347
packages/node-editor/src/components/menu/NodeContextMenu.tsx
Normal file
347
packages/node-editor/src/components/menu/NodeContextMenu.tsx
Normal 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;
|
||||
1
packages/node-editor/src/components/menu/index.ts
Normal file
1
packages/node-editor/src/components/menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './NodeContextMenu';
|
||||
275
packages/node-editor/src/components/nodes/GraphNodeComponent.tsx
Normal file
275
packages/node-editor/src/components/nodes/GraphNodeComponent.tsx
Normal 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;
|
||||
6
packages/node-editor/src/components/nodes/index.ts
Normal file
6
packages/node-editor/src/components/nodes/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
GraphNodeComponent,
|
||||
MemoizedGraphNodeComponent,
|
||||
type GraphNodeComponentProps,
|
||||
type NodeExecutionState
|
||||
} from './GraphNodeComponent';
|
||||
310
packages/node-editor/src/components/pins/NodePin.tsx
Normal file
310
packages/node-editor/src/components/pins/NodePin.tsx
Normal 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;
|
||||
1
packages/node-editor/src/components/pins/index.ts
Normal file
1
packages/node-editor/src/components/pins/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NodePin, PinRow, type NodePinProps, type PinRowProps } from './NodePin';
|
||||
Reference in New Issue
Block a user