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:
71
packages/node-editor/package.json
Normal file
71
packages/node-editor/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@esengine/node-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./styles": {
|
||||
"import": "./dist/styles/index.css"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"node-editor",
|
||||
"visual-programming",
|
||||
"blueprint",
|
||||
"shader-graph",
|
||||
"state-machine",
|
||||
"ecs",
|
||||
"game-engine"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": false
|
||||
},
|
||||
"zustand": {
|
||||
"optional": false
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.17",
|
||||
"@types/react": "^18.3.12",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-dts": "^3.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/node-editor"
|
||||
}
|
||||
}
|
||||
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';
|
||||
2
packages/node-editor/src/domain/index.ts
Normal file
2
packages/node-editor/src/domain/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './models';
|
||||
export * from './value-objects';
|
||||
156
packages/node-editor/src/domain/models/Connection.ts
Normal file
156
packages/node-editor/src/domain/models/Connection.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { PinCategory } from '../value-objects/PinType';
|
||||
|
||||
/**
|
||||
* Connection - Represents a link between two pins
|
||||
* 连接 - 表示两个引脚之间的链接
|
||||
*/
|
||||
export class Connection {
|
||||
private readonly _id: string;
|
||||
private readonly _fromNodeId: string;
|
||||
private readonly _fromPinId: string;
|
||||
private readonly _toNodeId: string;
|
||||
private readonly _toPinId: string;
|
||||
private readonly _category: PinCategory;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
fromNodeId: string,
|
||||
fromPinId: string,
|
||||
toNodeId: string,
|
||||
toPinId: string,
|
||||
category: PinCategory
|
||||
) {
|
||||
this._id = id;
|
||||
this._fromNodeId = fromNodeId;
|
||||
this._fromPinId = fromPinId;
|
||||
this._toNodeId = toNodeId;
|
||||
this._toPinId = toPinId;
|
||||
this._category = category;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source node ID (output side)
|
||||
* 源节点ID(输出端)
|
||||
*/
|
||||
get fromNodeId(): string {
|
||||
return this._fromNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source pin ID (output side)
|
||||
* 源引脚ID(输出端)
|
||||
*/
|
||||
get fromPinId(): string {
|
||||
return this._fromPinId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target node ID (input side)
|
||||
* 目标节点ID(输入端)
|
||||
*/
|
||||
get toNodeId(): string {
|
||||
return this._toNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target pin ID (input side)
|
||||
* 目标引脚ID(输入端)
|
||||
*/
|
||||
get toPinId(): string {
|
||||
return this._toPinId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection category determines the wire color
|
||||
* 连接类别决定连线颜色
|
||||
*/
|
||||
get category(): PinCategory {
|
||||
return this._category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is an execution flow connection
|
||||
* 是否是执行流连接
|
||||
*/
|
||||
get isExec(): boolean {
|
||||
return this._category === 'exec';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this connection involves a specific node
|
||||
* 检查此连接是否涉及特定节点
|
||||
*/
|
||||
involvesNode(nodeId: string): boolean {
|
||||
return this._fromNodeId === nodeId || this._toNodeId === nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this connection involves a specific pin
|
||||
* 检查此连接是否涉及特定引脚
|
||||
*/
|
||||
involvesPin(pinId: string): boolean {
|
||||
return this._fromPinId === pinId || this._toPinId === pinId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this connection matches the given endpoints
|
||||
* 检查此连接是否匹配给定的端点
|
||||
*/
|
||||
matches(fromPinId: string, toPinId: string): boolean {
|
||||
return this._fromPinId === fromPinId && this._toPinId === toPinId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks equality with another connection
|
||||
* 检查与另一个连接是否相等
|
||||
*/
|
||||
equals(other: Connection): boolean {
|
||||
return (
|
||||
this._fromNodeId === other._fromNodeId &&
|
||||
this._fromPinId === other._fromPinId &&
|
||||
this._toNodeId === other._toNodeId &&
|
||||
this._toPinId === other._toPinId
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
id: this._id,
|
||||
fromNodeId: this._fromNodeId,
|
||||
fromPinId: this._fromPinId,
|
||||
toNodeId: this._toNodeId,
|
||||
toPinId: this._toPinId,
|
||||
category: this._category
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
fromPinId: string;
|
||||
toNodeId: string;
|
||||
toPinId: string;
|
||||
category: PinCategory;
|
||||
}): Connection {
|
||||
return new Connection(
|
||||
json.id,
|
||||
json.fromNodeId,
|
||||
json.fromPinId,
|
||||
json.toNodeId,
|
||||
json.toPinId,
|
||||
json.category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection ID from pin IDs
|
||||
* 从引脚ID创建连接ID
|
||||
*/
|
||||
static createId(fromPinId: string, toPinId: string): string {
|
||||
return `${fromPinId}->${toPinId}`;
|
||||
}
|
||||
}
|
||||
271
packages/node-editor/src/domain/models/Graph.ts
Normal file
271
packages/node-editor/src/domain/models/Graph.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { GraphNode } from './GraphNode';
|
||||
import { Connection } from './Connection';
|
||||
import { Pin } from './Pin';
|
||||
import { Position } from '../value-objects/Position';
|
||||
|
||||
/**
|
||||
* Graph - Aggregate root for the node graph
|
||||
* 图 - 节点图的聚合根
|
||||
*
|
||||
* This class is immutable - all modification methods return new instances.
|
||||
* 此类是不可变的 - 所有修改方法返回新实例
|
||||
*/
|
||||
export class Graph {
|
||||
private readonly _id: string;
|
||||
private readonly _name: string;
|
||||
private readonly _nodes: Map<string, GraphNode>;
|
||||
private readonly _connections: Connection[];
|
||||
private readonly _metadata: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
nodes: GraphNode[] = [],
|
||||
connections: Connection[] = [],
|
||||
metadata: Record<string, unknown> = {}
|
||||
) {
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._nodes = new Map(nodes.map(n => [n.id, n]));
|
||||
this._connections = [...connections];
|
||||
this._metadata = { ...metadata };
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get nodes(): GraphNode[] {
|
||||
return Array.from(this._nodes.values());
|
||||
}
|
||||
|
||||
get connections(): Connection[] {
|
||||
return [...this._connections];
|
||||
}
|
||||
|
||||
get metadata(): Record<string, unknown> {
|
||||
return { ...this._metadata };
|
||||
}
|
||||
|
||||
get nodeCount(): number {
|
||||
return this._nodes.size;
|
||||
}
|
||||
|
||||
get connectionCount(): number {
|
||||
return this._connections.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a node by ID
|
||||
* 通过ID获取节点
|
||||
*/
|
||||
getNode(nodeId: string): GraphNode | undefined {
|
||||
return this._nodes.get(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pin by its full ID
|
||||
* 通过完整ID获取引脚
|
||||
*/
|
||||
getPin(pinId: string): Pin | undefined {
|
||||
for (const node of this._nodes.values()) {
|
||||
const pin = node.getPin(pinId);
|
||||
if (pin) return pin;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all connections involving a node
|
||||
* 获取涉及某节点的所有连接
|
||||
*/
|
||||
getNodeConnections(nodeId: string): Connection[] {
|
||||
return this._connections.filter(c => c.involvesNode(nodeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all connections to/from a specific pin
|
||||
* 获取特定引脚的所有连接
|
||||
*/
|
||||
getPinConnections(pinId: string): Connection[] {
|
||||
return this._connections.filter(c => c.involvesPin(pinId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a pin is connected
|
||||
* 检查引脚是否已连接
|
||||
*/
|
||||
isPinConnected(pinId: string): boolean {
|
||||
return this._connections.some(c => c.involvesPin(pinId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new node to the graph (immutable)
|
||||
* 向图中添加新节点(不可变)
|
||||
*/
|
||||
addNode(node: GraphNode): Graph {
|
||||
if (this._nodes.has(node.id)) {
|
||||
throw new Error(`Node with ID "${node.id}" already exists`);
|
||||
}
|
||||
const newNodes = [...this.nodes, node];
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node and its connections (immutable)
|
||||
* 移除节点及其连接(不可变)
|
||||
*/
|
||||
removeNode(nodeId: string): Graph {
|
||||
if (!this._nodes.has(nodeId)) {
|
||||
return this;
|
||||
}
|
||||
const newNodes = this.nodes.filter(n => n.id !== nodeId);
|
||||
const newConnections = this._connections.filter(c => !c.involvesNode(nodeId));
|
||||
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a node (immutable)
|
||||
* 更新节点(不可变)
|
||||
*/
|
||||
updateNode(nodeId: string, updater: (node: GraphNode) => GraphNode): Graph {
|
||||
const node = this._nodes.get(nodeId);
|
||||
if (!node) return this;
|
||||
|
||||
const updatedNode = updater(node);
|
||||
const newNodes = this.nodes.map(n => n.id === nodeId ? updatedNode : n);
|
||||
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a node to a new position (immutable)
|
||||
* 移动节点到新位置(不可变)
|
||||
*/
|
||||
moveNode(nodeId: string, newPosition: Position): Graph {
|
||||
return this.updateNode(nodeId, node => node.moveTo(newPosition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a connection between two pins (immutable)
|
||||
* 在两个引脚之间添加连接(不可变)
|
||||
*/
|
||||
addConnection(connection: Connection): Graph {
|
||||
// Validate connection
|
||||
// 验证连接
|
||||
const fromPin = this.getPin(connection.fromPinId);
|
||||
const toPin = this.getPin(connection.toPinId);
|
||||
|
||||
if (!fromPin || !toPin) {
|
||||
throw new Error('Invalid connection: pin not found');
|
||||
}
|
||||
|
||||
if (!fromPin.canConnectTo(toPin)) {
|
||||
throw new Error('Invalid connection: incompatible pin types');
|
||||
}
|
||||
|
||||
// Check for duplicate connections
|
||||
// 检查重复连接
|
||||
const exists = this._connections.some(c =>
|
||||
c.matches(connection.fromPinId, connection.toPinId)
|
||||
);
|
||||
if (exists) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Remove existing connection to input pin if it doesn't allow multiple
|
||||
// 如果输入引脚不允许多连接,移除现有连接
|
||||
let newConnections = [...this._connections];
|
||||
if (!toPin.allowMultiple) {
|
||||
newConnections = newConnections.filter(c => c.toPinId !== connection.toPinId);
|
||||
}
|
||||
|
||||
newConnections.push(connection);
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a connection (immutable)
|
||||
* 移除连接(不可变)
|
||||
*/
|
||||
removeConnection(connectionId: string): Graph {
|
||||
const newConnections = this._connections.filter(c => c.id !== connectionId);
|
||||
if (newConnections.length === this._connections.length) {
|
||||
return this;
|
||||
}
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all connections to/from a pin (immutable)
|
||||
* 移除引脚的所有连接(不可变)
|
||||
*/
|
||||
disconnectPin(pinId: string): Graph {
|
||||
const newConnections = this._connections.filter(c => !c.involvesPin(pinId));
|
||||
if (newConnections.length === this._connections.length) {
|
||||
return this;
|
||||
}
|
||||
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates graph metadata (immutable)
|
||||
* 更新图元数据(不可变)
|
||||
*/
|
||||
setMetadata(metadata: Record<string, unknown>): Graph {
|
||||
return new Graph(this._id, this._name, this.nodes, this._connections, {
|
||||
...this._metadata,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new graph with updated name (immutable)
|
||||
* 创建具有更新名称的新图(不可变)
|
||||
*/
|
||||
rename(newName: string): Graph {
|
||||
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the graph structure
|
||||
* 验证图结构
|
||||
*/
|
||||
validate(): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for orphan connections
|
||||
// 检查孤立连接
|
||||
for (const conn of this._connections) {
|
||||
if (!this.getPin(conn.fromPinId)) {
|
||||
errors.push(`Connection "${conn.id}" references non-existent source pin`);
|
||||
}
|
||||
if (!this.getPin(conn.toPinId)) {
|
||||
errors.push(`Connection "${conn.id}" references non-existent target pin`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
id: this._id,
|
||||
name: this._name,
|
||||
nodes: this.nodes.map(n => n.toJSON()),
|
||||
connections: this._connections.map(c => c.toJSON()),
|
||||
metadata: this._metadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty graph
|
||||
* 创建空图
|
||||
*/
|
||||
static empty(id: string, name: string): Graph {
|
||||
return new Graph(id, name, [], [], {});
|
||||
}
|
||||
}
|
||||
267
packages/node-editor/src/domain/models/GraphNode.ts
Normal file
267
packages/node-editor/src/domain/models/GraphNode.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Position } from '../value-objects/Position';
|
||||
import { Pin, PinDefinition } from './Pin';
|
||||
|
||||
/**
|
||||
* Node category determines the visual style of the node header
|
||||
* 节点类别决定节点头部的视觉样式
|
||||
*/
|
||||
export type NodeCategory =
|
||||
| 'event' // Event node - triggers execution (事件节点 - 触发执行)
|
||||
| 'function' // Function call node (函数调用节点)
|
||||
| 'pure' // Pure function - no execution pins (纯函数 - 无执行引脚)
|
||||
| 'flow' // Flow control - branch, loop, etc. (流程控制 - 分支、循环等)
|
||||
| 'variable' // Variable get/set (变量读写)
|
||||
| 'literal' // Literal value input (字面量输入)
|
||||
| 'comment' // Comment node (注释节点)
|
||||
| 'custom'; // Custom category with user-defined color (自定义类别)
|
||||
|
||||
/**
|
||||
* Node template definition for creating nodes
|
||||
* 用于创建节点的节点模板定义
|
||||
*/
|
||||
export interface NodeTemplate {
|
||||
/** Unique template identifier (唯一模板标识符) */
|
||||
id: string;
|
||||
|
||||
/** Display title (显示标题) */
|
||||
title: string;
|
||||
|
||||
/** Optional subtitle (可选副标题) */
|
||||
subtitle?: string;
|
||||
|
||||
/** Node category for styling (节点类别用于样式) */
|
||||
category: NodeCategory;
|
||||
|
||||
/** Custom header color override (自定义头部颜色覆盖) */
|
||||
headerColor?: string;
|
||||
|
||||
/** Icon name from icon library (图标库中的图标名称) */
|
||||
icon?: string;
|
||||
|
||||
/** Input pin definitions (输入引脚定义) */
|
||||
inputPins: Omit<PinDefinition, 'direction'>[];
|
||||
|
||||
/** Output pin definitions (输出引脚定义) */
|
||||
outputPins: Omit<PinDefinition, 'direction'>[];
|
||||
|
||||
/** Whether the node can be collapsed (节点是否可折叠) */
|
||||
collapsible?: boolean;
|
||||
|
||||
/** Whether to show the title bar (是否显示标题栏) */
|
||||
showHeader?: boolean;
|
||||
|
||||
/** Minimum width in pixels (最小宽度,像素) */
|
||||
minWidth?: number;
|
||||
|
||||
/** Category path for node palette (节点面板的分类路径) */
|
||||
path?: string[];
|
||||
|
||||
/** Search keywords (搜索关键词) */
|
||||
keywords?: string[];
|
||||
|
||||
/** Description for documentation (文档描述) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphNode - Represents a node instance in the graph
|
||||
* 图节点 - 表示图中的节点实例
|
||||
*/
|
||||
export class GraphNode {
|
||||
private readonly _id: string;
|
||||
private readonly _templateId: string;
|
||||
private _position: Position;
|
||||
private readonly _category: NodeCategory;
|
||||
private readonly _title: string;
|
||||
private readonly _subtitle?: string;
|
||||
private readonly _icon?: string;
|
||||
private readonly _headerColor?: string;
|
||||
private readonly _inputPins: Pin[];
|
||||
private readonly _outputPins: Pin[];
|
||||
private _isCollapsed: boolean;
|
||||
private _comment?: string;
|
||||
private _data: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data: Record<string, unknown> = {}
|
||||
) {
|
||||
this._id = id;
|
||||
this._templateId = template.id;
|
||||
this._position = position;
|
||||
this._category = template.category;
|
||||
this._title = template.title;
|
||||
this._subtitle = template.subtitle;
|
||||
this._icon = template.icon;
|
||||
this._headerColor = template.headerColor;
|
||||
this._isCollapsed = false;
|
||||
this._data = { ...data };
|
||||
|
||||
// Create input pins (创建输入引脚)
|
||||
this._inputPins = template.inputPins.map((def, index) =>
|
||||
new Pin(
|
||||
`${id}_in_${index}`,
|
||||
id,
|
||||
{ ...def, direction: 'input' }
|
||||
)
|
||||
);
|
||||
|
||||
// Create output pins (创建输出引脚)
|
||||
this._outputPins = template.outputPins.map((def, index) =>
|
||||
new Pin(
|
||||
`${id}_out_${index}`,
|
||||
id,
|
||||
{ ...def, direction: 'output' }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get templateId(): string {
|
||||
return this._templateId;
|
||||
}
|
||||
|
||||
get position(): Position {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
get category(): NodeCategory {
|
||||
return this._category;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
get subtitle(): string | undefined {
|
||||
return this._subtitle;
|
||||
}
|
||||
|
||||
get icon(): string | undefined {
|
||||
return this._icon;
|
||||
}
|
||||
|
||||
get headerColor(): string | undefined {
|
||||
return this._headerColor;
|
||||
}
|
||||
|
||||
get inputPins(): readonly Pin[] {
|
||||
return this._inputPins;
|
||||
}
|
||||
|
||||
get outputPins(): readonly Pin[] {
|
||||
return this._outputPins;
|
||||
}
|
||||
|
||||
get allPins(): readonly Pin[] {
|
||||
return [...this._inputPins, ...this._outputPins];
|
||||
}
|
||||
|
||||
get isCollapsed(): boolean {
|
||||
return this._isCollapsed;
|
||||
}
|
||||
|
||||
get comment(): string | undefined {
|
||||
return this._comment;
|
||||
}
|
||||
|
||||
get data(): Record<string, unknown> {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pin by its ID
|
||||
* 通过ID获取引脚
|
||||
*/
|
||||
getPin(pinId: string): Pin | undefined {
|
||||
return this.allPins.find(p => p.id === pinId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pin by its name
|
||||
* 通过名称获取引脚
|
||||
*/
|
||||
getPinByName(name: string, direction: 'input' | 'output'): Pin | undefined {
|
||||
const pins = direction === 'input' ? this._inputPins : this._outputPins;
|
||||
return pins.find(p => p.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the execution input pin if exists
|
||||
* 获取执行输入引脚(如果存在)
|
||||
*/
|
||||
getExecInput(): Pin | undefined {
|
||||
return this._inputPins.find(p => p.isExec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all execution output pins
|
||||
* 获取所有执行输出引脚
|
||||
*/
|
||||
getExecOutputs(): Pin[] {
|
||||
return this._outputPins.filter(p => p.isExec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new node with updated position (immutable)
|
||||
* 创建具有更新位置的新节点(不可变)
|
||||
*/
|
||||
moveTo(newPosition: Position): GraphNode {
|
||||
const node = this.clone();
|
||||
node._position = newPosition;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new node with collapse state toggled (immutable)
|
||||
* 创建切换折叠状态的新节点(不可变)
|
||||
*/
|
||||
toggleCollapse(): GraphNode {
|
||||
const node = this.clone();
|
||||
node._isCollapsed = !node._isCollapsed;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new node with updated comment (immutable)
|
||||
* 创建具有更新注释的新节点(不可变)
|
||||
*/
|
||||
setComment(comment: string | undefined): GraphNode {
|
||||
const node = this.clone();
|
||||
node._comment = comment;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new node with updated data (immutable)
|
||||
* 创建具有更新数据的新节点(不可变)
|
||||
*/
|
||||
updateData(data: Record<string, unknown>): GraphNode {
|
||||
const node = this.clone();
|
||||
node._data = { ...node._data, ...data };
|
||||
return node;
|
||||
}
|
||||
|
||||
private clone(): GraphNode {
|
||||
const cloned = Object.create(GraphNode.prototype) as GraphNode;
|
||||
Object.assign(cloned, this);
|
||||
cloned._data = { ...this._data };
|
||||
return cloned;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
id: this._id,
|
||||
templateId: this._templateId,
|
||||
position: this._position.toJSON(),
|
||||
isCollapsed: this._isCollapsed,
|
||||
comment: this._comment,
|
||||
data: this._data
|
||||
};
|
||||
}
|
||||
}
|
||||
187
packages/node-editor/src/domain/models/Pin.ts
Normal file
187
packages/node-editor/src/domain/models/Pin.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { PinType, PinDirection, PinCategory, PinShape } from '../value-objects/PinType';
|
||||
|
||||
/**
|
||||
* Pin definition for node templates
|
||||
* 节点模板的引脚定义
|
||||
*/
|
||||
export interface PinDefinition {
|
||||
/** Unique identifier within the node (节点内的唯一标识符) */
|
||||
name: string;
|
||||
|
||||
/** Display name shown in UI (UI中显示的名称) */
|
||||
displayName: string;
|
||||
|
||||
/** Pin direction (引脚方向) */
|
||||
direction: PinDirection;
|
||||
|
||||
/** Pin data type category (引脚数据类型分类) */
|
||||
category: PinCategory;
|
||||
|
||||
/** Subtype for struct/enum (结构体/枚举的子类型) */
|
||||
subType?: string;
|
||||
|
||||
/** Whether this pin accepts array type (是否接受数组类型) */
|
||||
isArray?: boolean;
|
||||
|
||||
/** Default value when not connected (未连接时的默认值) */
|
||||
defaultValue?: unknown;
|
||||
|
||||
/** Whether multiple connections are allowed (是否允许多个连接) */
|
||||
allowMultiple?: boolean;
|
||||
|
||||
/** Whether this pin is hidden by default (是否默认隐藏) */
|
||||
hidden?: boolean;
|
||||
|
||||
/** Custom color override (自定义颜色覆盖) */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin - Represents a connection point on a node
|
||||
* 引脚 - 表示节点上的连接点
|
||||
*/
|
||||
export class Pin {
|
||||
private readonly _id: string;
|
||||
private readonly _nodeId: string;
|
||||
private readonly _name: string;
|
||||
private readonly _displayName: string;
|
||||
private readonly _direction: PinDirection;
|
||||
private readonly _type: PinType;
|
||||
private readonly _defaultValue: unknown;
|
||||
private readonly _allowMultiple: boolean;
|
||||
private readonly _hidden: boolean;
|
||||
private readonly _color?: string;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
nodeId: string,
|
||||
definition: PinDefinition
|
||||
) {
|
||||
this._id = id;
|
||||
this._nodeId = nodeId;
|
||||
this._name = definition.name;
|
||||
this._displayName = definition.displayName;
|
||||
this._direction = definition.direction;
|
||||
this._type = new PinType(
|
||||
definition.category,
|
||||
definition.subType,
|
||||
definition.isArray ?? false
|
||||
);
|
||||
this._defaultValue = definition.defaultValue;
|
||||
this._allowMultiple = definition.allowMultiple ?? (definition.category === 'exec' && definition.direction === 'output');
|
||||
this._hidden = definition.hidden ?? false;
|
||||
this._color = definition.color;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get nodeId(): string {
|
||||
return this._nodeId;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get displayName(): string {
|
||||
return this._displayName;
|
||||
}
|
||||
|
||||
get direction(): PinDirection {
|
||||
return this._direction;
|
||||
}
|
||||
|
||||
get type(): PinType {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
get category(): PinCategory {
|
||||
return this._type.category;
|
||||
}
|
||||
|
||||
get shape(): PinShape {
|
||||
return this._type.shape;
|
||||
}
|
||||
|
||||
get defaultValue(): unknown {
|
||||
return this._defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether multiple connections are allowed
|
||||
* 是否允许多个连接
|
||||
*/
|
||||
get allowMultiple(): boolean {
|
||||
return this._allowMultiple;
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this._hidden;
|
||||
}
|
||||
|
||||
get color(): string | undefined {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is an execution flow pin
|
||||
* 是否是执行流引脚
|
||||
*/
|
||||
get isExec(): boolean {
|
||||
return this._type.category === 'exec';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is an input pin
|
||||
* 是否是输入引脚
|
||||
*/
|
||||
get isInput(): boolean {
|
||||
return this._direction === 'input';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is an output pin
|
||||
* 是否是输出引脚
|
||||
*/
|
||||
get isOutput(): boolean {
|
||||
return this._direction === 'output';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this pin can connect to another pin
|
||||
* 检查此引脚是否可以连接到另一个引脚
|
||||
*/
|
||||
canConnectTo(other: Pin): boolean {
|
||||
// Cannot connect to self (不能连接到自己)
|
||||
if (this._nodeId === other._nodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be opposite directions (必须是相反方向)
|
||||
if (this._direction === other._direction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check type compatibility (检查类型兼容性)
|
||||
return this._type.canConnectTo(other._type);
|
||||
}
|
||||
|
||||
toJSON(): PinDefinition & { id: string; nodeId: string } {
|
||||
return {
|
||||
id: this._id,
|
||||
nodeId: this._nodeId,
|
||||
name: this._name,
|
||||
displayName: this._displayName,
|
||||
direction: this._direction,
|
||||
category: this._type.category,
|
||||
subType: this._type.subType,
|
||||
isArray: this._type.isArray,
|
||||
defaultValue: this._defaultValue,
|
||||
allowMultiple: this._allowMultiple,
|
||||
hidden: this._hidden,
|
||||
color: this._color
|
||||
};
|
||||
}
|
||||
}
|
||||
4
packages/node-editor/src/domain/models/index.ts
Normal file
4
packages/node-editor/src/domain/models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Pin, type PinDefinition } from './Pin';
|
||||
export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode';
|
||||
export { Connection } from './Connection';
|
||||
export { Graph } from './Graph';
|
||||
175
packages/node-editor/src/domain/value-objects/PinType.ts
Normal file
175
packages/node-editor/src/domain/value-objects/PinType.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Pin direction - input or output
|
||||
* 引脚方向 - 输入或输出
|
||||
*/
|
||||
export type PinDirection = 'input' | 'output';
|
||||
|
||||
/**
|
||||
* Pin data type categories for visual programming
|
||||
* 可视化编程的引脚数据类型分类
|
||||
*
|
||||
* These types cover common use cases in:
|
||||
* 这些类型涵盖以下常见用例:
|
||||
* - Blueprint visual scripting (蓝图可视化脚本)
|
||||
* - Shader graph editors (着色器图编辑器)
|
||||
* - State machine editors (状态机编辑器)
|
||||
* - Animation graph editors (动画图编辑器)
|
||||
*/
|
||||
export type PinCategory =
|
||||
| 'exec' // Execution flow pin (执行流引脚)
|
||||
| 'bool' // Boolean value (布尔值)
|
||||
| 'int' // Integer number (整数)
|
||||
| 'float' // Floating point number (浮点数)
|
||||
| 'string' // Text string (字符串)
|
||||
| 'vector2' // 2D vector (二维向量)
|
||||
| 'vector3' // 3D vector (三维向量)
|
||||
| 'vector4' // 4D vector / Color (四维向量/颜色)
|
||||
| 'color' // RGBA color (RGBA颜色)
|
||||
| 'object' // Object reference (对象引用)
|
||||
| 'array' // Array of values (数组)
|
||||
| 'map' // Key-value map (键值映射)
|
||||
| 'struct' // Custom struct (自定义结构体)
|
||||
| 'enum' // Enumeration (枚举)
|
||||
| 'delegate' // Event delegate (事件委托)
|
||||
| 'any'; // Wildcard type (通配符类型)
|
||||
|
||||
/**
|
||||
* Pin shape for rendering
|
||||
* 引脚渲染形状
|
||||
*/
|
||||
export type PinShape =
|
||||
| 'circle' // Standard data pin (标准数据引脚)
|
||||
| 'triangle' // Execution flow pin (执行流引脚)
|
||||
| 'diamond' // Array/special pin (数组/特殊引脚)
|
||||
| 'square'; // Struct pin (结构体引脚)
|
||||
|
||||
/**
|
||||
* Gets the default shape for a pin category
|
||||
* 获取引脚类型的默认形状
|
||||
*/
|
||||
export function getDefaultPinShape(category: PinCategory): PinShape {
|
||||
switch (category) {
|
||||
case 'exec':
|
||||
return 'triangle';
|
||||
case 'array':
|
||||
case 'map':
|
||||
return 'diamond';
|
||||
case 'struct':
|
||||
return 'square';
|
||||
default:
|
||||
return 'circle';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin type value object with validation
|
||||
* 带验证的引脚类型值对象
|
||||
*/
|
||||
export class PinType {
|
||||
private readonly _category: PinCategory;
|
||||
private readonly _subType?: string;
|
||||
private readonly _isArray: boolean;
|
||||
|
||||
constructor(category: PinCategory, subType?: string, isArray = false) {
|
||||
this._category = category;
|
||||
this._subType = subType;
|
||||
this._isArray = isArray;
|
||||
}
|
||||
|
||||
get category(): PinCategory {
|
||||
return this._category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtype for complex types like struct or enum
|
||||
* 复杂类型(如结构体或枚举)的子类型
|
||||
*/
|
||||
get subType(): string | undefined {
|
||||
return this._subType;
|
||||
}
|
||||
|
||||
get isArray(): boolean {
|
||||
return this._isArray;
|
||||
}
|
||||
|
||||
get shape(): PinShape {
|
||||
if (this._isArray) return 'diamond';
|
||||
return getDefaultPinShape(this._category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this type can connect to another type
|
||||
* 检查此类型是否可以连接到另一个类型
|
||||
*/
|
||||
canConnectTo(other: PinType): boolean {
|
||||
// Any type can connect to anything
|
||||
// any 类型可以连接任何类型
|
||||
if (this._category === 'any' || other._category === 'any') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exec pins can only connect to exec pins
|
||||
// exec 引脚只能连接 exec 引脚
|
||||
if (this._category === 'exec' || other._category === 'exec') {
|
||||
return this._category === other._category;
|
||||
}
|
||||
|
||||
// Same category can connect
|
||||
// 相同类型可以连接
|
||||
if (this._category === other._category) {
|
||||
// For struct/enum, subtype must match
|
||||
// 对于结构体/枚举,子类型必须匹配
|
||||
if (this._category === 'struct' || this._category === 'enum') {
|
||||
return this._subType === other._subType;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Numeric type coercion (数值类型转换)
|
||||
const numericTypes: PinCategory[] = ['int', 'float'];
|
||||
if (numericTypes.includes(this._category) && numericTypes.includes(other._category)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vector type coercion (向量类型转换)
|
||||
const vectorTypes: PinCategory[] = ['vector2', 'vector3', 'vector4', 'color'];
|
||||
if (vectorTypes.includes(this._category) && vectorTypes.includes(other._category)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
equals(other: PinType): boolean {
|
||||
return (
|
||||
this._category === other._category &&
|
||||
this._subType === other._subType &&
|
||||
this._isArray === other._isArray
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): { category: PinCategory; subType?: string; isArray: boolean } {
|
||||
return {
|
||||
category: this._category,
|
||||
subType: this._subType,
|
||||
isArray: this._isArray
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: { category: PinCategory; subType?: string; isArray?: boolean }): PinType {
|
||||
return new PinType(json.category, json.subType, json.isArray ?? false);
|
||||
}
|
||||
|
||||
// Common type constants (常用类型常量)
|
||||
static readonly EXEC = new PinType('exec');
|
||||
static readonly BOOL = new PinType('bool');
|
||||
static readonly INT = new PinType('int');
|
||||
static readonly FLOAT = new PinType('float');
|
||||
static readonly STRING = new PinType('string');
|
||||
static readonly VECTOR2 = new PinType('vector2');
|
||||
static readonly VECTOR3 = new PinType('vector3');
|
||||
static readonly VECTOR4 = new PinType('vector4');
|
||||
static readonly COLOR = new PinType('color');
|
||||
static readonly OBJECT = new PinType('object');
|
||||
static readonly ANY = new PinType('any');
|
||||
}
|
||||
93
packages/node-editor/src/domain/value-objects/Position.ts
Normal file
93
packages/node-editor/src/domain/value-objects/Position.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Position - Immutable 2D position value object
|
||||
* 位置 - 不可变的二维位置值对象
|
||||
*/
|
||||
export class Position {
|
||||
private readonly _x: number;
|
||||
private readonly _y: number;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
}
|
||||
|
||||
get x(): number {
|
||||
return this._x;
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this._y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Position by adding offset
|
||||
* 通过添加偏移量创建新的位置
|
||||
*/
|
||||
add(offset: Position): Position {
|
||||
return new Position(this._x + offset._x, this._y + offset._y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Position by subtracting another position
|
||||
* 通过减去另一个位置创建新的位置
|
||||
*/
|
||||
subtract(other: Position): Position {
|
||||
return new Position(this._x - other._x, this._y - other._y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Position by scaling
|
||||
* 通过缩放创建新的位置
|
||||
*/
|
||||
scale(factor: number): Position {
|
||||
return new Position(this._x * factor, this._y * factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates distance to another position
|
||||
* 计算到另一个位置的距离
|
||||
*/
|
||||
distanceTo(other: Position): number {
|
||||
const dx = this._x - other._x;
|
||||
const dy = this._y - other._y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks equality with another position
|
||||
* 检查与另一个位置是否相等
|
||||
*/
|
||||
equals(other: Position): boolean {
|
||||
return this._x === other._x && this._y === other._y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of this position
|
||||
* 创建此位置的副本
|
||||
*/
|
||||
clone(): Position {
|
||||
return new Position(this._x, this._y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts to plain object for serialization
|
||||
* 转换为普通对象用于序列化
|
||||
*/
|
||||
toJSON(): { x: number; y: number } {
|
||||
return { x: this._x, y: this._y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Position from plain object
|
||||
* 从普通对象创建位置
|
||||
*/
|
||||
static fromJSON(json: { x: number; y: number }): Position {
|
||||
return new Position(json.x, json.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero position constant
|
||||
* 零位置常量
|
||||
*/
|
||||
static readonly ZERO = new Position(0, 0);
|
||||
}
|
||||
8
packages/node-editor/src/domain/value-objects/index.ts
Normal file
8
packages/node-editor/src/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Position } from './Position';
|
||||
export {
|
||||
PinType,
|
||||
type PinDirection,
|
||||
type PinCategory,
|
||||
type PinShape,
|
||||
getDefaultPinShape
|
||||
} from './PinType';
|
||||
70
packages/node-editor/src/index.ts
Normal file
70
packages/node-editor/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @esengine/node-editor
|
||||
*
|
||||
* Universal node-based visual editor for blueprint, shader graph, and state machine.
|
||||
* 通用节点式可视化编辑器,用于蓝图、着色器图和状态机
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Import styles (导入样式)
|
||||
import './styles/index.css';
|
||||
|
||||
// Domain models (领域模型)
|
||||
export {
|
||||
// Models
|
||||
Graph,
|
||||
GraphNode,
|
||||
Pin,
|
||||
Connection,
|
||||
// Types
|
||||
type NodeTemplate,
|
||||
type NodeCategory,
|
||||
type PinDefinition
|
||||
} from './domain/models';
|
||||
|
||||
// Value objects (值对象)
|
||||
export {
|
||||
Position,
|
||||
PinType,
|
||||
type PinDirection,
|
||||
type PinCategory,
|
||||
type PinShape,
|
||||
getDefaultPinShape
|
||||
} from './domain/value-objects';
|
||||
|
||||
// Components (组件)
|
||||
export {
|
||||
// Main editor component
|
||||
NodeEditor,
|
||||
type NodeEditorProps,
|
||||
type NodeExecutionStates,
|
||||
// Canvas components
|
||||
GraphCanvas,
|
||||
useCanvasTransform,
|
||||
type GraphCanvasProps,
|
||||
type CanvasTransform,
|
||||
// Node components
|
||||
GraphNodeComponent,
|
||||
MemoizedGraphNodeComponent,
|
||||
type GraphNodeComponentProps,
|
||||
type NodeExecutionState,
|
||||
// Pin components
|
||||
NodePin,
|
||||
PinRow,
|
||||
type NodePinProps,
|
||||
type PinRowProps,
|
||||
// Connection components
|
||||
ConnectionLine,
|
||||
ConnectionPreview,
|
||||
ConnectionLayer,
|
||||
type ConnectionLineProps,
|
||||
type ConnectionPreviewProps,
|
||||
type ConnectionLayerProps,
|
||||
// Menu components
|
||||
NodeContextMenu,
|
||||
type NodeContextMenuProps,
|
||||
// Dialog components
|
||||
ConfirmDialog,
|
||||
type ConfirmDialogProps
|
||||
} from './components';
|
||||
262
packages/node-editor/src/styles/Canvas.css
Normal file
262
packages/node-editor/src/styles/Canvas.css
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Canvas Styles
|
||||
* 画布样式
|
||||
*/
|
||||
|
||||
/* ==================== Canvas Container 画布容器 ==================== */
|
||||
.ne-canvas {
|
||||
background: var(--ne-canvas-bg);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.ne-canvas:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ==================== Canvas Content 画布内容 ==================== */
|
||||
.ne-canvas-content {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ==================== Canvas Grid 画布网格 ==================== */
|
||||
.ne-canvas-grid {
|
||||
z-index: var(--ne-z-grid);
|
||||
}
|
||||
|
||||
/* ==================== Selection Box 选择框 ==================== */
|
||||
.ne-selection-box {
|
||||
position: absolute;
|
||||
border: 1px dashed var(--ne-node-border-selected);
|
||||
background: rgba(0, 120, 212, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: var(--ne-z-dragging);
|
||||
}
|
||||
|
||||
/* ==================== Minimap 小地图 ==================== */
|
||||
.ne-minimap {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid var(--ne-node-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
z-index: var(--ne-z-menu);
|
||||
}
|
||||
|
||||
.ne-minimap-viewport {
|
||||
position: absolute;
|
||||
border: 2px solid var(--ne-node-border-selected);
|
||||
background: rgba(0, 120, 212, 0.2);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.ne-minimap-node {
|
||||
position: absolute;
|
||||
background: var(--ne-node-bg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ==================== Zoom Controls 缩放控制 ==================== */
|
||||
.ne-zoom-controls {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: var(--ne-z-menu);
|
||||
}
|
||||
|
||||
.ne-zoom-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ne-node-border);
|
||||
background: var(--ne-node-bg);
|
||||
color: var(--ne-text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background var(--ne-transition-fast),
|
||||
border-color var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-zoom-btn:hover {
|
||||
background: var(--ne-node-bg-hover);
|
||||
border-color: var(--ne-node-border-selected);
|
||||
}
|
||||
|
||||
.ne-zoom-btn:active {
|
||||
background: var(--ne-node-bg-selected);
|
||||
}
|
||||
|
||||
.ne-zoom-level {
|
||||
padding: 4px 8px;
|
||||
font-size: var(--ne-font-size-small);
|
||||
color: var(--ne-text-secondary);
|
||||
text-align: center;
|
||||
background: var(--ne-node-bg);
|
||||
border: 1px solid var(--ne-node-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==================== Context Menu 右键菜单 ==================== */
|
||||
.ne-context-menu {
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
background: var(--ne-node-bg);
|
||||
border: 1px solid var(--ne-node-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
z-index: var(--ne-z-menu);
|
||||
font-family: var(--ne-font-family);
|
||||
font-size: var(--ne-font-size-body);
|
||||
}
|
||||
|
||||
.ne-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--ne-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-context-menu-item:hover {
|
||||
background: var(--ne-node-bg-hover);
|
||||
}
|
||||
|
||||
.ne-context-menu-item.disabled {
|
||||
color: var(--ne-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ne-context-menu-item.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-shortcut {
|
||||
color: var(--ne-text-muted);
|
||||
font-size: var(--ne-font-size-small);
|
||||
}
|
||||
|
||||
.ne-context-menu-separator {
|
||||
height: 1px;
|
||||
background: var(--ne-node-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.ne-context-menu-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ne-context-menu-submenu::after {
|
||||
content: '▶';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--ne-text-muted);
|
||||
}
|
||||
|
||||
/* ==================== Search Popup 搜索弹窗 ==================== */
|
||||
.ne-search-popup {
|
||||
position: fixed;
|
||||
width: 300px;
|
||||
max-height: 400px;
|
||||
background: var(--ne-node-bg);
|
||||
border: 1px solid var(--ne-node-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: var(--ne-z-menu);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ne-search-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--ne-node-border);
|
||||
background: transparent;
|
||||
color: var(--ne-text-primary);
|
||||
font-size: var(--ne-font-size-title);
|
||||
font-family: var(--ne-font-family);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ne-search-input::placeholder {
|
||||
color: var(--ne-text-muted);
|
||||
}
|
||||
|
||||
.ne-search-results {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ne-search-category {
|
||||
padding: 8px 16px 4px;
|
||||
color: var(--ne-text-muted);
|
||||
font-size: var(--ne-font-size-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ne-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-search-result:hover,
|
||||
.ne-search-result.selected {
|
||||
background: var(--ne-node-bg-hover);
|
||||
}
|
||||
|
||||
.ne-search-result-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ne-search-result-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ne-search-result-name {
|
||||
color: var(--ne-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ne-search-result-path {
|
||||
color: var(--ne-text-muted);
|
||||
font-size: var(--ne-font-size-small);
|
||||
}
|
||||
|
||||
.ne-search-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--ne-text-muted);
|
||||
}
|
||||
179
packages/node-editor/src/styles/Connection.css
Normal file
179
packages/node-editor/src/styles/Connection.css
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Connection Line Styles
|
||||
* 连接线样式
|
||||
*/
|
||||
|
||||
/* ==================== Connection Layer 连接层 ==================== */
|
||||
.ne-connection-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: var(--ne-z-connections);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ==================== Connection Path 连接路径 ==================== */
|
||||
.ne-connection {
|
||||
fill: none;
|
||||
stroke-width: var(--ne-connection-width);
|
||||
stroke-linecap: round;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
transition: stroke-width var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-connection:hover {
|
||||
stroke-width: var(--ne-connection-width-hover);
|
||||
}
|
||||
|
||||
.ne-connection.selected {
|
||||
stroke: var(--ne-connection-selected);
|
||||
stroke-width: var(--ne-connection-width-hover);
|
||||
filter: drop-shadow(0 0 4px var(--ne-connection-selected));
|
||||
}
|
||||
|
||||
/* ==================== Connection Type Colors 连接类型颜色 ==================== */
|
||||
.ne-connection.exec {
|
||||
stroke: var(--ne-pin-exec);
|
||||
stroke-width: var(--ne-connection-width-exec);
|
||||
}
|
||||
|
||||
.ne-connection.bool { stroke: var(--ne-pin-bool); }
|
||||
.ne-connection.int { stroke: var(--ne-pin-int); }
|
||||
.ne-connection.float { stroke: var(--ne-pin-float); }
|
||||
.ne-connection.string { stroke: var(--ne-pin-string); }
|
||||
.ne-connection.vector2 { stroke: var(--ne-pin-vector2); }
|
||||
.ne-connection.vector3 { stroke: var(--ne-pin-vector3); }
|
||||
.ne-connection.vector4 { stroke: var(--ne-pin-vector4); }
|
||||
.ne-connection.color { stroke: var(--ne-pin-color); }
|
||||
.ne-connection.object { stroke: var(--ne-pin-object); }
|
||||
.ne-connection.array { stroke: var(--ne-pin-array); }
|
||||
.ne-connection.map { stroke: var(--ne-pin-map); }
|
||||
.ne-connection.struct { stroke: var(--ne-pin-struct); }
|
||||
.ne-connection.enum { stroke: var(--ne-pin-enum); }
|
||||
.ne-connection.delegate { stroke: var(--ne-pin-delegate); }
|
||||
.ne-connection.any { stroke: var(--ne-pin-any); }
|
||||
|
||||
/* ==================== Connection Preview 连接预览 ==================== */
|
||||
.ne-connection-preview {
|
||||
fill: none;
|
||||
stroke: var(--ne-connection-preview);
|
||||
stroke-width: 3px;
|
||||
stroke-dasharray: 8 4;
|
||||
pointer-events: none;
|
||||
animation: ne-connection-dash 0.3s linear infinite;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
}
|
||||
|
||||
.ne-connection-preview.valid {
|
||||
stroke: var(--ne-state-success);
|
||||
stroke-dasharray: none;
|
||||
stroke-width: 3px;
|
||||
filter: drop-shadow(0 0 8px var(--ne-state-success));
|
||||
}
|
||||
|
||||
.ne-connection-preview.invalid {
|
||||
stroke: var(--ne-connection-invalid);
|
||||
filter: drop-shadow(0 0 6px var(--ne-connection-invalid));
|
||||
}
|
||||
|
||||
/* Preview type colors */
|
||||
.ne-connection-preview.exec { stroke: var(--ne-pin-exec); }
|
||||
.ne-connection-preview.bool { stroke: var(--ne-pin-bool); }
|
||||
.ne-connection-preview.int { stroke: var(--ne-pin-int); }
|
||||
.ne-connection-preview.float { stroke: var(--ne-pin-float); }
|
||||
.ne-connection-preview.string { stroke: var(--ne-pin-string); }
|
||||
.ne-connection-preview.vector2 { stroke: var(--ne-pin-vector2); }
|
||||
.ne-connection-preview.vector3 { stroke: var(--ne-pin-vector3); }
|
||||
.ne-connection-preview.vector4 { stroke: var(--ne-pin-vector4); }
|
||||
.ne-connection-preview.color { stroke: var(--ne-pin-color); }
|
||||
.ne-connection-preview.object { stroke: var(--ne-pin-object); }
|
||||
.ne-connection-preview.array { stroke: var(--ne-pin-array); }
|
||||
.ne-connection-preview.map { stroke: var(--ne-pin-map); }
|
||||
.ne-connection-preview.struct { stroke: var(--ne-pin-struct); }
|
||||
.ne-connection-preview.enum { stroke: var(--ne-pin-enum); }
|
||||
.ne-connection-preview.delegate { stroke: var(--ne-pin-delegate); }
|
||||
.ne-connection-preview.any { stroke: var(--ne-pin-any); }
|
||||
|
||||
@keyframes ne-connection-dash {
|
||||
to {
|
||||
stroke-dashoffset: -12;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Connection Hit Area 连接点击区域 ==================== */
|
||||
.ne-connection-hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 20px;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ne-connection-hit:hover + .ne-connection-glow + .ne-connection {
|
||||
stroke-width: var(--ne-connection-width-hover);
|
||||
}
|
||||
|
||||
/* ==================== Connection Glow 连接发光 ==================== */
|
||||
.ne-connection-glow {
|
||||
fill: none;
|
||||
stroke-width: 8px;
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-connection-glow.exec { stroke: var(--ne-pin-exec); }
|
||||
.ne-connection-glow.bool { stroke: var(--ne-pin-bool); }
|
||||
.ne-connection-glow.int { stroke: var(--ne-pin-int); }
|
||||
.ne-connection-glow.float { stroke: var(--ne-pin-float); }
|
||||
.ne-connection-glow.string { stroke: var(--ne-pin-string); }
|
||||
.ne-connection-glow.vector2 { stroke: var(--ne-pin-vector2); }
|
||||
.ne-connection-glow.vector3 { stroke: var(--ne-pin-vector3); }
|
||||
.ne-connection-glow.vector4 { stroke: var(--ne-pin-vector4); }
|
||||
.ne-connection-glow.color { stroke: var(--ne-pin-color); }
|
||||
.ne-connection-glow.object { stroke: var(--ne-pin-object); }
|
||||
.ne-connection-glow.array { stroke: var(--ne-pin-array); }
|
||||
.ne-connection-glow.map { stroke: var(--ne-pin-map); }
|
||||
.ne-connection-glow.struct { stroke: var(--ne-pin-struct); }
|
||||
.ne-connection-glow.enum { stroke: var(--ne-pin-enum); }
|
||||
.ne-connection-glow.delegate { stroke: var(--ne-pin-delegate); }
|
||||
.ne-connection-glow.any { stroke: var(--ne-pin-any); }
|
||||
|
||||
.ne-connection-hit:hover + .ne-connection-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ==================== Connection Flow Animation 连接流动动画 ==================== */
|
||||
.ne-connection.exec.animated {
|
||||
stroke-dasharray: 4 8;
|
||||
animation: ne-exec-flow 0.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ne-exec-flow {
|
||||
to {
|
||||
stroke-dashoffset: -12;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Reroute Node 重路由节点 ==================== */
|
||||
.ne-reroute {
|
||||
fill: var(--ne-node-bg);
|
||||
stroke: currentColor;
|
||||
stroke-width: 2px;
|
||||
cursor: move;
|
||||
transition: transform var(--ne-transition-fast);
|
||||
}
|
||||
|
||||
.ne-reroute:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.ne-reroute.selected {
|
||||
stroke: var(--ne-connection-selected);
|
||||
filter: drop-shadow(0 0 4px var(--ne-connection-selected));
|
||||
}
|
||||
290
packages/node-editor/src/styles/ContextMenu.css
Normal file
290
packages/node-editor/src/styles/ContextMenu.css
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Context Menu Styles
|
||||
* 上下文菜单样式
|
||||
*/
|
||||
|
||||
/* ==================== Menu Container 菜单容器 ==================== */
|
||||
.ne-context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--ne-z-menu);
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
max-height: 500px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
font-family: var(--ne-font-family);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ==================== Menu Header 菜单头部 ==================== */
|
||||
.ne-context-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.ne-context-menu-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
.ne-context-menu-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ne-context-menu-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #909090;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ne-context-menu-checkbox input {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== Search Box 搜索框 ==================== */
|
||||
.ne-context-menu-search {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #303030;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 28px 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--ne-font-family);
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 2px;
|
||||
color: #e0e0e0;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-input::placeholder {
|
||||
color: #606060;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-input:focus {
|
||||
border-color: #5080c0;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #606060;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ne-context-menu-search-clear:hover {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* ==================== Content Area 内容区域 ==================== */
|
||||
.ne-context-menu-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ne-context-menu-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ne-context-menu-content::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.ne-context-menu-content::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ne-context-menu-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #505050;
|
||||
}
|
||||
|
||||
/* ==================== Category Section 分类区域 ==================== */
|
||||
.ne-context-menu-category {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ne-context-menu-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #808080;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ne-context-menu-category-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.ne-context-menu-category-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.ne-context-menu-category.expanded .ne-context-menu-category-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ne-context-menu-category-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ne-context-menu-category-items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ne-context-menu-category.expanded .ne-context-menu-category-items {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==================== Menu Item 菜单项 ==================== */
|
||||
.ne-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 4px 24px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.ne-context-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ne-context-menu-item.highlighted {
|
||||
background: #264f78;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-icon-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-icon-dot.event { background: var(--ne-category-event); }
|
||||
.ne-context-menu-item-icon-dot.function { background: var(--ne-category-function); }
|
||||
.ne-context-menu-item-icon-dot.pure { background: var(--ne-category-pure); }
|
||||
.ne-context-menu-item-icon-dot.flow { background: var(--ne-category-flow); }
|
||||
.ne-context-menu-item-icon-dot.variable { background: var(--ne-category-variable); }
|
||||
.ne-context-menu-item-icon-dot.literal { background: var(--ne-category-literal); }
|
||||
.ne-context-menu-item-icon-dot.comment { background: var(--ne-category-comment); }
|
||||
.ne-context-menu-item-icon-dot.custom { background: var(--ne-category-custom); }
|
||||
|
||||
/* Function icon - f */
|
||||
.ne-context-menu-item-icon-func {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: var(--ne-category-function);
|
||||
}
|
||||
|
||||
.ne-context-menu-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-title {
|
||||
font-size: 12px;
|
||||
color: #d0d0d0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-title .highlight {
|
||||
background: #4a6a2a;
|
||||
color: #90ff90;
|
||||
padding: 0 1px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.ne-context-menu-item-description {
|
||||
font-size: 10px;
|
||||
color: #707070;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ==================== Empty State 空状态 ==================== */
|
||||
.ne-context-menu-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #606060;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Footer 底部 ==================== */
|
||||
.ne-context-menu-footer {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid #303030;
|
||||
background: #1e1e1e;
|
||||
font-size: 10px;
|
||||
color: #606060;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ne-context-menu-footer kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
font-family: var(--ne-font-family-mono);
|
||||
font-size: 9px;
|
||||
background: #303030;
|
||||
border-radius: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
191
packages/node-editor/src/styles/Dialog.css
Normal file
191
packages/node-editor/src/styles/Dialog.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Dialog Styles
|
||||
* 对话框样式
|
||||
*/
|
||||
|
||||
/* ==================== Overlay 遮罩层 ==================== */
|
||||
.ne-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: var(--ne-z-dialog);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: ne-dialog-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes ne-dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Dialog Container 对话框容器 ==================== */
|
||||
.ne-dialog {
|
||||
min-width: 320px;
|
||||
max-width: 450px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
font-family: var(--ne-font-family);
|
||||
animation: ne-dialog-slide-in 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes ne-dialog-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Dialog Header 对话框头部 ==================== */
|
||||
.ne-dialog-header {
|
||||
padding: 12px 16px;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.ne-dialog-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Type-specific header colors */
|
||||
.ne-dialog-danger .ne-dialog-header {
|
||||
background: linear-gradient(90deg, #4a1a1a 0%, #252525 100%);
|
||||
}
|
||||
|
||||
.ne-dialog-warning .ne-dialog-header {
|
||||
background: linear-gradient(90deg, #4a3a1a 0%, #252525 100%);
|
||||
}
|
||||
|
||||
.ne-dialog-info .ne-dialog-header {
|
||||
background: linear-gradient(90deg, #1a3a4a 0%, #252525 100%);
|
||||
}
|
||||
|
||||
/* ==================== Dialog Body 对话框主体 ==================== */
|
||||
.ne-dialog-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ne-dialog-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #b0b0b0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ==================== Dialog Footer 对话框底部 ==================== */
|
||||
.ne-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #303030;
|
||||
}
|
||||
|
||||
/* ==================== Dialog Buttons 对话框按钮 ==================== */
|
||||
.ne-dialog-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--ne-font-family);
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ne-dialog-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-cancel {
|
||||
background: #2a2a2a;
|
||||
border-color: #404040;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-cancel:hover {
|
||||
background: #353535;
|
||||
border-color: #505050;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-cancel:active {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-confirm {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-danger {
|
||||
background: #8b2a2a;
|
||||
border-color: #a03030;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-danger:hover {
|
||||
background: #9b3535;
|
||||
border-color: #b04040;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-danger:active {
|
||||
background: #7b2525;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-danger:focus {
|
||||
box-shadow: 0 0 0 2px rgba(160, 48, 48, 0.4);
|
||||
}
|
||||
|
||||
.ne-dialog-btn-warning {
|
||||
background: #8b6a2a;
|
||||
border-color: #a08030;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-warning:hover {
|
||||
background: #9b7535;
|
||||
border-color: #b09040;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-warning:active {
|
||||
background: #7b5a25;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-warning:focus {
|
||||
box-shadow: 0 0 0 2px rgba(160, 128, 48, 0.4);
|
||||
}
|
||||
|
||||
.ne-dialog-btn-info {
|
||||
background: #2a5a8b;
|
||||
border-color: #3070a0;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-info:hover {
|
||||
background: #356a9b;
|
||||
border-color: #4080b0;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-info:active {
|
||||
background: #254a7b;
|
||||
}
|
||||
|
||||
.ne-dialog-btn-info:focus {
|
||||
box-shadow: 0 0 0 2px rgba(48, 112, 160, 0.4);
|
||||
}
|
||||
265
packages/node-editor/src/styles/GraphNode.css
Normal file
265
packages/node-editor/src/styles/GraphNode.css
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Graph Node Styles
|
||||
* 图节点样式
|
||||
*/
|
||||
|
||||
/* ==================== Node Container 节点容器 ==================== */
|
||||
.ne-node {
|
||||
position: absolute;
|
||||
min-width: var(--ne-node-min-width);
|
||||
max-width: var(--ne-node-max-width);
|
||||
background: var(--ne-node-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--ne-node-border);
|
||||
border-radius: var(--ne-node-border-radius);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
font-family: var(--ne-font-family);
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
transition: box-shadow var(--ne-transition-normal),
|
||||
border-color var(--ne-transition-normal);
|
||||
}
|
||||
|
||||
.ne-node:hover {
|
||||
border-color: rgba(80, 80, 100, 0.6);
|
||||
}
|
||||
|
||||
.ne-node.selected {
|
||||
border-color: var(--ne-node-border-selected);
|
||||
box-shadow: 0 0 0 1px var(--ne-node-border-selected),
|
||||
0 0 16px rgba(229, 160, 32, 0.4),
|
||||
0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.ne-node.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
z-index: var(--ne-z-dragging);
|
||||
}
|
||||
|
||||
/* ==================== Node Header 节点头部 ==================== */
|
||||
.ne-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
min-height: var(--ne-node-header-height);
|
||||
border-radius: calc(var(--ne-node-border-radius) - 1px) calc(var(--ne-node-border-radius) - 1px) 0 0;
|
||||
color: var(--ne-text-title);
|
||||
font-size: var(--ne-font-size-title);
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Gradient overlay for header */
|
||||
.ne-node-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.15;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Node category header colors */
|
||||
.ne-node-header.event {
|
||||
background: linear-gradient(90deg, var(--ne-category-event) 0%, var(--ne-category-event-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.function {
|
||||
background: linear-gradient(90deg, var(--ne-category-function) 0%, var(--ne-category-function-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.pure {
|
||||
background: linear-gradient(90deg, var(--ne-category-pure) 0%, var(--ne-category-pure-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.flow {
|
||||
background: linear-gradient(90deg, var(--ne-category-flow) 0%, var(--ne-category-flow-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.variable {
|
||||
background: linear-gradient(90deg, var(--ne-category-variable) 0%, var(--ne-category-variable-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.literal {
|
||||
background: linear-gradient(90deg, var(--ne-category-literal) 0%, var(--ne-category-literal-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.comment {
|
||||
background: linear-gradient(90deg, var(--ne-category-comment) 0%, var(--ne-category-comment-dark) 100%);
|
||||
}
|
||||
|
||||
.ne-node-header.custom {
|
||||
background: linear-gradient(90deg, var(--ne-category-custom) 0%, var(--ne-category-custom-dark) 100%);
|
||||
}
|
||||
|
||||
/* Header icon - diamond shape for events */
|
||||
.ne-node-header-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ne-node-header.event .ne-node-header-icon::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 1px;
|
||||
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ne-node-header-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.ne-node-header-subtitle {
|
||||
font-size: var(--ne-font-size-small);
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Header exec pin */
|
||||
.ne-node-header-exec {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: transparent;
|
||||
border: 2px solid #a04040;
|
||||
cursor: pointer;
|
||||
transition: all var(--ne-transition-fast);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ne-node-header-exec:hover {
|
||||
border-color: #ff6060;
|
||||
box-shadow: 0 0 6px rgba(255, 96, 96, 0.6);
|
||||
}
|
||||
|
||||
.ne-node-header-exec.connected {
|
||||
background: #c04040;
|
||||
border-color: #c04040;
|
||||
}
|
||||
|
||||
/* ==================== Node Body 节点主体 ==================== */
|
||||
.ne-node-body {
|
||||
padding: 6px 0;
|
||||
background: var(--ne-node-bg-body);
|
||||
border-radius: 0 0 calc(var(--ne-node-border-radius) - 1px) calc(var(--ne-node-border-radius) - 1px);
|
||||
}
|
||||
|
||||
.ne-node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* ==================== Pin Row 引脚行 ==================== */
|
||||
.ne-pin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
padding: 1px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ne-pin-row.input {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding-left: 10px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ne-pin-row.output {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
padding-right: 10px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.ne-pin-label {
|
||||
font-size: 11px;
|
||||
color: #b0b0b0;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ne-pin-row.output .ne-pin-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ==================== Collapsed State 折叠状态 ==================== */
|
||||
.ne-node.collapsed .ne-node-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ne-node.collapsed .ne-node-header {
|
||||
border-radius: calc(var(--ne-node-border-radius) - 1px);
|
||||
}
|
||||
|
||||
/* ==================== Comment Node 注释节点 ==================== */
|
||||
.ne-node.comment {
|
||||
background: rgba(50, 70, 50, 0.4);
|
||||
border: 1px dashed rgba(100, 140, 100, 0.6);
|
||||
min-width: 200px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.ne-node.comment .ne-node-header {
|
||||
background: transparent;
|
||||
color: #90a090;
|
||||
}
|
||||
|
||||
.ne-node.comment .ne-node-body {
|
||||
background: transparent;
|
||||
color: #80a080;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==================== Execution States 执行状态 ==================== */
|
||||
.ne-node.running {
|
||||
border-color: var(--ne-state-running);
|
||||
box-shadow: 0 0 0 1px var(--ne-state-running),
|
||||
0 0 12px rgba(255, 167, 38, 0.5);
|
||||
animation: ne-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ne-node.success {
|
||||
border-color: var(--ne-state-success);
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.ne-node.error {
|
||||
border-color: var(--ne-state-error);
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
@keyframes ne-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 1px var(--ne-state-running),
|
||||
0 0 12px rgba(255, 167, 38, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px var(--ne-state-running),
|
||||
0 0 20px rgba(255, 167, 38, 0.7);
|
||||
}
|
||||
}
|
||||
104
packages/node-editor/src/styles/NodePin.css
Normal file
104
packages/node-editor/src/styles/NodePin.css
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Node Pin Styles
|
||||
* 节点引脚样式
|
||||
*/
|
||||
|
||||
/* ==================== Pin Base 引脚基础 ==================== */
|
||||
.ne-pin {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ne-pin:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.ne-pin.compatible {
|
||||
animation: ne-pin-pulse 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==================== Pin Type Colors 引脚类型颜色 ==================== */
|
||||
.ne-pin.exec { color: var(--ne-pin-exec); }
|
||||
.ne-pin.bool { color: var(--ne-pin-bool); }
|
||||
.ne-pin.int { color: var(--ne-pin-int); }
|
||||
.ne-pin.float { color: var(--ne-pin-float); }
|
||||
.ne-pin.string { color: var(--ne-pin-string); }
|
||||
.ne-pin.vector2 { color: var(--ne-pin-vector2); }
|
||||
.ne-pin.vector3 { color: var(--ne-pin-vector3); }
|
||||
.ne-pin.vector4 { color: var(--ne-pin-vector4); }
|
||||
.ne-pin.color { color: var(--ne-pin-color); }
|
||||
.ne-pin.object { color: var(--ne-pin-object); }
|
||||
.ne-pin.array { color: var(--ne-pin-array); }
|
||||
.ne-pin.map { color: var(--ne-pin-map); }
|
||||
.ne-pin.struct { color: var(--ne-pin-struct); }
|
||||
.ne-pin.enum { color: var(--ne-pin-enum); }
|
||||
.ne-pin.delegate { color: var(--ne-pin-delegate); }
|
||||
.ne-pin.any { color: var(--ne-pin-any); }
|
||||
|
||||
/* ==================== Pin States 引脚状态 ==================== */
|
||||
.ne-pin.dragging-from {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.ne-pin.drop-target {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
.ne-pin.invalid-target {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ==================== Pin Animations 引脚动画 ==================== */
|
||||
@keyframes ne-pin-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Pin Value Input 引脚值输入 ==================== */
|
||||
.ne-pin-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ne-pin-value-input {
|
||||
width: 50px;
|
||||
padding: 2px 4px;
|
||||
font-size: 10px;
|
||||
font-family: var(--ne-font-family-mono);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ne-pin-value-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.ne-pin-value-checkbox {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
accent-color: var(--ne-pin-bool);
|
||||
}
|
||||
|
||||
.ne-pin-value-color {
|
||||
width: 20px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
12
packages/node-editor/src/styles/index.css
Normal file
12
packages/node-editor/src/styles/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Node Editor Styles Entry Point
|
||||
* 节点编辑器样式入口
|
||||
*/
|
||||
|
||||
@import './variables.css';
|
||||
@import './Canvas.css';
|
||||
@import './GraphNode.css';
|
||||
@import './NodePin.css';
|
||||
@import './Connection.css';
|
||||
@import './ContextMenu.css';
|
||||
@import './Dialog.css';
|
||||
136
packages/node-editor/src/styles/variables.css
Normal file
136
packages/node-editor/src/styles/variables.css
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Node Editor Theme Variables
|
||||
* 节点编辑器主题变量
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ==================== Background Colors 背景颜色 ==================== */
|
||||
--ne-canvas-bg: #141419;
|
||||
--ne-canvas-grid: #1a1a22;
|
||||
--ne-canvas-grid-major: #222230;
|
||||
|
||||
--ne-node-bg: rgba(12, 12, 16, 0.75);
|
||||
--ne-node-bg-body: rgba(8, 8, 12, 0.85);
|
||||
--ne-node-border: rgba(40, 40, 50, 0.6);
|
||||
--ne-node-border-selected: #e5a020;
|
||||
|
||||
/* ==================== Node Category Colors 节点类别颜色 ==================== */
|
||||
/* Event - Red gradient (like UE BeginOverlap) */
|
||||
--ne-category-event: #b81c1c;
|
||||
--ne-category-event-dark: #6b1010;
|
||||
|
||||
/* Function - Blue gradient */
|
||||
--ne-category-function: #1b6eb5;
|
||||
--ne-category-function-dark: #0d3d66;
|
||||
|
||||
/* Pure - Green/Teal gradient */
|
||||
--ne-category-pure: #3d8b3d;
|
||||
--ne-category-pure-dark: #1f5a1f;
|
||||
|
||||
/* Flow - Gray gradient */
|
||||
--ne-category-flow: #4a4a4a;
|
||||
--ne-category-flow-dark: #2a2a2a;
|
||||
|
||||
/* Variable - Purple gradient */
|
||||
--ne-category-variable: #7b3d9b;
|
||||
--ne-category-variable-dark: #4a1f66;
|
||||
|
||||
/* Literal - Gold/Orange gradient */
|
||||
--ne-category-literal: #9a7020;
|
||||
--ne-category-literal-dark: #5a4010;
|
||||
|
||||
/* Comment - Green gradient */
|
||||
--ne-category-comment: #3d6b3d;
|
||||
--ne-category-comment-dark: #2a4a2a;
|
||||
|
||||
/* Custom - Dark Gray gradient */
|
||||
--ne-category-custom: #3a3a3a;
|
||||
--ne-category-custom-dark: #1a1a1a;
|
||||
|
||||
/* ==================== Pin Type Colors 引脚类型颜色 ==================== */
|
||||
--ne-pin-exec: #ffffff;
|
||||
--ne-pin-bool: #8c0000;
|
||||
--ne-pin-int: #1cc4c4;
|
||||
--ne-pin-float: #7ecd32;
|
||||
--ne-pin-string: #e060e0;
|
||||
--ne-pin-vector2: #f0c030;
|
||||
--ne-pin-vector3: #f0c030;
|
||||
--ne-pin-vector4: #f0c030;
|
||||
--ne-pin-color: #e08030;
|
||||
--ne-pin-object: #00a0e0;
|
||||
--ne-pin-array: #7030c0;
|
||||
--ne-pin-map: #e060a0;
|
||||
--ne-pin-struct: #3060c0;
|
||||
--ne-pin-enum: #10a090;
|
||||
--ne-pin-delegate: #c01030;
|
||||
--ne-pin-any: #707070;
|
||||
|
||||
/* ==================== Connection Colors 连接线颜色 ==================== */
|
||||
--ne-connection-exec: #ffffff;
|
||||
--ne-connection-data: #888888;
|
||||
--ne-connection-hover: #00aaff;
|
||||
--ne-connection-selected: #ffcc00;
|
||||
--ne-connection-invalid: #ff4444;
|
||||
--ne-connection-preview: rgba(255, 255, 255, 0.5);
|
||||
|
||||
/* ==================== Text Colors 文字颜色 ==================== */
|
||||
--ne-text-primary: #c0c0c0;
|
||||
--ne-text-secondary: #909090;
|
||||
--ne-text-muted: #606060;
|
||||
--ne-text-title: #ffffff;
|
||||
--ne-text-subtitle: #cccccc;
|
||||
|
||||
/* ==================== State Colors 状态颜色 ==================== */
|
||||
--ne-state-success: #4caf50;
|
||||
--ne-state-warning: #ff9800;
|
||||
--ne-state-error: #f44336;
|
||||
--ne-state-info: #2196f3;
|
||||
--ne-state-running: #ffa726;
|
||||
|
||||
/* ==================== Shadow 阴影 ==================== */
|
||||
--ne-shadow-node: 0 4px 12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.8),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
--ne-shadow-node-hover: 0 6px 16px rgba(0, 0, 0, 0.6);
|
||||
--ne-shadow-node-selected: 0 0 0 2px var(--ne-node-border-selected),
|
||||
0 0 20px rgba(245, 166, 35, 0.3);
|
||||
--ne-shadow-pin: 0 0 4px rgba(0, 0, 0, 0.5);
|
||||
--ne-shadow-pin-hover: 0 0 8px currentColor;
|
||||
|
||||
/* ==================== Sizing 尺寸 ==================== */
|
||||
--ne-node-min-width: 200px;
|
||||
--ne-node-max-width: 350px;
|
||||
--ne-node-border-radius: 6px;
|
||||
--ne-node-header-height: 28px;
|
||||
--ne-node-padding: 8px 0;
|
||||
|
||||
--ne-pin-size: 10px;
|
||||
--ne-pin-size-exec: 12px;
|
||||
--ne-pin-spacing: 22px;
|
||||
--ne-pin-hit-area: 24px;
|
||||
|
||||
--ne-connection-width: 2px;
|
||||
--ne-connection-width-hover: 3px;
|
||||
--ne-connection-width-exec: 3px;
|
||||
|
||||
/* ==================== Typography 字体 ==================== */
|
||||
--ne-font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
--ne-font-family-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
--ne-font-size-title: 12px;
|
||||
--ne-font-size-body: 11px;
|
||||
--ne-font-size-small: 10px;
|
||||
|
||||
/* ==================== Animation 动画 ==================== */
|
||||
--ne-transition-fast: 0.1s ease;
|
||||
--ne-transition-normal: 0.15s ease;
|
||||
--ne-transition-slow: 0.3s ease;
|
||||
|
||||
/* ==================== Z-Index 层级 ==================== */
|
||||
--ne-z-grid: 0;
|
||||
--ne-z-connections: 1;
|
||||
--ne-z-nodes: 2;
|
||||
--ne-z-dragging: 100;
|
||||
--ne-z-menu: 1000;
|
||||
--ne-z-tooltip: 1001;
|
||||
--ne-z-dialog: 2000;
|
||||
}
|
||||
26
packages/node-editor/tsconfig.json
Normal file
26
packages/node-editor/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
87
packages/node-editor/vite.config.ts
Normal file
87
packages/node-editor/vite.config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* Custom plugin: Convert CSS to self-executing style injection code
|
||||
* 自定义插件:将 CSS 转换为自执行的样式注入代码
|
||||
*/
|
||||
function injectCSSPlugin(): any {
|
||||
let cssCounter = 0;
|
||||
|
||||
return {
|
||||
name: 'inject-css-plugin',
|
||||
enforce: 'post' as const,
|
||||
generateBundle(_options: any, bundle: any) {
|
||||
const bundleKeys = Object.keys(bundle);
|
||||
|
||||
// Find all CSS files (找到所有 CSS 文件)
|
||||
const cssFiles = bundleKeys.filter(key => key.endsWith('.css'));
|
||||
|
||||
for (const cssFile of cssFiles) {
|
||||
const cssChunk = bundle[cssFile];
|
||||
if (!cssChunk || !cssChunk.source) continue;
|
||||
|
||||
const cssContent = cssChunk.source;
|
||||
const styleId = `esengine-node-editor-style-${cssCounter++}`;
|
||||
|
||||
// Generate style injection code (生成样式注入代码)
|
||||
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${JSON.stringify(cssContent)};document.head.appendChild(s);}}})();`;
|
||||
|
||||
// Inject into index.js (注入到 index.js)
|
||||
for (const jsKey of bundleKeys) {
|
||||
if (!jsKey.endsWith('.js')) continue;
|
||||
const jsChunk = bundle[jsKey];
|
||||
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
|
||||
|
||||
if (jsKey === 'index.js') {
|
||||
jsChunk.code = injectCode + '\n' + jsChunk.code;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove standalone CSS file (删除独立的 CSS 文件)
|
||||
delete bundle[cssFile];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
include: ['src'],
|
||||
outDir: 'dist',
|
||||
rollupTypes: false
|
||||
}),
|
||||
injectCSSPlugin()
|
||||
],
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
index: resolve(__dirname, 'src/index.ts')
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: (format, entryName) => `${entryName}.js`
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'zustand',
|
||||
/^@esengine\//
|
||||
],
|
||||
output: {
|
||||
exports: 'named',
|
||||
preserveModules: false
|
||||
}
|
||||
},
|
||||
target: 'es2020',
|
||||
minify: false,
|
||||
sourcemap: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user