refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)

* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构

* feat(editor): 添加插件市场功能

* feat(editor): 重构插件市场以支持版本管理和ZIP打包

* feat(editor): 重构插件发布流程并修复React渲染警告

* fix(plugin): 修复插件发布和市场的路径不一致问题

* feat: 重构插件发布流程并添加插件删除功能

* fix(editor): 完善插件删除功能并修复多个关键问题

* fix(auth): 修复自动登录与手动登录的竞态条件问题

* feat(editor): 重构插件管理流程

* feat(editor): 支持 ZIP 文件直接发布插件

- 新增 PluginSourceParser 解析插件源
- 重构发布流程支持文件夹和 ZIP 两种方式
- 优化发布向导 UI

* feat(editor): 插件市场支持多版本安装

- 插件解压到项目 plugins 目录
- 新增 Tauri 后端安装/卸载命令
- 支持选择任意版本安装
- 修复打包逻辑,保留完整 dist 目录结构

* feat(editor): 个人中心支持多版本管理

- 合并同一插件的不同版本
- 添加版本历史展开/折叠功能
- 禁止有待审核 PR 时更新插件

* fix(editor): 修复 InspectorRegistry 服务注册

- InspectorRegistry 实现 IService 接口
- 注册到 Core.services 供插件使用

* feat(behavior-tree-editor): 完善插件注册和文件操作

- 添加文件创建模板和操作处理器
- 实现右键菜单创建行为树功能
- 修复文件读取权限问题(使用 Tauri 命令)
- 添加 BehaviorTreeEditorPanel 组件
- 修复 rollup 配置支持动态导入

* feat(plugin): 完善插件构建和发布流程

* fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成

* fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能

* fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式

* refactor(behavior-tree-editor): 移除调试面板功能简化代码结构

* refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑

* feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性

* fix(lint): 修复ESLint错误确保CI通过

* refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能

* refactor(behavior-tree-editor): 清理技术债务,优化代码质量

* fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
YHH
2025-11-18 14:46:51 +08:00
committed by GitHub
parent eac660b1a0
commit bce3a6e253
251 changed files with 26144 additions and 8844 deletions

View File

@@ -0,0 +1,4 @@
export { useCommandHistory } from './useCommandHistory';
export { useNodeOperations } from './useNodeOperations';
export { useConnectionOperations } from './useConnectionOperations';
export { useCanvasInteraction } from './useCanvasInteraction';

View File

@@ -0,0 +1,111 @@
import { useCallback, useMemo } from 'react';
import { useBehaviorTreeDataStore, useUIStore } from '../stores';
/**
* 画布交互 Hook
* 封装画布的缩放、平移等交互逻辑
*/
export function useCanvasInteraction() {
// 从数据 store 获取画布状态
const canvasOffset = useBehaviorTreeDataStore(state => state.canvasOffset);
const canvasScale = useBehaviorTreeDataStore(state => state.canvasScale);
const setCanvasOffset = useBehaviorTreeDataStore(state => state.setCanvasOffset);
const setCanvasScale = useBehaviorTreeDataStore(state => state.setCanvasScale);
const resetView = useBehaviorTreeDataStore(state => state.resetView);
// 从 UI store 获取平移状态
const isPanning = useUIStore(state => state.isPanning);
const panStart = useUIStore(state => state.panStart);
const setIsPanning = useUIStore(state => state.setIsPanning);
const setPanStart = useUIStore(state => state.setPanStart);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY;
const scaleFactor = 1.1;
if (delta < 0) {
setCanvasScale(Math.min(canvasScale * scaleFactor, 3));
} else {
setCanvasScale(Math.max(canvasScale / scaleFactor, 0.1));
}
}, [canvasScale, setCanvasScale]);
const startPanning = useCallback((clientX: number, clientY: number) => {
setIsPanning(true);
setPanStart({ x: clientX, y: clientY });
}, [setIsPanning, setPanStart]);
const updatePanning = useCallback((clientX: number, clientY: number) => {
if (!isPanning) return;
const dx = clientX - panStart.x;
const dy = clientY - panStart.y;
setCanvasOffset({
x: canvasOffset.x + dx,
y: canvasOffset.y + dy
});
setPanStart({ x: clientX, y: clientY });
}, [isPanning, panStart, canvasOffset, setCanvasOffset, setPanStart]);
const stopPanning = useCallback(() => {
setIsPanning(false);
}, [setIsPanning]);
const zoomIn = useCallback(() => {
setCanvasScale(Math.min(canvasScale * 1.2, 3));
}, [canvasScale, setCanvasScale]);
const zoomOut = useCallback(() => {
setCanvasScale(Math.max(canvasScale / 1.2, 0.1));
}, [canvasScale, setCanvasScale]);
const zoomToFit = useCallback(() => {
resetView();
}, [resetView]);
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
return {
x: (screenX - canvasOffset.x) / canvasScale,
y: (screenY - canvasOffset.y) / canvasScale
};
}, [canvasOffset, canvasScale]);
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
return {
x: canvasX * canvasScale + canvasOffset.x,
y: canvasY * canvasScale + canvasOffset.y
};
}, [canvasOffset, canvasScale]);
return useMemo(() => ({
canvasOffset,
canvasScale,
isPanning,
handleWheel,
startPanning,
updatePanning,
stopPanning,
zoomIn,
zoomOut,
zoomToFit,
screenToCanvas,
canvasToScreen
}), [
canvasOffset,
canvasScale,
isPanning,
handleWheel,
startPanning,
updatePanning,
stopPanning,
zoomIn,
zoomOut,
zoomToFit,
screenToCanvas,
canvasToScreen
]);
}

