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:
4
packages/behavior-tree-editor/src/hooks/index.ts
Normal file
4
packages/behavior-tree-editor/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useCommandHistory } from './useCommandHistory';
|
||||
export { useNodeOperations } from './useNodeOperations';
|
||||
export { useConnectionOperations } from './useConnectionOperations';
|
||||
export { useCanvasInteraction } from './useCanvasInteraction';
|
||||
111
packages/behavior-tree-editor/src/hooks/useCanvasInteraction.ts
Normal file
111
packages/behavior-tree-editor/src/hooks/useCanvasInteraction.ts
Normal 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
|
||||
]);
|
||||
}
|
||||
250
packages/behavior-tree-editor/src/hooks/useCanvasMouseEvents.ts
Normal file
250
packages/behavior-tree-editor/src/hooks/useCanvasMouseEvents.ts
Normal 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
|
||||
};
|
||||
}
|
||||
77
packages/behavior-tree-editor/src/hooks/useCommandHistory.ts
Normal file
77
packages/behavior-tree-editor/src/hooks/useCommandHistory.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
55
packages/behavior-tree-editor/src/hooks/useContextMenu.ts
Normal file
55
packages/behavior-tree-editor/src/hooks/useContextMenu.ts
Normal 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
|
||||
};
|
||||
}
|
||||
132
packages/behavior-tree-editor/src/hooks/useDropHandler.ts
Normal file
132
packages/behavior-tree-editor/src/hooks/useDropHandler.ts
Normal 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
|
||||
};
|
||||
}
|
||||
70
packages/behavior-tree-editor/src/hooks/useEditorHandlers.ts
Normal file
70
packages/behavior-tree-editor/src/hooks/useEditorHandlers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
18
packages/behavior-tree-editor/src/hooks/useEditorState.ts
Normal file
18
packages/behavior-tree-editor/src/hooks/useEditorState.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
182
packages/behavior-tree-editor/src/hooks/useNodeDrag.ts
Normal file
182
packages/behavior-tree-editor/src/hooks/useNodeDrag.ts
Normal 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
|
||||
};
|
||||
}
|
||||
86
packages/behavior-tree-editor/src/hooks/useNodeOperations.ts
Normal file
86
packages/behavior-tree-editor/src/hooks/useNodeOperations.ts
Normal 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]);
|
||||
}
|
||||
39
packages/behavior-tree-editor/src/hooks/useNodeTracking.ts
Normal file
39
packages/behavior-tree-editor/src/hooks/useNodeTracking.ts
Normal 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
|
||||
};
|
||||
}
|
||||
186
packages/behavior-tree-editor/src/hooks/usePortConnection.ts
Normal file
186
packages/behavior-tree-editor/src/hooks/usePortConnection.ts
Normal 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
|
||||
};
|
||||
}
|
||||
208
packages/behavior-tree-editor/src/hooks/useQuickCreateMenu.ts
Normal file
208
packages/behavior-tree-editor/src/hooks/useQuickCreateMenu.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user