Refactor/clean architecture phase1 (#215)
* refactor(editor): 建立Clean Architecture领域模型层 * refactor(editor): 实现应用层架构 - 命令模式、用例和状态管理 * refactor(editor): 实现展示层核心Hooks * refactor(editor): 实现基础设施层和展示层组件 * refactor(editor): 迁移画布和连接渲染到 Clean Architecture 组件 * feat(editor): 集成应用层架构和命令模式,实现撤销/重做功能 * refactor(editor): UI组件拆分 * refactor(editor): 提取快速创建菜单逻辑 * refactor(editor): 重构BehaviorTreeEditor,提取组件和Hook * refactor(editor): 提取端口连接和键盘事件Hook * refactor(editor): 提取拖放处理Hook * refactor(editor): 提取画布交互Hook和工具函数 * refactor(editor): 完成核心重构 * fix(editor): 修复节点无法创建和连接 * refactor(behavior-tree,editor): 重构节点子节点约束系统,实现元数据驱动的架构
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import React, { useRef, useCallback, forwardRef } from 'react';
|
||||
import { useCanvasInteraction } from '../../../hooks/useCanvasInteraction';
|
||||
import { EditorConfig } from '../../../types';
|
||||
|
||||
/**
|
||||
* 画布组件属性
|
||||
*/
|
||||
interface BehaviorTreeCanvasProps {
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
config: EditorConfig;
|
||||
|
||||
/**
|
||||
* 子组件
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 画布点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标移动事件
|
||||
*/
|
||||
onMouseMove?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标抬起事件
|
||||
*/
|
||||
onMouseUp?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标离开事件
|
||||
*/
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖放事件
|
||||
*/
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动悬停事件
|
||||
*/
|
||||
onDragOver?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动进入事件
|
||||
*/
|
||||
onDragEnter?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动离开事件
|
||||
*/
|
||||
onDragLeave?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树画布组件
|
||||
* 负责画布的渲染、缩放、平移等基础功能
|
||||
*/
|
||||
export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasProps>(({
|
||||
config,
|
||||
children,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseMove,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave
|
||||
}, forwardedRef) => {
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = forwardedRef || internalRef;
|
||||
|
||||
const {
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning
|
||||
} = useCanvasInteraction();
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||
e.preventDefault();
|
||||
startPanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseDown?.(e);
|
||||
}, [startPanning, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
updatePanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseMove?.(e);
|
||||
}, [isPanning, updatePanning, onMouseMove]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
stopPanning();
|
||||
}
|
||||
|
||||
onMouseUp?.(e);
|
||||
}, [isPanning, stopPanning, onMouseUp]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e);
|
||||
}, [onContextMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="behavior-tree-canvas"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
cursor: isPanning ? 'grabbing' : 'default',
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
{/* 网格背景 */}
|
||||
{config.showGrid && (
|
||||
<div
|
||||
className="canvas-grid"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${config.gridSize * canvasScale}px ${config.gridSize * canvasScale}px`,
|
||||
backgroundPosition: `${canvasOffset.x}px ${canvasOffset.y}px`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容容器(应用变换) */}
|
||||
<div
|
||||
className="canvas-content"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';
|
||||
@@ -0,0 +1 @@
|
||||
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConnectionRenderer } from './ConnectionRenderer';
|
||||
import { ConnectionViewData } from '../../../types';
|
||||
import { Node } from '../../../../domain/models/Node';
|
||||
import { Connection } from '../../../../domain/models/Connection';
|
||||
|
||||
/**
|
||||
* 连线层属性
|
||||
*/
|
||||
interface ConnectionLayerProps {
|
||||
/**
|
||||
* 所有连接
|
||||
*/
|
||||
connections: Connection[];
|
||||
|
||||
/**
|
||||
* 所有节点(用于查找位置)
|
||||
*/
|
||||
nodes: Node[];
|
||||
|
||||
/**
|
||||
* 选中的连接
|
||||
*/
|
||||
selectedConnection?: { from: string; to: string } | null;
|
||||
|
||||
/**
|
||||
* 获取端口位置的函数
|
||||
*/
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
|
||||
/**
|
||||
* 连线点击事件
|
||||
*/
|
||||
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
|
||||
/**
|
||||
* 连线右键事件
|
||||
*/
|
||||
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连线层
|
||||
* 管理所有连线的渲染
|
||||
*/
|
||||
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
||||
connections,
|
||||
nodes,
|
||||
selectedConnection,
|
||||
getPortPosition,
|
||||
onConnectionClick,
|
||||
onConnectionContextMenu
|
||||
}) => {
|
||||
const nodeMap = useMemo(() => {
|
||||
return new Map(nodes.map((node) => [node.id, node]));
|
||||
}, [nodes]);
|
||||
|
||||
const connectionViewData = useMemo(() => {
|
||||
return connections
|
||||
.map((connection) => {
|
||||
const fromNode = nodeMap.get(connection.from);
|
||||
const toNode = nodeMap.get(connection.to);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selectedConnection?.from === connection.from &&
|
||||
selectedConnection?.to === connection.to;
|
||||
|
||||
const viewData: ConnectionViewData = {
|
||||
connection,
|
||||
isSelected
|
||||
};
|
||||
|
||||
return { viewData, fromNode, toNode };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [connections, nodeMap, selectedConnection]);
|
||||
|
||||
if (connectionViewData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="connection-layer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<g style={{ pointerEvents: 'auto' }}>
|
||||
{connectionViewData.map(({ viewData, fromNode, toNode }) => (
|
||||
<ConnectionRenderer
|
||||
key={`${viewData.connection.from}-${viewData.connection.to}`}
|
||||
connectionData={viewData}
|
||||
fromNode={fromNode}
|
||||
toNode={toNode}
|
||||
getPortPosition={getPortPosition}
|
||||
onClick={onConnectionClick}
|
||||
onContextMenu={onConnectionContextMenu}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ConnectionViewData } from '../../../types';
|
||||
import { Node } from '../../../../domain/models/Node';
|
||||
|
||||
/**
|
||||
* 连线渲染器属性
|
||||
*/
|
||||
interface ConnectionRendererProps {
|
||||
/**
|
||||
* 连接视图数据
|
||||
*/
|
||||
connectionData: ConnectionViewData;
|
||||
|
||||
/**
|
||||
* 源节点
|
||||
*/
|
||||
fromNode: Node;
|
||||
|
||||
/**
|
||||
* 目标节点
|
||||
*/
|
||||
toNode: Node;
|
||||
|
||||
/**
|
||||
* 获取端口位置的函数
|
||||
*/
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
|
||||
/**
|
||||
* 连线点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
|
||||
/**
|
||||
* 连线右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连线渲染器
|
||||
* 使用贝塞尔曲线渲染节点间的连接
|
||||
*/
|
||||
export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
||||
connectionData,
|
||||
fromNode,
|
||||
toNode,
|
||||
getPortPosition,
|
||||
onClick,
|
||||
onContextMenu
|
||||
}) => {
|
||||
const { connection, isSelected } = connectionData;
|
||||
|
||||
const pathData = useMemo(() => {
|
||||
let fromPos, toPos;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
// 属性连接:从DOM获取实际引脚位置
|
||||
fromPos = getPortPosition(connection.from);
|
||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||
} else {
|
||||
// 节点连接:使用DOM获取端口位置
|
||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||
}
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
// 如果DOM还没渲染,返回null
|
||||
return null;
|
||||
}
|
||||
|
||||
const x1 = fromPos.x;
|
||||
const y1 = fromPos.y;
|
||||
const x2 = toPos.x;
|
||||
const y2 = toPos.y;
|
||||
|
||||
let pathD: string;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
// 属性连接使用水平贝塞尔曲线
|
||||
const controlX1 = x1 + (x2 - x1) * 0.5;
|
||||
const controlX2 = x1 + (x2 - x1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
||||
} else {
|
||||
// 节点连接使用垂直贝塞尔曲线
|
||||
const controlY = y1 + (y2 - y1) * 0.5;
|
||||
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
return {
|
||||
path: pathD,
|
||||
midX: (x1 + x2) / 2,
|
||||
midY: (y1 + y2) / 2
|
||||
};
|
||||
}, [connection, fromNode, toNode, getPortPosition]);
|
||||
|
||||
const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
|
||||
const strokeColor = isSelected ? '#FFD700' : color;
|
||||
const strokeWidth = isSelected ? 4 : 2;
|
||||
|
||||
if (!pathData) {
|
||||
// DOM还没渲染完成,跳过此连接
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
className="connection"
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-connection-from={connection.from}
|
||||
data-connection-to={connection.to}
|
||||
>
|
||||
{/* 透明的宽线条,用于更容易点击 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={20}
|
||||
/>
|
||||
|
||||
{/* 实际显示的线条 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
|
||||
{/* 箭头标记 */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3, 0 6"
|
||||
fill={strokeColor}
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* 选中时显示的中点 */}
|
||||
{isSelected && (
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="5"
|
||||
fill={strokeColor}
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ConnectionRenderer } from './ConnectionRenderer';
|
||||
export { ConnectionLayer } from './ConnectionLayer';
|
||||
@@ -0,0 +1,319 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TreePine,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
|
||||
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface BehaviorTreeNodeProps {
|
||||
node: BehaviorTreeNodeType;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
dragDelta: { dx: number; dy: number };
|
||||
uncommittedNodeIds: Set<string>;
|
||||
blackboardVariables: BlackboardVariables;
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
isExecuting: boolean;
|
||||
connections: Connection[];
|
||||
nodes: BehaviorTreeNodeType[];
|
||||
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
draggingNodeId: string | null;
|
||||
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
}
|
||||
|
||||
export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
node,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
dragDelta,
|
||||
uncommittedNodeIds,
|
||||
blackboardVariables,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
connections,
|
||||
nodes,
|
||||
executorRef,
|
||||
iconMap,
|
||||
draggingNodeId,
|
||||
onNodeClick,
|
||||
onContextMenu,
|
||||
onNodeMouseDown,
|
||||
onNodeMouseUpForConnection,
|
||||
onPortMouseDown,
|
||||
onPortMouseUp
|
||||
}) => {
|
||||
const isRoot = node.id === ROOT_NODE_ID;
|
||||
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
|
||||
|
||||
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
|
||||
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
|
||||
|
||||
const isUncommitted = uncommittedNodeIds.has(node.id);
|
||||
const nodeClasses = [
|
||||
'bt-node',
|
||||
isSelected && 'selected',
|
||||
isRoot && 'root',
|
||||
isUncommitted && 'uncommitted'
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-node-id={node.id}
|
||||
className={nodeClasses}
|
||||
onClick={(e) => onNodeClick(e, node)}
|
||||
onContextMenu={(e) => onContextMenu(e, node)}
|
||||
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
|
||||
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
|
||||
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
||||
}}
|
||||
>
|
||||
{isBlackboardVariable ? (
|
||||
(() => {
|
||||
const varName = node.data.variableName as string;
|
||||
const currentValue = blackboardVariables[varName];
|
||||
const initialValue = initialBlackboardVariables[varName];
|
||||
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bt-node-header blackboard">
|
||||
<Database size={16} className="bt-node-header-icon" />
|
||||
<div className="bt-node-header-title">
|
||||
{varName || 'Variable'}
|
||||
</div>
|
||||
{isModified && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.2)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
运行时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bt-node-body">
|
||||
<div
|
||||
className="bt-node-blackboard-value"
|
||||
style={{
|
||||
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
|
||||
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 4px'
|
||||
}}
|
||||
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
|
||||
>
|
||||
{JSON.stringify(currentValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="variable-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
||||
className="bt-node-port bt-node-port-variable-output"
|
||||
title="Output"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
|
||||
{isRoot ? (
|
||||
<TreePine size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
node.template.icon && (() => {
|
||||
const IconComponent = iconMap[node.template.icon];
|
||||
return IconComponent ? (
|
||||
<IconComponent size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
<span className="bt-node-header-icon">{node.template.icon}</span>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<div className="bt-node-header-title">
|
||||
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
|
||||
<div className="bt-node-id" title={node.id}>
|
||||
#{node.id}
|
||||
</div>
|
||||
</div>
|
||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||
<div
|
||||
className="bt-node-missing-executor-warning"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertCircle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#f44336',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-missing-executor-tooltip">
|
||||
缺失执行器:找不到节点对应的执行器 "{node.template.className}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isUncommitted && (
|
||||
<div
|
||||
className="bt-node-uncommitted-warning"
|
||||
style={{
|
||||
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff5722',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-uncommitted-tooltip">
|
||||
未生效节点:运行时添加的节点,需重新运行才能生效
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
|
||||
!nodes.some((n) =>
|
||||
connections.some((c) => c.from === node.id && c.to === n.id)
|
||||
) && (
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
style={{
|
||||
marginLeft: isUncommitted ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff9800',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-empty-warning-tooltip">
|
||||
空节点:没有子节点,执行时会直接跳过
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bt-node-body">
|
||||
{!isRoot && (
|
||||
<div className="bt-node-category">
|
||||
{node.template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.template.properties.length > 0 && (
|
||||
<div className="bt-node-properties">
|
||||
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
|
||||
const hasConnection = connections.some(
|
||||
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
|
||||
);
|
||||
const propValue = node.data[prop.name];
|
||||
|
||||
return (
|
||||
<div key={idx} className="bt-node-property">
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-property={prop.name}
|
||||
data-port-type="property-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
|
||||
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
|
||||
title={prop.description || prop.name}
|
||||
/>
|
||||
<span
|
||||
className="bt-node-property-label"
|
||||
title={prop.description}
|
||||
>
|
||||
{prop.name}:
|
||||
</span>
|
||||
{propValue !== undefined && (
|
||||
<span className="bt-node-property-value">
|
||||
{String(propValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-input"
|
||||
title="Input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-output"
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NodeViewData } from '../../../types';
|
||||
|
||||
/**
|
||||
* 图标映射
|
||||
*/
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
TreePine: LucideIcons.TreePine,
|
||||
GitBranch: LucideIcons.GitBranch,
|
||||
Shuffle: LucideIcons.Shuffle,
|
||||
Repeat: LucideIcons.Repeat,
|
||||
RotateCcw: LucideIcons.RotateCcw,
|
||||
FlipHorizontal: LucideIcons.FlipHorizontal,
|
||||
CheckCircle: LucideIcons.CheckCircle,
|
||||
XCircle: LucideIcons.XCircle,
|
||||
Play: LucideIcons.Play,
|
||||
Pause: LucideIcons.Pause,
|
||||
Square: LucideIcons.Square,
|
||||
Circle: LucideIcons.Circle,
|
||||
Diamond: LucideIcons.Diamond,
|
||||
Box: LucideIcons.Box,
|
||||
Flag: LucideIcons.Flag,
|
||||
Target: LucideIcons.Target
|
||||
};
|
||||
|
||||
/**
|
||||
* 节点渲染器属性
|
||||
*/
|
||||
interface BehaviorTreeNodeRendererProps {
|
||||
/**
|
||||
* 节点视图数据
|
||||
*/
|
||||
nodeData: NodeViewData;
|
||||
|
||||
/**
|
||||
* 节点点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树节点渲染器
|
||||
* 负责单个节点的渲染
|
||||
*/
|
||||
export const BehaviorTreeNodeRenderer: React.FC<BehaviorTreeNodeRendererProps> = ({
|
||||
nodeData,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseDown
|
||||
}) => {
|
||||
const { node, isSelected, isDragging, executionStatus } = nodeData;
|
||||
const { template, position } = node;
|
||||
|
||||
const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box;
|
||||
|
||||
const nodeStyle = useMemo(() => {
|
||||
let borderColor = template.color || '#4a9eff';
|
||||
const backgroundColor = '#2a2a2a';
|
||||
let boxShadow = 'none';
|
||||
|
||||
if (isSelected) {
|
||||
boxShadow = `0 0 0 2px ${borderColor}`;
|
||||
}
|
||||
|
||||
if (executionStatus === 'running') {
|
||||
borderColor = '#ffa500';
|
||||
boxShadow = `0 0 10px ${borderColor}`;
|
||||
} else if (executionStatus === 'success') {
|
||||
borderColor = '#00ff00';
|
||||
} else if (executionStatus === 'failure') {
|
||||
borderColor = '#ff0000';
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
minWidth: '180px',
|
||||
padding: '12px',
|
||||
backgroundColor,
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none' as const,
|
||||
transition: 'box-shadow 0.2s',
|
||||
opacity: isDragging ? 0.7 : 1
|
||||
};
|
||||
}, [template.color, position, isSelected, isDragging, executionStatus]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onMouseDown?.(e, node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="behavior-tree-node"
|
||||
style={nodeStyle}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
{/* 节点头部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<IconComponent size={20} color={template.color || '#4a9eff'} />
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
flex: 1
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 节点类型 */}
|
||||
{template.category && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888888',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 节点描述 */}
|
||||
{template.description && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#cccccc',
|
||||
marginTop: '8px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入连接点 */}
|
||||
<div
|
||||
className="node-input-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="input"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
|
||||
{/* 输出连接点 */}
|
||||
<div
|
||||
className="node-output-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="output"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
onReplaceNode: () => void;
|
||||
}
|
||||
|
||||
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
onReplaceNode
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
backgroundColor: '#2d2d30',
|
||||
border: '1px solid #454545',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 10000,
|
||||
minWidth: '150px',
|
||||
padding: '4px 0'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
onClick={onReplaceNode}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px',
|
||||
transition: 'background-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
替换节点
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||
import { Search, X, LucideIcon } from 'lucide-react';
|
||||
|
||||
interface QuickCreateMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
onSearchChange: (text: string) => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onNodeSelect: (template: NodeTemplate) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
searchText,
|
||||
selectedIndex,
|
||||
iconMap,
|
||||
onSearchChange,
|
||||
onIndexChange,
|
||||
onNodeSelect,
|
||||
onClose
|
||||
}) => {
|
||||
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
const searchTextLower = searchText.toLowerCase();
|
||||
const filteredTemplates = searchTextLower
|
||||
? allTemplates.filter((t: NodeTemplate) => {
|
||||
const className = t.className || '';
|
||||
return t.displayName.toLowerCase().includes(searchTextLower) ||
|
||||
t.description.toLowerCase().includes(searchTextLower) ||
|
||||
t.category.toLowerCase().includes(searchTextLower) ||
|
||||
className.toLowerCase().includes(searchTextLower);
|
||||
})
|
||||
: allTemplates;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeRef.current) {
|
||||
selectedNodeRef.current.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.quick-create-menu-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '300px',
|
||||
maxHeight: '400px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderBottom: '1px solid #3c3c3c',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索节点..."
|
||||
autoFocus
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
onIndexChange(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onIndexChange(Math.min(selectedIndex + 1, filteredTemplates.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onIndexChange(Math.max(selectedIndex - 1, 0));
|
||||
} else if (e.key === 'Enter' && filteredTemplates.length > 0) {
|
||||
e.preventDefault();
|
||||
const selectedTemplate = filteredTemplates[selectedIndex];
|
||||
if (selectedTemplate) {
|
||||
onNodeSelect(selectedTemplate);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
padding: '4px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#999',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
<div
|
||||
className="quick-create-menu-list"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
未找到匹配的节点
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template: NodeTemplate, index: number) => {
|
||||
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||
const className = template.className || '';
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isSelected ? selectedNodeRef : null}
|
||||
onClick={() => onNodeSelect(template)}
|
||||
onMouseEnter={() => onIndexChange(index)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
|
||||
borderLeft: `3px solid ${template.color || '#666'}`,
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{IconComponent && (
|
||||
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#ccc',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '2px'
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
{className && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{className}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '2px'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import React from 'react';
|
||||
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo } from 'lucide-react';
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
|
||||
interface EditorToolbarProps {
|
||||
executionMode: ExecutionMode;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
onStep: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onResetView: () => void;
|
||||
onClearCanvas: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
executionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
onStep,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onResetView,
|
||||
onClearCanvas
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
backgroundColor: 'rgba(45, 45, 45, 0.95)',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={executionMode === 'running'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'running' ? '#2d2d2d' : '#4caf50',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'running' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="运行 (Play)"
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
|
||||
{/* 暂停按钮 */}
|
||||
<button
|
||||
onClick={onPause}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#ff9800',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={executionMode === 'paused' ? '继续' : '暂停'}
|
||||
>
|
||||
{executionMode === 'paused' ? <Play size={16} /> : <Pause size={16} />}
|
||||
</button>
|
||||
|
||||
{/* 停止按钮 */}
|
||||
<button
|
||||
onClick={onStop}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#f44336',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="停止"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
|
||||
{/* 单步执行按钮 */}
|
||||
<button
|
||||
onClick={onStep}
|
||||
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2d2d2d' : '#2196f3',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
|
||||
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="单步执行"
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<button
|
||||
onClick={onReset}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: '#9e9e9e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="重置"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#666',
|
||||
margin: '4px 0'
|
||||
}} />
|
||||
|
||||
{/* 重置视图按钮 */}
|
||||
<button
|
||||
onClick={onResetView}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="重置视图 (滚轮缩放, Alt+拖动平移)"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
View
|
||||
</button>
|
||||
|
||||
{/* 清空画布按钮 */}
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="清空画布"
|
||||
onClick={onClearCanvas}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
清空
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '24px',
|
||||
backgroundColor: '#555',
|
||||
margin: '0 4px'
|
||||
}} />
|
||||
|
||||
{/* 撤销按钮 */}
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: canUndo ? '#3c3c3c' : '#2d2d2d',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canUndo ? '#cccccc' : '#666',
|
||||
cursor: canUndo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="撤销 (Ctrl+Z)"
|
||||
>
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
|
||||
{/* 重做按钮 */}
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: canRedo ? '#3c3c3c' : '#2d2d2d',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canRedo ? '#cccccc' : '#666',
|
||||
cursor: canRedo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
|
||||
>
|
||||
<Redo size={16} />
|
||||
</button>
|
||||
|
||||
{/* 状态指示器 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#ccc',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
executionMode === 'running' ? '#4caf50' :
|
||||
executionMode === 'paused' ? '#ff9800' : '#666'
|
||||
}} />
|
||||
{executionMode === 'idle' ? 'Idle' :
|
||||
executionMode === 'running' ? 'Running' :
|
||||
executionMode === 'paused' ? 'Paused' : 'Step'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user