View File

@@ -0,0 +1,250 @@
import { RefObject, useEffect, useRef } from 'react';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface QuickCreateMenuState {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
replaceNodeId: string | null;
}
interface UseCanvasMouseEventsParams {
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
connectingFrom: string | null;
connectingFromProperty: string | null;
connectingToPos: { x: number; y: number } | null;
isBoxSelecting: boolean;
boxSelectStart: { x: number; y: number } | null;
boxSelectEnd: { x: number; y: number } | null;
nodes: BehaviorTreeNode[];
selectedNodeIds: string[];
quickCreateMenu: QuickCreateMenuState;
setConnectingToPos: (pos: { x: number; y: number } | null) => void;
setIsBoxSelecting: (isSelecting: boolean) => void;
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
setSelectedNodeIds: (ids: string[]) => void;
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
setQuickCreateMenu: (menu: QuickCreateMenuState) => void;
clearConnecting: () => void;
clearBoxSelect: () => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning' | 'info', duration?: number) => void;
}
export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
const {
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
connectingToPos,
isBoxSelecting,
boxSelectStart,
boxSelectEnd,
nodes,
selectedNodeIds,
quickCreateMenu,
setConnectingToPos,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
setSelectedNodeIds,
setSelectedConnection,
setQuickCreateMenu,
clearConnecting,
clearBoxSelect,
showToast
} = params;
const isBoxSelectingRef = useRef(isBoxSelecting);
const boxSelectStartRef = useRef(boxSelectStart);
const canvasOffsetRef = useRef(canvasOffset);
const canvasScaleRef = useRef(canvasScale);
const nodesRef = useRef(nodes);
const selectedNodeIdsRef = useRef(selectedNodeIds);
useEffect(() => {
isBoxSelectingRef.current = isBoxSelecting;
boxSelectStartRef.current = boxSelectStart;
canvasOffsetRef.current = canvasOffset;
canvasScaleRef.current = canvasScale;
nodesRef.current = nodes;
selectedNodeIdsRef.current = selectedNodeIds;
}, [isBoxSelecting, boxSelectStart, canvasOffset, canvasScale, nodes, selectedNodeIds]);
useEffect(() => {
if (!isBoxSelecting) return;
const handleGlobalMouseMove = (e: MouseEvent) => {
if (!isBoxSelectingRef.current || !boxSelectStartRef.current) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const canvasX = (e.clientX - rect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
const canvasY = (e.clientY - rect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
setBoxSelectEnd({ x: canvasX, y: canvasY });
};
const handleGlobalMouseUp = (e: MouseEvent) => {
if (!isBoxSelectingRef.current || !boxSelectStartRef.current || !boxSelectEnd) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) {
clearBoxSelect();
return;
}
const minX = Math.min(boxSelectStartRef.current.x, boxSelectEnd.x);
const maxX = Math.max(boxSelectStartRef.current.x, boxSelectEnd.x);
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
const selectedInBox = nodesRef.current
.filter((node: BehaviorTreeNode) => {
if (node.id === ROOT_NODE_ID) return false;
const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`);
if (!nodeElement) {
return node.position.x >= minX && node.position.x <= maxX &&
node.position.y >= minY && node.position.y <= maxY;
}
const nodeRect = nodeElement.getBoundingClientRect();
const canvasRect = canvasRef.current!.getBoundingClientRect();
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffsetRef.current.x) / canvasScaleRef.current;
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffsetRef.current.y) / canvasScaleRef.current;
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
})
.map((node: BehaviorTreeNode) => node.id);
if (e.ctrlKey || e.metaKey) {
const newSet = new Set([...selectedNodeIdsRef.current, ...selectedInBox]);
setSelectedNodeIds(Array.from(newSet));
} else {
setSelectedNodeIds(selectedInBox);
}
clearBoxSelect();
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, canvasRef, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
const handleCanvasMouseMove = (e: React.MouseEvent) => {
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
const rect = canvasRef.current.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
setConnectingToPos({
x: canvasX,
y: canvasY
});
}
};
const handleCanvasMouseUp = (e: React.MouseEvent) => {
if (quickCreateMenu.visible) {
return;
}
const target = e.target as HTMLElement;
const isPort = target.closest('[data-port="true"]');
if (isPort) {
return;
}
if (connectingFrom && connectingToPos) {
// 如果是属性连接,不允许创建新节点
if (connectingFromProperty) {
showToast?.(
'属性连接必须连接到现有节点的属性端口',
'warning'
);
clearConnecting();
setConnectingToPos(null);
return;
}
const sourceNode = nodes.find(n => n.id === connectingFrom);
if (sourceNode && !sourceNode.canAddChild()) {
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
showToast?.(
`节点"${sourceNode.template.displayName}"已达到最大子节点数 ${maxChildren}`,
'warning'
);
clearConnecting();
setConnectingToPos(null);
return;
}
setQuickCreateMenu({
visible: true,
position: {
x: e.clientX,
y: e.clientY
},
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
setConnectingToPos(null);
return;
}
clearConnecting();
};
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (quickCreateMenu.visible) {
setQuickCreateMenu({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
return;
}
if (e.button === 0 && !e.altKey) {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
setIsBoxSelecting(true);
setBoxSelectStart({ x: canvasX, y: canvasY });
setBoxSelectEnd({ x: canvasX, y: canvasY });
if (!e.ctrlKey && !e.metaKey) {
setSelectedNodeIds([]);
setSelectedConnection(null);
}
}
};
return {
handleCanvasMouseMove,
handleCanvasMouseUp,
handleCanvasMouseDown
};
}

View File

@@ -0,0 +1,77 @@
import { useRef, useCallback, useMemo, useEffect } from 'react';
import { CommandManager } from '@esengine/editor-core';
/**
* 撤销/重做功能 Hook
*/
export function useCommandHistory() {
const commandManagerRef = useRef<CommandManager>(new CommandManager());
const commandManager = commandManagerRef.current;
const canUndo = useCallback(() => {
return commandManager.canUndo();
}, [commandManager]);
const canRedo = useCallback(() => {
return commandManager.canRedo();
}, [commandManager]);
const undo = useCallback(() => {
if (commandManager.canUndo()) {
commandManager.undo();
}
}, [commandManager]);
const redo = useCallback(() => {
if (commandManager.canRedo()) {
commandManager.redo();
}
}, [commandManager]);
const getUndoHistory = useCallback(() => {
return commandManager.getUndoHistory();
}, [commandManager]);
const getRedoHistory = useCallback(() => {
return commandManager.getRedoHistory();
}, [commandManager]);
const clear = useCallback(() => {
commandManager.clear();
}, [commandManager]);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey;
if (isCtrlOrCmd && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
} else if (isCtrlOrCmd && e.key === 'y') {
e.preventDefault();
redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo]);
return {
commandManager,
canUndo: canUndo(),
canRedo: canRedo(),
undo,
redo,
getUndoHistory,
getRedoHistory,
clear
};
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useMemo } from 'react';
import { CommandManager } from '@esengine/editor-core';
import { ConnectionType } from '../domain/models/Connection';
import { IValidator } from '../domain/interfaces/IValidator';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase';
import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useConnectionOperations');
/**
* 连接操作 Hook
*/
export function useConnectionOperations(
validator: IValidator,
commandManager: CommandManager
) {
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
const addConnectionUseCase = useMemo(
() => new AddConnectionUseCase(commandManager, treeState, validator),
[commandManager, treeState, validator]
);
const removeConnectionUseCase = useMemo(
() => new RemoveConnectionUseCase(commandManager, treeState),
[commandManager, treeState]
);
const addConnection = useCallback((
from: string,
to: string,
connectionType: ConnectionType = 'node',
fromProperty?: string,
toProperty?: string
) => {
try {
return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty);
} catch (error) {
logger.error('添加连接失败:', error);
throw error;
}
}, [addConnectionUseCase]);
const removeConnection = useCallback((
from: string,
to: string,
fromProperty?: string,
toProperty?: string
) => {
try {
removeConnectionUseCase.execute(from, to, fromProperty, toProperty);
} catch (error) {
logger.error('移除连接失败:', error);
throw error;
}
}, [removeConnectionUseCase]);
return useMemo(() => ({
addConnection,
removeConnection
}), [addConnection, removeConnection]);
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface ContextMenuState {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
}
export function useContextMenu() {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
position: { x: 0, y: 0 },
nodeId: null
});
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
e.preventDefault();
e.stopPropagation();
// 不允许对Root节点右键
if (node.id === ROOT_NODE_ID) {
return;
}
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
nodeId: node.id
});
};
const handleCanvasContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
nodeId: null
});
};
const closeContextMenu = () => {
setContextMenu({ ...contextMenu, visible: false });
};
return {
contextMenu,
setContextMenu,
handleNodeContextMenu,
handleCanvasContextMenu,
closeContextMenu
};
}

View File

@@ -0,0 +1,132 @@
import { useState, RefObject } from 'react';
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useDropHandler');
interface DraggedVariableData {
variableName: string;
}
interface UseDropHandlerParams {
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
nodeOperations: ReturnType<typeof useNodeOperations>;
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
}
export function useDropHandler(params: UseDropHandlerParams) {
const {
canvasRef,
canvasOffset,
canvasScale,
nodeOperations,
onNodeCreate
} = params;
const [isDragging, setIsDragging] = useState(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
try {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const position = {
x: (e.clientX - rect.left - canvasOffset.x) / canvasScale,
y: (e.clientY - rect.top - canvasOffset.y) / canvasScale
};
const blackboardVariableData = e.dataTransfer.getData('application/blackboard-variable');
if (blackboardVariableData) {
const variableData = JSON.parse(blackboardVariableData) as DraggedVariableData;
const variableTemplate: NodeTemplate = {
type: NodeType.Action,
displayName: variableData.variableName,
category: 'Blackboard Variable',
icon: 'Database',
description: `Blackboard variable: ${variableData.variableName}`,
color: '#9c27b0',
defaultConfig: {
nodeType: 'blackboard-variable',
variableName: variableData.variableName
},
properties: [
{
name: 'variableName',
label: '变量名',
type: 'variable',
defaultValue: variableData.variableName,
description: '黑板变量的名称',
required: true
}
]
};
nodeOperations.createNode(
variableTemplate,
new Position(position.x, position.y),
{
nodeType: 'blackboard-variable',
variableName: variableData.variableName
}
);
return;
}
let templateData = e.dataTransfer.getData('application/behavior-tree-node');
if (!templateData) {
templateData = e.dataTransfer.getData('text/plain');
}
if (!templateData) {
return;
}
const template = JSON.parse(templateData) as NodeTemplate;
nodeOperations.createNode(
template,
new Position(position.x, position.y),
template.defaultConfig
);
onNodeCreate?.(template, position);
} catch (error) {
logger.error('Failed to create node:', error);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
if (e.currentTarget === e.target) {
setIsDragging(false);
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
};
return {
isDragging,
handleDrop,
handleDragOver,
handleDragLeave,
handleDragEnter
};
}

View File

@@ -0,0 +1,70 @@
import { useCallback } from 'react';
import { ask } from '@tauri-apps/plugin-dialog';
import { BehaviorTreeNode } from '../stores';
interface UseEditorHandlersParams {
isDraggingNode: boolean;
selectedNodeIds: string[];
setSelectedNodeIds: (ids: string[]) => void;
resetView: () => void;
resetTree: () => void;
triggerForceUpdate: () => void;
onNodeSelect?: (node: BehaviorTreeNode) => void;
}
export function useEditorHandlers(params: UseEditorHandlersParams) {
const {
isDraggingNode,
selectedNodeIds,
setSelectedNodeIds,
resetView,
resetTree,
triggerForceUpdate,
onNodeSelect
} = params;
const handleNodeClick = useCallback((e: React.MouseEvent, node: BehaviorTreeNode) => {
// 阻止事件冒泡,避免触发画布的点击事件
e.stopPropagation();
if (isDraggingNode) {
return;
}
if (e.ctrlKey || e.metaKey) {
if (selectedNodeIds.includes(node.id)) {
setSelectedNodeIds(selectedNodeIds.filter((id: string) => id !== node.id));
} else {
setSelectedNodeIds([...selectedNodeIds, node.id]);
}
} else {
setSelectedNodeIds([node.id]);
}
onNodeSelect?.(node);
}, [isDraggingNode, selectedNodeIds, setSelectedNodeIds, onNodeSelect]);
const handleResetView = useCallback(() => {
resetView();
requestAnimationFrame(() => {
triggerForceUpdate();
});
}, [resetView, triggerForceUpdate]);
const handleClearCanvas = useCallback(async () => {
const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', {
title: '清空画布',
kind: 'warning'
});
if (confirmed) {
resetTree();
setSelectedNodeIds([]);
}
}, [resetTree, setSelectedNodeIds]);
return {
handleNodeClick,
handleResetView,
handleClearCanvas
};
}

View File

@@ -0,0 +1,18 @@
import { useRef, useState } from 'react';
import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor';
export function useEditorState() {
const canvasRef = useRef<HTMLDivElement>(null);
const stopExecutionRef = useRef<(() => void) | null>(null);
const executorRef = useRef<BehaviorTreeExecutor | null>(null);
const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null);
return {
canvasRef,
stopExecutionRef,
executorRef,
selectedConnection,
setSelectedConnection
};
}

View File

@@ -0,0 +1,176 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { ExecutionController, ExecutionMode } from '../application/services/ExecutionController';
import { BlackboardManager } from '../application/services/BlackboardManager';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { ExecutionLog } from '../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../domain/models/Blackboard';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useExecutionController');
type BlackboardVariables = Record<string, BlackboardValue>;
interface UseExecutionControllerParams {
rootNodeId: string;
projectPath: string | null;
blackboardVariables: BlackboardVariables;
nodes: BehaviorTreeNode[];
connections: Connection[];
initialBlackboardVariables: BlackboardVariables;
onBlackboardUpdate: (variables: BlackboardVariables) => void;
onInitialBlackboardSave: (variables: BlackboardVariables) => void;
onExecutingChange: (isExecuting: boolean) => void;
onSaveNodesDataSnapshot: () => void;
onRestoreNodesData: () => void;
sortChildrenByPosition: () => void;
}
export function useExecutionController(params: UseExecutionControllerParams) {
const {
rootNodeId,
projectPath,
blackboardVariables,
nodes,
connections,
onBlackboardUpdate,
onInitialBlackboardSave,
onExecutingChange,
onSaveNodesDataSnapshot,
onRestoreNodesData,
sortChildrenByPosition
} = params;
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
const [executionSpeed, setExecutionSpeed] = useState<number>(1.0);
const [tickCount, setTickCount] = useState(0);
const controller = useMemo(() => {
return new ExecutionController({
rootNodeId,
projectPath,
onLogsUpdate: setExecutionLogs,
onBlackboardUpdate,
onTickCountUpdate: setTickCount,
onExecutionStatusUpdate: (statuses, orders) => {
const store = useBehaviorTreeDataStore.getState();
store.updateNodeExecutionStatuses(statuses, orders);
}
// 不在这里传递 onBreakpointHit避免频繁重建
});
}, [rootNodeId, projectPath, onBlackboardUpdate]);
const blackboardManager = useMemo(() => new BlackboardManager(), []);
useEffect(() => {
// 保存当前 controller 的引用,确保清理时使用正确的实例
const currentController = controller;
return () => {
currentController.destroy();
};
}, [controller]);
useEffect(() => {
controller.setConnections(connections);
}, [connections, controller]);
useEffect(() => {
if (executionMode === 'idle') return;
const executorVars = controller.getBlackboardVariables();
Object.entries(blackboardVariables).forEach(([key, value]) => {
if (executorVars[key] !== value) {
controller.updateBlackboardVariable(key, value);
}
});
}, [blackboardVariables, executionMode, controller]);
useEffect(() => {
if (executionMode === 'idle') return;
controller.updateNodes(nodes);
}, [nodes, executionMode, controller]);
const handlePlay = async () => {
try {
sortChildrenByPosition();
logger.info('[Execute] Sorted children by position before execution');
blackboardManager.setInitialVariables(blackboardVariables);
blackboardManager.setCurrentVariables(blackboardVariables);
onInitialBlackboardSave(blackboardManager.getInitialVariables());
onSaveNodesDataSnapshot();
onExecutingChange(true);
setExecutionMode('running');
await controller.play(nodes, blackboardVariables, connections);
} catch (error) {
logger.error('Failed to start execution:', error);
setExecutionMode('idle');
onExecutingChange(false);
}
};
const handlePause = async () => {
try {
await controller.pause();
const newMode = controller.getMode();
setExecutionMode(newMode);
} catch (error) {
logger.error('Failed to pause/resume execution:', error);
}
};
const handleStop = async () => {
try {
await controller.stop();
setExecutionMode('idle');
setTickCount(0);
const restoredVars = blackboardManager.restoreInitialVariables();
onBlackboardUpdate(restoredVars);
onRestoreNodesData();
useBehaviorTreeDataStore.getState().clearNodeExecutionStatuses();
onExecutingChange(false);
} catch (error) {
logger.error('Failed to stop execution:', error);
}
};
const handleStep = () => {
controller.step();
// 单步执行后保持idle状态不需要专门的step状态
};
const handleReset = async () => {
try {
await controller.reset();
setExecutionMode('idle');
setTickCount(0);
} catch (error) {
logger.error('Failed to reset execution:', error);
}
};
const handleSpeedChange = (speed: number) => {
setExecutionSpeed(speed);
controller.setSpeed(speed);
};
return {
executionMode,
executionLogs,
executionSpeed,
tickCount,
handlePlay,
handlePause,
handleStop,
handleStep,
handleReset,
handleSpeedChange,
setExecutionLogs,
controller,
blackboardManager
};
}

View File

@@ -0,0 +1,72 @@
import { useEffect } from 'react';
import { Connection, ROOT_NODE_ID } from '../stores';
import { useNodeOperations } from './useNodeOperations';
import { useConnectionOperations } from './useConnectionOperations';
interface UseKeyboardShortcutsParams {
selectedNodeIds: string[];
selectedConnection: { from: string; to: string } | null;
connections: Connection[];
nodeOperations: ReturnType<typeof useNodeOperations>;
connectionOperations: ReturnType<typeof useConnectionOperations>;
setSelectedNodeIds: (ids: string[]) => void;
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
}
export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams) {
const {
selectedNodeIds,
selectedConnection,
connections,
nodeOperations,
connectionOperations,
setSelectedNodeIds,
setSelectedConnection
} = params;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isEditingText = activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement instanceof HTMLSelectElement ||
(activeElement as HTMLElement)?.isContentEditable;
if (isEditingText) {
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
if (selectedConnection) {
const conn = connections.find(
(c: Connection) => c.from === selectedConnection.from && c.to === selectedConnection.to
);
if (conn) {
connectionOperations.removeConnection(
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
);
}
setSelectedConnection(null);
return;
}
if (selectedNodeIds.length > 0) {
const nodesToDelete = selectedNodeIds.filter((id: string) => id !== ROOT_NODE_ID);
if (nodesToDelete.length > 0) {
nodeOperations.deleteNodes(nodesToDelete);
setSelectedNodeIds([]);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedNodeIds, selectedConnection, nodeOperations, connectionOperations, connections, setSelectedNodeIds, setSelectedConnection]);
}

View File

@@ -0,0 +1,182 @@
import { useRef, useCallback, RefObject } from 'react';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations';
interface UseNodeDragParams {
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
nodes: BehaviorTreeNode[];
selectedNodeIds: string[];
draggingNodeId: string | null;
dragStartPositions: Map<string, { x: number; y: number }>;
isDraggingNode: boolean;
dragDelta: { dx: number; dy: number };
nodeOperations: ReturnType<typeof useNodeOperations>;
setSelectedNodeIds: (ids: string[]) => void;
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => void;
stopDragging: () => void;
setIsDraggingNode: (isDragging: boolean) => void;
setDragDelta: (delta: { dx: number; dy: number }) => void;
setIsBoxSelecting: (isSelecting: boolean) => void;
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
sortChildrenByPosition: () => void;
}
/**
* 拖拽上下文,存储拖拽过程中需要保持稳定的值
*/
interface DragContext {
// 鼠标按下时的客户端坐标
startClientX: number;
startClientY: number;
// 拖拽开始时的画布状态(缩放和偏移)
startCanvasScale: number;
startCanvasOffset: { x: number; y: number };
// 被拖拽节点的初始画布坐标
nodeStartPositions: Map<string, { x: number; y: number }>;
}
export function useNodeDrag(params: UseNodeDragParams) {
const {
canvasRef,
canvasOffset,
canvasScale,
nodes,
selectedNodeIds,
draggingNodeId,
dragStartPositions,
isDraggingNode,
dragDelta,
nodeOperations,
setSelectedNodeIds,
startDragging,
stopDragging,
setIsDraggingNode,
setDragDelta,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
sortChildrenByPosition
} = params;
// 使用 ref 存储拖拽上下文,避免闭包问题
const dragContextRef = useRef<DragContext | null>(null);
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
if (e.button !== 0) return;
if (nodeId === ROOT_NODE_ID) return;
const target = e.target as HTMLElement;
const isPort = target.closest('[data-port="true"]');
if (isPort) {
return;
}
e.stopPropagation();
setIsBoxSelecting(false);
setBoxSelectStart(null);
setBoxSelectEnd(null);
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
if (!node) return;
// 确定要拖拽的节点列表
let nodesToDrag: string[];
if (selectedNodeIds.includes(nodeId)) {
nodesToDrag = selectedNodeIds;
} else {
nodesToDrag = [nodeId];
setSelectedNodeIds([nodeId]);
}
// 记录所有要拖拽节点的初始位置
const startPositions = new Map<string, { x: number; y: number }>();
nodesToDrag.forEach((id: string) => {
const n = nodes.find((node: BehaviorTreeNode) => node.id === id);
if (n) {
startPositions.set(id, { x: n.position.x, y: n.position.y });
}
});
// 创建拖拽上下文,保存拖拽开始时的所有关键状态
dragContextRef.current = {
startClientX: e.clientX,
startClientY: e.clientY,
startCanvasScale: canvasScale,
startCanvasOffset: { ...canvasOffset },
nodeStartPositions: startPositions
};
startDragging(nodeId, startPositions);
}, [nodes, selectedNodeIds, canvasScale, canvasOffset, setSelectedNodeIds, setIsBoxSelecting, setBoxSelectStart, setBoxSelectEnd, startDragging]);
const handleNodeMouseMove = useCallback((e: React.MouseEvent) => {
if (!draggingNodeId || !dragContextRef.current) return;
if (!isDraggingNode) {
setIsDraggingNode(true);
}
const context = dragContextRef.current;
// 计算鼠标在客户端坐标系中的移动距离(像素)
const clientDeltaX = e.clientX - context.startClientX;
const clientDeltaY = e.clientY - context.startClientY;
// 转换为画布坐标系中的移动距离
// 注意:这里使用拖拽开始时的缩放比例,确保计算一致性
const canvasDeltaX = clientDeltaX / context.startCanvasScale;
const canvasDeltaY = clientDeltaY / context.startCanvasScale;
setDragDelta({ dx: canvasDeltaX, dy: canvasDeltaY });
}, [draggingNodeId, isDraggingNode, setIsDraggingNode, setDragDelta]);
const handleNodeMouseUp = useCallback(() => {
if (!draggingNodeId || !dragContextRef.current) return;
const context = dragContextRef.current;
if (dragDelta.dx !== 0 || dragDelta.dy !== 0) {
// 根据拖拽增量计算所有节点的新位置
const moves: Array<{ nodeId: string; position: Position }> = [];
context.nodeStartPositions.forEach((startPos, nodeId) => {
moves.push({
nodeId,
position: new Position(
startPos.x + dragDelta.dx,
startPos.y + dragDelta.dy
)
});
});
// 先重置拖拽状态,避免 moveNodes 触发重新渲染时位置计算错误
setDragDelta({ dx: 0, dy: 0 });
setIsDraggingNode(false);
// 然后更新节点位置
nodeOperations.moveNodes(moves);
setTimeout(() => {
sortChildrenByPosition();
}, 0);
} else {
// 没有实际移动,直接重置状态
setDragDelta({ dx: 0, dy: 0 });
setIsDraggingNode(false);
}
// 清理拖拽上下文
dragContextRef.current = null;
stopDragging();
}, [draggingNodeId, dragDelta, nodeOperations, sortChildrenByPosition, setDragDelta, stopDragging, setIsDraggingNode]);
return {
handleNodeMouseDown,
handleNodeMouseMove,
handleNodeMouseUp
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useMemo } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { CommandManager } from '@esengine/editor-core';
import { Position } from '../domain/value-objects/Position';
import { INodeFactory } from '../domain/interfaces/INodeFactory';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
import { CreateNodeUseCase } from '../application/use-cases/CreateNodeUseCase';
import { DeleteNodeUseCase } from '../application/use-cases/DeleteNodeUseCase';
import { MoveNodeUseCase } from '../application/use-cases/MoveNodeUseCase';
import { UpdateNodeDataUseCase } from '../application/use-cases/UpdateNodeDataUseCase';
/**
* 节点操作 Hook
*/
export function useNodeOperations(
nodeFactory: INodeFactory,
commandManager: CommandManager
) {
const treeState = useMemo(() => TreeStateAdapter.getInstance(), []);
const createNodeUseCase = useMemo(
() => new CreateNodeUseCase(nodeFactory, commandManager, treeState),
[nodeFactory, commandManager, treeState]
);
const deleteNodeUseCase = useMemo(
() => new DeleteNodeUseCase(commandManager, treeState),
[commandManager, treeState]
);
const moveNodeUseCase = useMemo(
() => new MoveNodeUseCase(commandManager, treeState),
[commandManager, treeState]
);
const updateNodeDataUseCase = useMemo(
() => new UpdateNodeDataUseCase(commandManager, treeState),
[commandManager, treeState]
);
const createNode = useCallback((
template: NodeTemplate,
position: Position,
data?: Record<string, unknown>
) => {
return createNodeUseCase.execute(template, position, data);
}, [createNodeUseCase]);
const createNodeByType = useCallback((
nodeType: string,
position: Position,
data?: Record<string, unknown>
) => {
return createNodeUseCase.executeByType(nodeType, position, data);
}, [createNodeUseCase]);
const deleteNode = useCallback((nodeId: string) => {
deleteNodeUseCase.execute(nodeId);
}, [deleteNodeUseCase]);
const deleteNodes = useCallback((nodeIds: string[]) => {
deleteNodeUseCase.executeBatch(nodeIds);
}, [deleteNodeUseCase]);
const moveNode = useCallback((nodeId: string, position: Position) => {
moveNodeUseCase.execute(nodeId, position);
}, [moveNodeUseCase]);
const moveNodes = useCallback((moves: Array<{ nodeId: string; position: Position }>) => {
moveNodeUseCase.executeBatch(moves);
}, [moveNodeUseCase]);
const updateNodeData = useCallback((nodeId: string, data: Record<string, unknown>) => {
updateNodeDataUseCase.execute(nodeId, data);
}, [updateNodeDataUseCase]);
return useMemo(() => ({
createNode,
createNodeByType,
deleteNode,
deleteNodes,
moveNode,
moveNodes,
updateNodeData
}), [createNode, createNodeByType, deleteNode, deleteNodes, moveNode, moveNodes, updateNodeData]);
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect, useRef } from 'react';
import { BehaviorTreeNode } from '../stores';
import { ExecutionMode } from '../application/services/ExecutionController';
interface UseNodeTrackingParams {
nodes: BehaviorTreeNode[];
executionMode: ExecutionMode;
}
export function useNodeTracking(params: UseNodeTrackingParams) {
const { nodes, executionMode } = params;
const [uncommittedNodeIds, setUncommittedNodeIds] = useState<Set<string>>(new Set());
const activeNodeIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (executionMode === 'idle') {
setUncommittedNodeIds(new Set());
activeNodeIdsRef.current = new Set(nodes.map((n) => n.id));
} else if (executionMode === 'running' || executionMode === 'paused') {
const currentNodeIds = new Set(nodes.map((n) => n.id));
const newNodeIds = new Set<string>();
currentNodeIds.forEach((id) => {
if (!activeNodeIdsRef.current.has(id)) {
newNodeIds.add(id);
}
});
if (newNodeIds.size > 0) {
setUncommittedNodeIds((prev) => new Set([...prev, ...newNodeIds]));
}
}
}, [nodes, executionMode]);
return {
uncommittedNodeIds
};
}

View File

@@ -0,0 +1,186 @@
import { RefObject } from 'react';
import { BehaviorTreeNode, Connection, ROOT_NODE_ID, useUIStore } from '../stores';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { useConnectionOperations } from './useConnectionOperations';
interface UsePortConnectionParams {
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
nodes: BehaviorTreeNode[];
connections: Connection[];
connectingFrom: string | null;
connectingFromProperty: string | null;
connectionOperations: ReturnType<typeof useConnectionOperations>;
setConnectingFrom: (nodeId: string | null) => void;
setConnectingFromProperty: (propertyName: string | null) => void;
clearConnecting: () => void;
sortChildrenByPosition: () => void;
showToast?: (message: string, type: 'success' | 'error' | 'info' | 'warning') => void;
}
export function usePortConnection(params: UsePortConnectionParams) {
const {
canvasRef,
nodes,
connections,
connectionOperations,
setConnectingFrom,
setConnectingFromProperty,
clearConnecting,
sortChildrenByPosition,
showToast
} = params;
const handlePortMouseDown = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const portType = target.getAttribute('data-port-type');
setConnectingFrom(nodeId);
setConnectingFromProperty(propertyName || null);
if (canvasRef.current) {
canvasRef.current.setAttribute('data-connecting-from-port-type', portType || '');
}
};
const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
e.stopPropagation();
// 从 store 读取最新状态避免闭包陷阱
const currentConnectingFrom = useUIStore.getState().connectingFrom;
const currentConnectingFromProperty = useUIStore.getState().connectingFromProperty;
if (!currentConnectingFrom) {
clearConnecting();
return;
}
if (currentConnectingFrom === nodeId) {
showToast?.('不能将节点连接到自己', 'warning');
clearConnecting();
return;
}
const target = e.currentTarget as HTMLElement;
const toPortType = target.getAttribute('data-port-type');
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type');
let actualFrom = currentConnectingFrom;
let actualTo = nodeId;
let actualFromProperty = currentConnectingFromProperty;
let actualToProperty = propertyName;
const needReverse =
(fromPortType === 'node-input' || fromPortType === 'property-input') &&
(toPortType === 'node-output' || toPortType === 'variable-output');
if (needReverse) {
actualFrom = nodeId;
actualTo = currentConnectingFrom;
actualFromProperty = propertyName || null;
actualToProperty = currentConnectingFromProperty ?? undefined;
}
if (actualFromProperty || actualToProperty) {
const existingConnection = connections.find(
(conn: Connection) =>
(conn.from === actualFrom && conn.to === actualTo &&
conn.fromProperty === actualFromProperty && conn.toProperty === actualToProperty) ||
(conn.from === actualTo && conn.to === actualFrom &&
conn.fromProperty === actualToProperty && conn.toProperty === actualFromProperty)
);
if (existingConnection) {
showToast?.('该连接已存在', 'warning');
clearConnecting();
return;
}
const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo);
if (toNode && actualToProperty) {
const targetProperty = toNode.template.properties.find(
(p: PropertyDefinition) => p.name === actualToProperty
);
if (!targetProperty?.allowMultipleConnections) {
const existingPropertyConnection = connections.find(
(conn: Connection) =>
conn.connectionType === 'property' &&
conn.to === actualTo &&
conn.toProperty === actualToProperty
);
if (existingPropertyConnection) {
showToast?.('该属性已有连接,请先删除现有连接', 'warning');
clearConnecting();
return;
}
}
}
try {
connectionOperations.addConnection(
actualFrom,
actualTo,
'property',
actualFromProperty || undefined,
actualToProperty || undefined
);
} catch (error) {
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
clearConnecting();
return;
}
} else {
if (actualFrom === ROOT_NODE_ID) {
const rootNode = nodes.find((n: BehaviorTreeNode) => n.id === ROOT_NODE_ID);
if (rootNode && rootNode.children.length > 0) {
showToast?.('根节点只能连接一个子节点', 'warning');
clearConnecting();
return;
}
}
const existingConnection = connections.find(
(conn: Connection) =>
(conn.from === actualFrom && conn.to === actualTo && conn.connectionType === 'node') ||
(conn.from === actualTo && conn.to === actualFrom && conn.connectionType === 'node')
);
if (existingConnection) {
showToast?.('该连接已存在', 'warning');
clearConnecting();
return;
}
try {
connectionOperations.addConnection(actualFrom, actualTo, 'node');
setTimeout(() => {
sortChildrenByPosition();
}, 0);
} catch (error) {
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
clearConnecting();
return;
}
}
clearConnecting();
};
const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => {
const currentConnectingFrom = useUIStore.getState().connectingFrom;
if (currentConnectingFrom && currentConnectingFrom !== nodeId) {
handlePortMouseUp(e, nodeId);
}
};
return {
handlePortMouseDown,
handlePortMouseUp,
handleNodeMouseUpForConnection
};
}

View File

@@ -0,0 +1,208 @@
import { useState, RefObject } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { Node } from '../domain/models/Node';
import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations';
import { useConnectionOperations } from './useConnectionOperations';
interface QuickCreateMenuState {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
replaceNodeId: string | null;
}
type ExecutionMode = 'idle' | 'running' | 'paused';
interface UseQuickCreateMenuParams {
nodeOperations: ReturnType<typeof useNodeOperations>;
connectionOperations: ReturnType<typeof useConnectionOperations>;
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
connectingFrom: string | null;
connectingFromProperty: string | null;
clearConnecting: () => void;
nodes: BehaviorTreeNode[];
connections: Connection[];
executionMode: ExecutionMode;
onStop: () => void;
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
showToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
const {
nodeOperations,
connectionOperations,
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
clearConnecting,
nodes,
connections,
executionMode,
onStop,
onNodeCreate,
showToast
} = params;
const [quickCreateMenu, setQuickCreateMenu] = useState<QuickCreateMenuState>({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
const handleReplaceNode = (newTemplate: NodeTemplate) => {
const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId);
if (!nodeToReplace) return;
// 如果行为树正在执行,先停止
if (executionMode !== 'idle') {
onStop();
}
// 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值
const newData = { ...newTemplate.defaultConfig };
// 获取新模板的属性名列表
const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name));
// 遍历旧节点的 data保留新模板中也存在的属性
for (const [key, value] of Object.entries(nodeToReplace.data)) {
// 跳过节点类型相关的字段
if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' ||
key === 'actionType' || key === 'conditionType') {
continue;
}
// 如果新模板也有这个属性,保留旧值(包括绑定信息)
if (newPropertyNames.has(key)) {
newData[key] = value;
}
}
// 创建新节点,保留原节点的位置和连接
const newNode = new Node(
nodeToReplace.id,
newTemplate,
newData,
nodeToReplace.position,
Array.from(nodeToReplace.children)
);
// 替换节点 - 通过 store 更新
const store = useBehaviorTreeDataStore.getState();
const updatedTree = store.tree.updateNode(newNode.id, () => newNode);
store.setTree(updatedTree);
// 删除所有指向该节点的属性连接,让用户重新连接
const propertyConnections = connections.filter((conn) =>
conn.connectionType === 'property' && conn.to === newNode.id
);
propertyConnections.forEach((conn) => {
connectionOperations.removeConnection(
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
);
});
// 关闭快速创建菜单
closeQuickCreateMenu();
// 显示提示
showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success');
};
const handleQuickCreateNode = (template: NodeTemplate) => {
// 如果是替换模式,直接调用替换函数
if (quickCreateMenu.mode === 'replace') {
handleReplaceNode(template);
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const posX = (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale;
const posY = (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale;
const newNode = nodeOperations.createNode(
template,
new Position(posX, posY),
template.defaultConfig
);
// 如果有连接源,创建连接
if (connectingFrom) {
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
if (fromNode) {
if (connectingFromProperty) {
// 属性连接
connectionOperations.addConnection(
connectingFrom,
newNode.id,
'property',
connectingFromProperty,
undefined
);
} else {
// 节点连接
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
}
}
}
closeQuickCreateMenu();
onNodeCreate?.(template, { x: posX, y: posY });
};
const openQuickCreateMenu = (
position: { x: number; y: number },
mode: 'create' | 'replace',
replaceNodeId?: string | null
) => {
setQuickCreateMenu({
visible: true,
position,
searchText: '',
selectedIndex: 0,
mode,
replaceNodeId: replaceNodeId || null
});
};
const closeQuickCreateMenu = () => {
setQuickCreateMenu({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
};
return {
quickCreateMenu,
setQuickCreateMenu,
handleQuickCreateNode,
handleReplaceNode,
openQuickCreateMenu,
closeQuickCreateMenu
};
}