feat: 预制体系统与架构改进 (#303)
* feat(prefab): 实现预制体系统和编辑器 UX 改进 ## 预制体系统 - 新增 PrefabSerializer: 预制体序列化/反序列化 - 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改 - 新增 PrefabService: 预制体核心服务 - 新增 PrefabLoader: 预制体资产加载器 - 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink ## 预制体编辑模式 - 支持双击 .prefab 文件进入编辑模式 - 预制体编辑模式工具栏 (保存/退出) - 预制体实例指示器和操作菜单 ## 编辑器 UX 改进 - SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航 - 支持双击实体名称内联编辑 - 删除实体时显示子节点数量警告 - 右键菜单添加重命名/复制选项及快捷键提示 - 布局持久化和重置功能 ## Bug 修复 - 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题 - 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见 - 修复 Inspector 资源字段高度不正确问题 * feat(editor): 改进编辑器 UX 交互体验 - ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计 - SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮 - PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理 - EntityInspector: 组件折叠状态持久化、属性搜索清除按钮 - Viewport: 变换操作实时数值显示 - 国际化: 添加相关文本 (en/zh) * fix(build): 修复 Web 构建资产加载和编辑器 UX 改进 构建系统修复: - 修复 asset-catalog.json 字段名不匹配 (entries vs assets) - 修复 BrowserFileSystemService 支持两种目录格式 - 修复 bundle 策略检测逻辑 (空对象判断) - 修复 module.json 中 assetExtensions 声明和类型推断 行为树修复: - 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath - 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree) 编辑器 UX 改进: - 构建完成对话框添加"打开文件夹"按钮 - 构建完成对话框样式优化 (圆形图标背景、按钮布局) - SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列) - SceneHierarchy 隐藏滚动条 错误追踪: - 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log) - 添加 append_to_log Tauri 命令 * feat(render): 修复 UI 渲染和点击特效系统 ## UI 渲染修复 - 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数 - 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap - Web 运行时添加 assetPathResolver 支持 GUID 解析 - UIInteractableComponent.blockEvents 默认值改为 false ## 点击特效系统 - 新增 ClickFxComponent 和 ClickFxSystem - 支持在点击位置播放粒子效果 - 支持多种触发模式和粒子轮换 ## Camera 系统重构 - CameraSystem 从 ecs-engine-bindgen 移至 camera 包 - 新增 CameraManager 统一管理相机 ## 编辑器改进 - 改进属性面板 UI 交互 - 粒子编辑器面板优化 - Transform 命令系统 * feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层 - 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay) - 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性 - 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染 - 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer - 更新粒子编辑器面板支持新的排序属性 - 优化 UI 渲染系统使用新的排序层级 * feat(ci): 集成 SignPath 代码签名服务 - 添加 SignPath 自动签名工作流(Windows) - 配置 release-editor.yml 支持代码签名 - 将构建改为草稿模式,等待签名完成后发布 - 添加证书文件到 .gitignore 防止泄露 * fix(asset): 修复 Web 构建资产路径解析和全局单例移除 ## 资产路径修复 - 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接 - BrowserPathResolver 支持两种模式: - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser) - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建) - BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理 ## 架构改进 - 移除全局单例 - 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入 - 移除 globalPathResolver 导出,改用 PathResolutionService - 移除 globalPathResolutionService 导出 - ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖 - EngineService 使用 new AssetManager() 替代全局实例 ## 新增服务 - PathResolutionService: 统一路径解析接口 - RuntimeModeService: 运行时模式查询服务 - SerializationContext: EntityRef 序列化上下文 ## 其他改进 - 完善 ServiceToken 注释说明本地定义的意图 - 导出 BrowserPathResolveMode 类型 * fix(build): 添加 world-streaming composite 设置修复类型检查 * fix(build): 移除 world-streaming 引用避免 composite 冲突 * fix(build): 将 const enum 改为 enum 兼容 isolatedModules * fix(build): 添加缺失的 IAssetManager 导入
This commit is contained in:
+374
-215
@@ -31,9 +31,11 @@ import {
|
||||
import type { IDialogExtended } from './services/TauriDialogService';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
|
||||
import { useEditorStore } from './stores';
|
||||
import { StartupPage } from './components/StartupPage';
|
||||
import { ProjectCreationWizard } from './components/ProjectCreationWizard';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { ContentBrowser } from './components/ContentBrowser';
|
||||
import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { Viewport } from './components/Viewport';
|
||||
@@ -49,7 +51,7 @@ import { ForumPanel } from './components/forum';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
import { MainToolbar } from './components/MainToolbar';
|
||||
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
|
||||
import { FlexLayoutDockContainer, FlexDockPanel, type FlexLayoutDockContainerHandle } from './components/FlexLayoutDockContainer';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
@@ -58,6 +60,7 @@ import { EngineService } from './services/EngineService';
|
||||
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
|
||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||
import { useLocale } from './hooks/useLocale';
|
||||
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
|
||||
import { en, zh, es } from './locales';
|
||||
import type { Locale } from '@esengine/editor-core';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
@@ -83,41 +86,82 @@ const logger = createLogger('App');
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
|
||||
const [pluginLoader] = useState(() => new PluginLoader());
|
||||
const { showToast, hideToast } = useToast();
|
||||
|
||||
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
|
||||
const [availableScenes, setAvailableScenes] = useState<string[]>([]);
|
||||
const [pluginManager, setPluginManager] = useState<PluginManager | null>(null);
|
||||
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
|
||||
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
|
||||
const [inspectorRegistry, setInspectorRegistry] = useState<InspectorRegistry | null>(null);
|
||||
const [logService, setLogService] = useState<LogService | null>(null);
|
||||
const [uiRegistry, setUiRegistry] = useState<UIRegistry | null>(null);
|
||||
const [settingsRegistry, setSettingsRegistry] = useState<SettingsRegistry | null>(null);
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const [notification, setNotification] = useState<INotification | null>(null);
|
||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||
const [buildService, setBuildService] = useState<BuildService | null>(null);
|
||||
const [projectServiceState, setProjectServiceState] = useState<ProjectService | null>(null);
|
||||
|
||||
// ===== 从 EditorStore 获取状态 | Get state from EditorStore =====
|
||||
const {
|
||||
projectLoaded, setProjectLoaded,
|
||||
currentProjectPath, setCurrentProjectPath,
|
||||
availableScenes, setAvailableScenes,
|
||||
isLoading, setIsLoading,
|
||||
loadingMessage,
|
||||
panels, setPanels,
|
||||
activeDynamicPanels, addDynamicPanel, removeDynamicPanel, clearDynamicPanels,
|
||||
dynamicPanelTitles, setDynamicPanelTitle,
|
||||
activePanelId, setActivePanelId,
|
||||
pluginUpdateTrigger, triggerPluginUpdate,
|
||||
isRemoteConnected, setIsRemoteConnected,
|
||||
isContentBrowserDocked, setIsContentBrowserDocked,
|
||||
isEditorFullscreen, setIsEditorFullscreen,
|
||||
status, setStatus,
|
||||
showProjectWizard, setShowProjectWizard,
|
||||
settingsInitialCategory, setSettingsInitialCategory,
|
||||
compilerDialog, openCompilerDialog, closeCompilerDialog,
|
||||
} = useEditorStore();
|
||||
|
||||
// ===== 服务实例用 useRef(不触发重渲染)| Service instances use useRef (no re-renders) =====
|
||||
const pluginManagerRef = useRef<PluginManager | null>(null);
|
||||
const entityStoreRef = useRef<EntityStoreService | null>(null);
|
||||
const messageHubRef = useRef<MessageHub | null>(null);
|
||||
const inspectorRegistryRef = useRef<InspectorRegistry | null>(null);
|
||||
const logServiceRef = useRef<LogService | null>(null);
|
||||
const uiRegistryRef = useRef<UIRegistry | null>(null);
|
||||
const settingsRegistryRef = useRef<SettingsRegistry | null>(null);
|
||||
const sceneManagerRef = useRef<SceneManagerService | null>(null);
|
||||
const notificationRef = useRef<INotification | null>(null);
|
||||
const dialogRef = useRef<IDialogExtended | null>(null);
|
||||
const buildServiceRef = useRef<BuildService | null>(null);
|
||||
const projectServiceRef = useRef<ProjectService | null>(null);
|
||||
|
||||
// 兼容层:提供 getter 访问服务 | Compatibility layer: provide getter access to services
|
||||
const pluginManager = pluginManagerRef.current;
|
||||
const entityStore = entityStoreRef.current;
|
||||
const messageHub = messageHubRef.current;
|
||||
const inspectorRegistry = inspectorRegistryRef.current;
|
||||
const logService = logServiceRef.current;
|
||||
const uiRegistry = uiRegistryRef.current;
|
||||
const settingsRegistry = settingsRegistryRef.current;
|
||||
const sceneManager = sceneManagerRef.current;
|
||||
const notification = notificationRef.current;
|
||||
const dialog = dialogRef.current;
|
||||
const buildService = buildServiceRef.current;
|
||||
const projectServiceState = projectServiceRef.current;
|
||||
|
||||
const [commandManager] = useState(() => new CommandManager());
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
|
||||
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
|
||||
useStoreSubscriptions({
|
||||
messageHub: messageHubRef.current,
|
||||
entityStore: entityStoreRef.current,
|
||||
sceneManager: sceneManagerRef.current,
|
||||
enabled: initialized,
|
||||
});
|
||||
|
||||
// 同步 locale 到 TauriDialogService
|
||||
useEffect(() => {
|
||||
if (dialog) {
|
||||
dialog.setLocale(locale);
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.setLocale(locale);
|
||||
}
|
||||
}, [locale, dialog]);
|
||||
const [status, setStatus] = useState(t('header.status.initializing'));
|
||||
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [showProjectWizard, setShowProjectWizard] = useState(false);
|
||||
}, [locale]);
|
||||
|
||||
// ===== 从 DialogStore 获取对话框状态 | Get dialog state from DialogStore =====
|
||||
const {
|
||||
showProfiler, setShowProfiler,
|
||||
showAdvancedProfiler, setShowAdvancedProfiler,
|
||||
@@ -129,16 +173,6 @@ function App() {
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
} = useDialogStore();
|
||||
const [settingsInitialCategory, setSettingsInitialCategory] = useState<string | undefined>(undefined);
|
||||
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
|
||||
const [activePanelId, setActivePanelId] = useState<string | undefined>(undefined);
|
||||
const [dynamicPanelTitles, setDynamicPanelTitles] = useState<Map<string, string>>(new Map());
|
||||
const [isEditorFullscreen, setIsEditorFullscreen] = useState(false);
|
||||
const [compilerDialog, setCompilerDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
currentFileName?: string;
|
||||
}>({ isOpen: false, compilerId: '' });
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用默认右键菜单
|
||||
@@ -153,6 +187,35 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Global keyboard shortcuts for undo/redo | 全局撤销/重做快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if user is typing in an input | 如果用户正在输入则跳过
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z: Undo | 撤销
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
if (commandManager.canUndo()) {
|
||||
commandManager.undo();
|
||||
}
|
||||
}
|
||||
// Ctrl+Y or Ctrl+Shift+Z: Redo | 重做
|
||||
else if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
|
||||
e.preventDefault();
|
||||
if (commandManager.canRedo()) {
|
||||
commandManager.redo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [commandManager]);
|
||||
|
||||
// 快捷键监听
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
@@ -181,12 +244,23 @@ function App() {
|
||||
e.preventDefault();
|
||||
if (sceneManager) {
|
||||
try {
|
||||
await sceneManager.saveScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
showToast(t('scene.savedSuccess', { name: sceneState.sceneName }), 'success');
|
||||
// 检查是否在预制体编辑模式 | Check if in prefab edit mode
|
||||
if (sceneManager.isPrefabEditMode()) {
|
||||
await sceneManager.savePrefab();
|
||||
const prefabState = sceneManager.getPrefabEditModeState();
|
||||
showToast(t('editMode.prefab.savedSuccess', { name: prefabState?.prefabName ?? 'Prefab' }), 'success');
|
||||
} else {
|
||||
await sceneManager.saveScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
showToast(t('scene.savedSuccess', { name: sceneState.sceneName }), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene:', error);
|
||||
showToast(t('scene.saveFailed'), 'error');
|
||||
console.error('Failed to save:', error);
|
||||
if (sceneManager.isPrefabEditMode()) {
|
||||
showToast(t('editMode.prefab.saveFailed'), 'error');
|
||||
} else {
|
||||
showToast(t('scene.saveFailed'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -208,29 +282,31 @@ function App() {
|
||||
showBuildSettings, showSettings, showAbout, showPluginGenerator,
|
||||
showPortManager, showAdvancedProfiler, errorDialog, confirmDialog]);
|
||||
|
||||
// 插件和通知订阅 | Plugin and notification subscriptions
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
const unsubscribeEnabled = hub.subscribe('plugin:enabled', () => {
|
||||
triggerPluginUpdate();
|
||||
});
|
||||
|
||||
const unsubscribeNotification = messageHub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
|
||||
if (notification && notification.message) {
|
||||
showToast(notification.message, notification.type);
|
||||
}
|
||||
});
|
||||
const unsubscribeDisabled = hub.subscribe('plugin:disabled', () => {
|
||||
triggerPluginUpdate();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
unsubscribeNotification();
|
||||
};
|
||||
}
|
||||
}, [messageHub, showToast]);
|
||||
const unsubscribeNotification = hub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
|
||||
if (notification && notification.message) {
|
||||
showToast(notification.message, notification.type);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
unsubscribeNotification();
|
||||
};
|
||||
}, [initialized, triggerPluginUpdate, showToast]);
|
||||
|
||||
// 监听远程连接状态
|
||||
// Monitor remote connection status
|
||||
@@ -307,18 +383,21 @@ function App() {
|
||||
}
|
||||
});
|
||||
|
||||
// 设置服务引用(不触发重渲染)| Set service refs (no re-renders)
|
||||
pluginManagerRef.current = services.pluginManager;
|
||||
entityStoreRef.current = services.entityStore;
|
||||
messageHubRef.current = services.messageHub;
|
||||
inspectorRegistryRef.current = services.inspectorRegistry;
|
||||
logServiceRef.current = services.logService;
|
||||
uiRegistryRef.current = services.uiRegistry;
|
||||
settingsRegistryRef.current = services.settingsRegistry;
|
||||
sceneManagerRef.current = services.sceneManager;
|
||||
notificationRef.current = services.notification;
|
||||
dialogRef.current = services.dialog as IDialogExtended;
|
||||
buildServiceRef.current = services.buildService;
|
||||
|
||||
// 设置初始化完成(触发一次重渲染)| Set initialized (triggers one re-render)
|
||||
setInitialized(true);
|
||||
setPluginManager(services.pluginManager);
|
||||
setEntityStore(services.entityStore);
|
||||
setMessageHub(services.messageHub);
|
||||
setInspectorRegistry(services.inspectorRegistry);
|
||||
setLogService(services.logService);
|
||||
setUiRegistry(services.uiRegistry);
|
||||
setSettingsRegistry(services.settingsRegistry);
|
||||
setSceneManager(services.sceneManager);
|
||||
setNotification(services.notification);
|
||||
setDialog(services.dialog as IDialogExtended);
|
||||
setBuildService(services.buildService);
|
||||
setStatus(t('header.status.ready'));
|
||||
|
||||
// Check for updates on startup (after 3 seconds)
|
||||
@@ -332,66 +411,81 @@ function App() {
|
||||
initializeEditor();
|
||||
}, []);
|
||||
|
||||
// 初始化后订阅消息 | Subscribe to messages after initialization
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('dynamic-panel:open', (data: any) => {
|
||||
const unsubscribe = hub.subscribe('dynamic-panel:open', (data: any) => {
|
||||
const { panelId, title } = data;
|
||||
logger.info('Opening dynamic panel:', panelId, 'with title:', title);
|
||||
setActiveDynamicPanels((prev) => {
|
||||
const newPanels = prev.includes(panelId) ? prev : [...prev, panelId];
|
||||
return newPanels;
|
||||
});
|
||||
addDynamicPanel(panelId, title);
|
||||
setActivePanelId(panelId);
|
||||
|
||||
// 更新动态面板标题
|
||||
if (title) {
|
||||
setDynamicPanelTitles((prev) => {
|
||||
const newTitles = new Map(prev);
|
||||
newTitles.set(panelId, title);
|
||||
return newTitles;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub]);
|
||||
}, [initialized, addDynamicPanel, setActivePanelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('editor:fullscreen', (data: any) => {
|
||||
const unsubscribe = hub.subscribe('editor:fullscreen', (data: any) => {
|
||||
const { fullscreen } = data;
|
||||
logger.info('Editor fullscreen state changed:', fullscreen);
|
||||
setIsEditorFullscreen(fullscreen);
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub]);
|
||||
}, [initialized, setIsEditorFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('compiler:open-dialog', (data: {
|
||||
const unsubscribe = hub.subscribe('compiler:open-dialog', (data: {
|
||||
compilerId: string;
|
||||
currentFileName?: string;
|
||||
projectPath?: string;
|
||||
}) => {
|
||||
logger.info('Opening compiler dialog:', data.compilerId);
|
||||
setCompilerDialog({
|
||||
isOpen: true,
|
||||
compilerId: data.compilerId,
|
||||
currentFileName: data.currentFileName
|
||||
});
|
||||
openCompilerDialog(data.compilerId, data.currentFileName);
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub]);
|
||||
}, [initialized, openCompilerDialog]);
|
||||
|
||||
// 注册引擎快照请求处理器(用于预制体编辑模式)
|
||||
// Register engine snapshot request handlers (for prefab edit mode)
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribeSave = hub.onRequest<void, boolean>(
|
||||
'engine:saveSceneSnapshot',
|
||||
async () => {
|
||||
const engineService = EngineService.getInstance();
|
||||
return engineService.saveSceneSnapshot();
|
||||
}
|
||||
);
|
||||
|
||||
const unsubscribeRestore = hub.onRequest<void, boolean>(
|
||||
'engine:restoreSceneSnapshot',
|
||||
async () => {
|
||||
const engineService = EngineService.getInstance();
|
||||
return await engineService.restoreSceneSnapshot();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeSave?.();
|
||||
unsubscribeRestore?.();
|
||||
};
|
||||
}, [initialized]);
|
||||
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(t('loading.step1'));
|
||||
setIsLoading(true, t('loading.step1'));
|
||||
|
||||
const projectService = Core.services.resolve(ProjectService);
|
||||
|
||||
@@ -401,7 +495,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectServiceState(projectService);
|
||||
projectServiceRef.current = projectService;
|
||||
await projectService.openProject(projectPath);
|
||||
|
||||
// 注意:插件配置会在引擎初始化后加载和激活
|
||||
@@ -438,7 +532,7 @@ function App() {
|
||||
setProjectLoaded(true);
|
||||
|
||||
// 等待引擎初始化完成(Viewport 渲染后会触发引擎初始化)
|
||||
setLoadingMessage(t('loading.step2'));
|
||||
setIsLoading(true, t('loading.step2'));
|
||||
const engineService = EngineService.getInstance();
|
||||
|
||||
// 等待引擎初始化(最多等待 30 秒,因为需要等待 Viewport 渲染)
|
||||
@@ -449,12 +543,12 @@ function App() {
|
||||
|
||||
// 加载项目插件配置并激活插件(在引擎初始化后、模块系统初始化前)
|
||||
// Load project plugin config and activate plugins (after engine init, before module system init)
|
||||
if (pluginManager) {
|
||||
if (pluginManagerRef.current) {
|
||||
const pluginSettings = projectService.getPluginSettings();
|
||||
console.log('[App] Plugin settings from project:', pluginSettings);
|
||||
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
|
||||
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
|
||||
await pluginManager.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
|
||||
await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
|
||||
} else {
|
||||
console.log('[App] No plugin settings found in project config');
|
||||
}
|
||||
@@ -470,16 +564,16 @@ function App() {
|
||||
|
||||
setStatus(t('header.status.projectOpened'));
|
||||
|
||||
setLoadingMessage(t('loading.step3'));
|
||||
setIsLoading(true, t('loading.step3'));
|
||||
|
||||
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
await sceneManagerService.newScene();
|
||||
}
|
||||
|
||||
if (pluginManager) {
|
||||
setLoadingMessage(t('loading.loadingPlugins'));
|
||||
await pluginLoader.loadProjectPlugins(projectPath, pluginManager);
|
||||
if (pluginManagerRef.current) {
|
||||
setIsLoading(true, t('loading.loadingPlugins'));
|
||||
await pluginLoader.loadProjectPlugins(projectPath, pluginManagerRef.current);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -517,8 +611,7 @@ function App() {
|
||||
const fullProjectPath = `${projectPath}${sep}${projectName}`;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(t('project.creating'));
|
||||
setIsLoading(true, t('project.creating'));
|
||||
|
||||
const projectService = Core.services.resolve(ProjectService);
|
||||
if (!projectService) {
|
||||
@@ -533,7 +626,7 @@ function App() {
|
||||
|
||||
await projectService.createProject(fullProjectPath);
|
||||
|
||||
setLoadingMessage(t('project.createdOpening'));
|
||||
setIsLoading(true, t('project.createdOpening'));
|
||||
|
||||
await handleOpenRecentProject(fullProjectPath);
|
||||
} catch (error) {
|
||||
@@ -550,8 +643,7 @@ function App() {
|
||||
cancelText: t('common.cancel'),
|
||||
onConfirm: () => {
|
||||
setConfirmDialog(null);
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(t('project.opening'));
|
||||
setIsLoading(true, t('project.opening'));
|
||||
handleOpenRecentProject(fullProjectPath).catch((err) => {
|
||||
console.error('Failed to open project:', err);
|
||||
setIsLoading(false);
|
||||
@@ -701,13 +793,13 @@ function App() {
|
||||
const handleLocaleChange = (newLocale: Locale) => {
|
||||
changeLocale(newLocale);
|
||||
|
||||
// 通知所有已加载的插件更新语言
|
||||
if (pluginManager) {
|
||||
pluginManager.setLocale(newLocale);
|
||||
// 通知所有已加载的插件更新语言 | Notify all loaded plugins to update locale
|
||||
if (pluginManagerRef.current) {
|
||||
pluginManagerRef.current.setLocale(newLocale);
|
||||
|
||||
// 通过 MessageHub 通知需要重新获取节点模板
|
||||
if (messageHub) {
|
||||
messageHub.publish('locale:changed', { locale: newLocale });
|
||||
// 通过 MessageHub 通知需要重新获取节点模板 | Notify via MessageHub to refetch node templates
|
||||
if (messageHubRef.current) {
|
||||
messageHubRef.current.publish('locale:changed', { locale: newLocale });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -729,30 +821,30 @@ function App() {
|
||||
};
|
||||
|
||||
const handleReloadPlugins = async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
if (currentProjectPath && pluginManagerRef.current) {
|
||||
try {
|
||||
// 1. 关闭所有动态面板
|
||||
setActiveDynamicPanels([]);
|
||||
// 1. 关闭所有动态面板 | Close all dynamic panels
|
||||
clearDynamicPanels();
|
||||
|
||||
// 2. 清空当前面板列表(强制卸载插件面板组件)
|
||||
// 2. 清空当前面板列表(强制卸载插件面板组件)| Clear panel list (force unmount plugin panels)
|
||||
setPanels((prev) => prev.filter((p) =>
|
||||
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
|
||||
));
|
||||
|
||||
// 3. 等待React完成卸载
|
||||
// 3. 等待React完成卸载 | Wait for React to unmount
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall)
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall)| Unload all project plugins
|
||||
await pluginLoader.unloadProjectPlugins(pluginManagerRef.current);
|
||||
|
||||
// 5. 等待卸载完成
|
||||
// 5. 等待卸载完成 | Wait for unload
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 6. 重新加载插件
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
// 6. 重新加载插件 | Reload plugins
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManagerRef.current);
|
||||
|
||||
// 7. 触发面板重新渲染
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
// 7. 触发面板重新渲染 | Trigger panel re-render
|
||||
triggerPluginUpdate();
|
||||
|
||||
showToast(t('plugin.reloadedSuccess'), 'success');
|
||||
} catch (error) {
|
||||
@@ -762,93 +854,152 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
const corePanels: FlexDockPanel[] = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: t('panel.sceneHierarchy'),
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: t('panel.viewport'),
|
||||
content: <Viewport locale={locale} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: t('panel.inspector'),
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'forum',
|
||||
title: t('panel.forum'),
|
||||
content: <ForumPanel />,
|
||||
closable: true
|
||||
}
|
||||
];
|
||||
// ===== 面板构建(拆分依赖减少重建)| Panel building (split deps to reduce rebuilds) =====
|
||||
// 使用 ref 存储面板构建函数,避免频繁重建
|
||||
// Use ref to store panel builder function to avoid frequent rebuilds
|
||||
const buildPanelsRef = useRef<() => void>(() => {});
|
||||
|
||||
// 获取启用的插件面板
|
||||
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
|
||||
.filter((panelDesc) => {
|
||||
if (!panelDesc.component) {
|
||||
return false;
|
||||
}
|
||||
if (panelDesc.isDynamic) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((panelDesc) => {
|
||||
const Component = panelDesc.component;
|
||||
// 使用 titleKey 翻译,回退到 title
|
||||
// Use titleKey for translation, fallback to title
|
||||
const title = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title,
|
||||
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
// 更新面板构建函数(不触发重渲染)| Update panel builder (no re-render)
|
||||
buildPanelsRef.current = () => {
|
||||
if (!projectLoaded || !initialized) return;
|
||||
|
||||
// 添加激活的动态面板
|
||||
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
|
||||
.filter((panelId) => {
|
||||
const panelDesc = uiRegistry.getPanel(panelId);
|
||||
return panelDesc && (panelDesc.component || panelDesc.render);
|
||||
})
|
||||
.map((panelId) => {
|
||||
const panelDesc = uiRegistry.getPanel(panelId)!;
|
||||
// 优先使用动态标题,否则使用默认标题
|
||||
// Prefer dynamic title, fallback to default title
|
||||
const customTitle = dynamicPanelTitles.get(panelId);
|
||||
// 使用 titleKey 翻译,回退到 title
|
||||
const defaultTitle = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
|
||||
const hub = messageHubRef.current;
|
||||
const store = entityStoreRef.current;
|
||||
const registry = uiRegistryRef.current;
|
||||
const inspReg = inspectorRegistryRef.current;
|
||||
|
||||
// 支持 component 或 render 两种方式
|
||||
let content: React.ReactNode;
|
||||
if (panelDesc.component) {
|
||||
const Component = panelDesc.component;
|
||||
content = <Component projectPath={currentProjectPath} locale={locale} />;
|
||||
} else if (panelDesc.render) {
|
||||
content = panelDesc.render();
|
||||
}
|
||||
if (!hub || !store || !registry) return;
|
||||
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title: customTitle || defaultTitle,
|
||||
content,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
const corePanels: FlexDockPanel[] = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: t('panel.sceneHierarchy'),
|
||||
content: <SceneHierarchy entityStore={store} messageHub={hub} commandManager={commandManager} />,
|
||||
closable: false,
|
||||
layout: { position: 'right-top' }
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: t('panel.viewport'),
|
||||
content: <Viewport locale={locale} messageHub={hub} commandManager={commandManager} />,
|
||||
closable: false,
|
||||
layout: { position: 'center' }
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: t('panel.inspector'),
|
||||
content: <Inspector entityStore={store} messageHub={hub} inspectorRegistry={inspReg!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false,
|
||||
layout: { position: 'right-bottom' }
|
||||
},
|
||||
{
|
||||
id: 'forum',
|
||||
title: t('panel.forum'),
|
||||
content: <ForumPanel />,
|
||||
closable: true,
|
||||
layout: { position: 'center' }
|
||||
}
|
||||
];
|
||||
|
||||
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||
// 如果内容管理器已停靠,添加到面板 | If content browser is docked, add to panels
|
||||
if (isContentBrowserDocked) {
|
||||
corePanels.push({
|
||||
id: 'content-browser',
|
||||
title: t('panel.contentBrowser'),
|
||||
content: (
|
||||
<ContentBrowser
|
||||
projectPath={currentProjectPath}
|
||||
locale={locale}
|
||||
onOpenScene={handleOpenSceneByPath}
|
||||
isDrawer={false}
|
||||
onDockInLayout={() => setIsContentBrowserDocked(false)}
|
||||
/>
|
||||
),
|
||||
closable: true,
|
||||
layout: { position: 'bottom', weight: 20, requiresSeparateTabset: true }
|
||||
});
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
|
||||
|
||||
// 获取启用的插件面板 | Get enabled plugin panels
|
||||
const pluginPanels: FlexDockPanel[] = registry.getAllPanels()
|
||||
.filter((panelDesc) => panelDesc.component && !panelDesc.isDynamic)
|
||||
.map((panelDesc) => {
|
||||
const Component = panelDesc.component!;
|
||||
const title = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title,
|
||||
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
|
||||
// 添加激活的动态面板 | Add active dynamic panels
|
||||
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
|
||||
.filter((panelId) => {
|
||||
const panelDesc = registry.getPanel(panelId);
|
||||
return panelDesc && (panelDesc.component || panelDesc.render);
|
||||
})
|
||||
.map((panelId) => {
|
||||
const panelDesc = registry.getPanel(panelId)!;
|
||||
const customTitle = dynamicPanelTitles.get(panelId);
|
||||
const defaultTitle = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (panelDesc.component) {
|
||||
const Component = panelDesc.component;
|
||||
content = <Component projectPath={currentProjectPath} locale={locale} />;
|
||||
} else if (panelDesc.render) {
|
||||
content = panelDesc.render();
|
||||
}
|
||||
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title: customTitle || defaultTitle,
|
||||
content,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
|
||||
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||
};
|
||||
|
||||
// Effect 1: 项目加载后首次构建面板 | Build panels after project loads
|
||||
useEffect(() => {
|
||||
if (projectLoaded && initialized) {
|
||||
buildPanelsRef.current();
|
||||
}
|
||||
}, [projectLoaded, initialized]);
|
||||
|
||||
// Effect 2: 插件更新时重建 | Rebuild on plugin update
|
||||
useEffect(() => {
|
||||
if (projectLoaded && initialized && pluginUpdateTrigger > 0) {
|
||||
buildPanelsRef.current();
|
||||
}
|
||||
}, [projectLoaded, initialized, pluginUpdateTrigger]);
|
||||
|
||||
// Effect 3: 动态面板变化时重建 | Rebuild on dynamic panel change
|
||||
useEffect(() => {
|
||||
if (projectLoaded && initialized) {
|
||||
buildPanelsRef.current();
|
||||
}
|
||||
}, [projectLoaded, initialized, activeDynamicPanels, isContentBrowserDocked]);
|
||||
|
||||
// Effect 4: 语言变化时更新面板标题(不重建组件)| Update panel titles on locale change (don't rebuild components)
|
||||
useEffect(() => {
|
||||
if (projectLoaded && initialized) {
|
||||
// 只更新标题,不重建组件 | Only update titles, don't rebuild components
|
||||
setPanels((prev) => prev.map(panel => ({
|
||||
...panel,
|
||||
title: panel.id === 'scene-hierarchy' ? t('panel.sceneHierarchy') :
|
||||
panel.id === 'viewport' ? t('panel.viewport') :
|
||||
panel.id === 'inspector' ? t('panel.inspector') :
|
||||
panel.id === 'forum' ? t('panel.forum') :
|
||||
panel.id === 'content-browser' ? t('panel.contentBrowser') :
|
||||
panel.title
|
||||
})));
|
||||
}
|
||||
}, [locale, t, projectLoaded, initialized, setPanels]);
|
||||
|
||||
|
||||
if (!initialized) {
|
||||
@@ -985,7 +1136,7 @@ function App() {
|
||||
compilerId={compilerDialog.compilerId}
|
||||
projectPath={currentProjectPath}
|
||||
currentFileName={compilerDialog.currentFileName}
|
||||
onClose={() => setCompilerDialog({ isOpen: false, compilerId: '' })}
|
||||
onClose={closeCompilerDialog}
|
||||
onCompileComplete={(result) => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
@@ -997,12 +1148,18 @@ function App() {
|
||||
|
||||
<div className="editor-content">
|
||||
<FlexLayoutDockContainer
|
||||
ref={layoutContainerRef}
|
||||
panels={panels}
|
||||
activePanelId={activePanelId}
|
||||
messageHub={messageHub}
|
||||
messageHub={messageHubRef.current}
|
||||
onPanelClose={(panelId) => {
|
||||
logger.info('Panel closed:', panelId);
|
||||
setActiveDynamicPanels((prev) => prev.filter((id) => id !== panelId));
|
||||
// 如果关闭的是内容管理器,重置停靠状态
|
||||
// If closing content browser, reset dock state
|
||||
if (panelId === 'content-browser') {
|
||||
setIsContentBrowserDocked(false);
|
||||
}
|
||||
removeDynamicPanel(panelId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1015,6 +1172,8 @@ function App() {
|
||||
locale={locale}
|
||||
projectPath={currentProjectPath}
|
||||
onOpenScene={handleOpenSceneByPath}
|
||||
onDockContentBrowser={() => setIsContentBrowserDocked(true)}
|
||||
onResetLayout={() => layoutContainerRef.current?.resetLayout()}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ export class TauriFileAPI implements IFileAPI {
|
||||
return await TauriAPI.openSceneDialog();
|
||||
}
|
||||
|
||||
public async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await TauriAPI.saveSceneDialog(defaultName);
|
||||
public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await TauriAPI.saveSceneDialog(defaultName, scenesDir);
|
||||
}
|
||||
|
||||
public async readFileContent(path: string): Promise<string> {
|
||||
|
||||
@@ -31,11 +31,13 @@ export class TauriAPI {
|
||||
static async saveFileDialog(
|
||||
title?: string,
|
||||
defaultName?: string,
|
||||
filters?: FileFilter[]
|
||||
filters?: FileFilter[],
|
||||
defaultPath?: string
|
||||
): Promise<string | null> {
|
||||
return await invoke<string | null>('save_file_dialog', {
|
||||
title,
|
||||
defaultName,
|
||||
defaultPath,
|
||||
filters
|
||||
});
|
||||
}
|
||||
@@ -101,15 +103,19 @@ export class TauriAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* @param defaultName 默认文件名(可选)
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
* 打开保存场景对话框
|
||||
* Open save scene dialog
|
||||
*
|
||||
* @param defaultName 默认文件名(可选)| Default file name (optional)
|
||||
* @param scenesDir 场景目录路径(可选)| Scenes directory path (optional)
|
||||
* @returns 用户选择的文件路径,取消则返回 null | Selected file path or null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await this.saveFileDialog(
|
||||
'Save ECS Scene',
|
||||
defaultName,
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }]
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
scenesDir
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,6 +376,19 @@ export class TauriAPI {
|
||||
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
|
||||
return await invoke<EnvironmentCheckResult>('check_environment');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 esbuild(全局)
|
||||
* Install esbuild globally via npm
|
||||
*
|
||||
* This command installs esbuild globally using `npm install -g esbuild`.
|
||||
* 使用 `npm install -g esbuild` 全局安装 esbuild。
|
||||
*
|
||||
* @returns Promise that resolves when installation completes
|
||||
*/
|
||||
static async installEsbuild(): Promise<void> {
|
||||
return await invoke<void>('install_esbuild');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,9 @@ import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
|
||||
// 纯运行时插件 | Runtime-only plugins
|
||||
import { CameraPlugin } from '@esengine/camera';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
* 安装所有内置插件
|
||||
@@ -57,6 +60,7 @@ export class PluginInstaller {
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'CameraPlugin', plugin: CameraPlugin },
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
UIRegistry,
|
||||
@@ -175,6 +176,17 @@ export class ServiceRegistry {
|
||||
Core.services.registerInstance(SceneManagerService, sceneManager);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
|
||||
|
||||
// 注册预制体文件处理器 | Register prefab file handler
|
||||
fileActionRegistry.registerActionHandler({
|
||||
extensions: ['prefab'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 发布事件,由编辑器面板处理预制体选择/预览
|
||||
// Publish event for editor panels to handle prefab selection/preview
|
||||
messageHub.publish('prefab:selected', { path: filePath });
|
||||
}
|
||||
});
|
||||
|
||||
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
|
||||
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
|
||||
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
|
||||
|
||||
@@ -165,6 +165,33 @@ export class CommandManager {
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ComponentRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 添加组件命令
|
||||
*
|
||||
* 自动添加缺失的依赖组件(通过 @ECSComponent requires 选项声明)
|
||||
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
|
||||
*/
|
||||
export class AddComponentCommand extends BaseCommand {
|
||||
private component: Component | null = null;
|
||||
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
|
||||
private autoAddedDependencies: Component[] = [];
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
@@ -18,9 +24,12 @@ export class AddComponentCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 先添加缺失的依赖组件 | Add missing dependencies first
|
||||
this.addMissingDependencies();
|
||||
|
||||
this.component = new this.ComponentClass();
|
||||
|
||||
// 应用初始数据
|
||||
// 应用初始数据 | Apply initial data
|
||||
if (this.initialData) {
|
||||
for (const [key, value] of Object.entries(this.initialData)) {
|
||||
(this.component as any)[key] = value;
|
||||
@@ -35,20 +44,90 @@ export class AddComponentCommand extends BaseCommand {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缺失的依赖组件
|
||||
* Add missing dependency components
|
||||
*/
|
||||
private addMissingDependencies(): void {
|
||||
const dependencies = getComponentDependencies(this.ComponentClass);
|
||||
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRegistry = Core.services.tryResolve(ComponentRegistry) as ComponentRegistry | null;
|
||||
if (!componentRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const depName of dependencies) {
|
||||
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
|
||||
const depInfo = componentRegistry.getComponent(depName);
|
||||
|
||||
if (!depInfo?.type) {
|
||||
console.warn(`Dependency component not found in registry: ${depName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const DepClass = depInfo.type;
|
||||
|
||||
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
|
||||
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
|
||||
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
|
||||
|
||||
if (foundByName) {
|
||||
// 组件已存在(通过名称匹配),跳过添加
|
||||
// Component already exists (matched by name), skip adding
|
||||
continue;
|
||||
}
|
||||
|
||||
// 自动添加依赖组件 | Auto-add dependency component
|
||||
const depComponent = new DepClass();
|
||||
this.entity.addComponent(depComponent);
|
||||
this.autoAddedDependencies.push(depComponent);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: depComponent,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.component) return;
|
||||
|
||||
// 先移除主组件 | Remove main component first
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: this.ComponentClass.name
|
||||
componentType: getComponentTypeName(this.ComponentClass)
|
||||
});
|
||||
|
||||
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
|
||||
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
|
||||
const dep = this.autoAddedDependencies[i];
|
||||
if (dep) {
|
||||
this.entity.removeComponent(dep);
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: dep.constructor.name,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
this.autoAddedDependencies = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `添加组件: ${this.ComponentClass.name}`;
|
||||
const mainName = getComponentTypeName(this.ComponentClass);
|
||||
if (this.autoAddedDependencies.length > 0) {
|
||||
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
|
||||
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
|
||||
}
|
||||
return `添加组件: ${mainName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { ICommand } from './ICommand';
|
||||
export { BaseCommand } from './BaseCommand';
|
||||
export { CommandManager } from './CommandManager';
|
||||
export { TransformCommand, type TransformState, type TransformOperationType } from './transform/TransformCommand';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*
|
||||
* 将预制体实例的修改应用到源预制体文件。
|
||||
* Applies modifications from a prefab instance to the source prefab file.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*/
|
||||
export class ApplyPrefabCommand extends BaseCommand {
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
}
|
||||
|
||||
// 执行应用操作 | Execute apply operation
|
||||
this.success = await this.prefabService.applyToPrefab(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to apply changes to prefab');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `应用修改到预制体: ${prefabName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*
|
||||
* 断开实体与源预制体的关联,使其成为普通实体。
|
||||
* Breaks the link between an entity and its source prefab, making it a regular entity.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent, Core } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 保存的预制体实例组件状态
|
||||
* Saved prefab instance component state
|
||||
*/
|
||||
interface PrefabInstanceState {
|
||||
entityId: number;
|
||||
sourcePrefabGuid: string;
|
||||
sourcePrefabPath: string;
|
||||
isRoot: boolean;
|
||||
rootInstanceEntityId: number | null;
|
||||
modifiedProperties: string[];
|
||||
originalValues: Record<string, unknown>;
|
||||
instantiatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*/
|
||||
export class BreakPrefabLinkCommand extends BaseCommand {
|
||||
private removedStates: PrefabInstanceState[] = [];
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 保存所有将被移除的组件状态 | Save all component states that will be removed
|
||||
this.removedStates = [];
|
||||
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
throw new Error('Entity is not a prefab instance');
|
||||
}
|
||||
|
||||
// 保存根实体的状态 | Save root entity state
|
||||
this.saveComponentState(this.entity);
|
||||
|
||||
// 如果是根节点,也保存所有子实体的状态
|
||||
// If it's root, also save all children's state
|
||||
if (comp.isRoot) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.entities.forEach((e) => {
|
||||
if (e.id === this.entity.id) return;
|
||||
const childComp = e.getComponent(PrefabInstanceComponent);
|
||||
if (childComp && childComp.rootInstanceEntityId === this.entity.id) {
|
||||
this.saveComponentState(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 执行断开链接操作 | Execute break link operation
|
||||
this.prefabService.breakPrefabLink(this.entity);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复所有被移除的组件 | Restore all removed components
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
for (const state of this.removedStates) {
|
||||
const entity = scene.findEntityById(state.entityId);
|
||||
if (!entity) continue;
|
||||
|
||||
// 创建并恢复组件 | Create and restore component
|
||||
const comp = new PrefabInstanceComponent(
|
||||
state.sourcePrefabGuid,
|
||||
state.sourcePrefabPath,
|
||||
state.isRoot
|
||||
);
|
||||
comp.rootInstanceEntityId = state.rootInstanceEntityId;
|
||||
comp.modifiedProperties = state.modifiedProperties;
|
||||
comp.originalValues = state.originalValues;
|
||||
comp.instantiatedAt = state.instantiatedAt;
|
||||
|
||||
entity.addComponent(comp);
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('prefab:link:restored', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const state = this.removedStates.find(s => s.entityId === this.entity.id);
|
||||
const prefabName = state?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `断开预制体链接: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实体的预制体实例组件状态
|
||||
* Save entity's prefab instance component state
|
||||
*/
|
||||
private saveComponentState(entity: Entity): void {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) return;
|
||||
|
||||
this.removedStates.push({
|
||||
entityId: entity.id,
|
||||
sourcePrefabGuid: comp.sourcePrefabGuid,
|
||||
sourcePrefabPath: comp.sourcePrefabPath,
|
||||
isRoot: comp.isRoot,
|
||||
rootInstanceEntityId: comp.rootInstanceEntityId,
|
||||
modifiedProperties: [...comp.modifiedProperties],
|
||||
originalValues: { ...comp.originalValues },
|
||||
instantiatedAt: comp.instantiatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*
|
||||
* 从选中的实体创建预制体资产并保存到文件系统。
|
||||
* Creates a prefab asset from the selected entity and saves it to the file system.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, IFileAPI, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建预制体命令选项
|
||||
* Create prefab command options
|
||||
*/
|
||||
export interface CreatePrefabOptions {
|
||||
/** 预制体名称 | Prefab name */
|
||||
name: string;
|
||||
/** 保存路径(不包含文件名) | Save path (without filename) */
|
||||
savePath?: string;
|
||||
/** 预制体描述 | Prefab description */
|
||||
description?: string;
|
||||
/** 预制体标签 | Prefab tags */
|
||||
tags?: string[];
|
||||
/** 是否包含子实体 | Whether to include child entities */
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*/
|
||||
export class CreatePrefabCommand extends BaseCommand {
|
||||
private savedFilePath: string | null = null;
|
||||
private savedGuid: string | null = null;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private fileAPI: IFileAPI,
|
||||
private projectService: ProjectService | undefined,
|
||||
private assetRegistry: AssetRegistryService | null,
|
||||
private sourceEntity: Entity,
|
||||
private options: CreatePrefabOptions
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取层级系统 | Get hierarchy system
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 创建预制体数据 | Create prefab data
|
||||
const prefabData = PrefabSerializer.createPrefab(
|
||||
this.sourceEntity,
|
||||
{
|
||||
name: this.options.name,
|
||||
description: this.options.description,
|
||||
tags: this.options.tags,
|
||||
includeChildren: this.options.includeChildren ?? true
|
||||
},
|
||||
hierarchySystem ?? undefined
|
||||
);
|
||||
|
||||
// 序列化为 JSON | Serialize to JSON
|
||||
const prefabJson = PrefabSerializer.serialize(prefabData, true);
|
||||
|
||||
// 确定保存路径 | Determine save path
|
||||
let savePath = this.options.savePath;
|
||||
if (!savePath && this.projectService?.isProjectOpen()) {
|
||||
// 默认保存到项目的 prefabs 目录 | Default save to project's prefabs directory
|
||||
const currentProject = this.projectService.getCurrentProject();
|
||||
if (currentProject) {
|
||||
const projectRoot = currentProject.path;
|
||||
const sep = projectRoot.includes('\\') ? '\\' : '/';
|
||||
savePath = `${projectRoot}${sep}assets${sep}prefabs`;
|
||||
// 确保目录存在 | Ensure directory exists
|
||||
await this.fileAPI.createDirectory(savePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建完整文件路径 | Build complete file path
|
||||
let fullPath: string | null = null;
|
||||
if (savePath) {
|
||||
const sep = savePath.includes('\\') ? '\\' : '/';
|
||||
fullPath = `${savePath}${sep}${this.options.name}.prefab`;
|
||||
} else {
|
||||
// 打开保存对话框 | Open save dialog
|
||||
fullPath = await this.fileAPI.saveSceneDialog(`${this.options.name}.prefab`);
|
||||
}
|
||||
|
||||
if (!fullPath) {
|
||||
throw new Error('保存被取消 | Save cancelled');
|
||||
}
|
||||
|
||||
// 确保扩展名正确 | Ensure correct extension
|
||||
if (!fullPath.endsWith('.prefab')) {
|
||||
fullPath += '.prefab';
|
||||
}
|
||||
|
||||
// 保存文件 | Save file
|
||||
await this.fileAPI.writeFileContent(fullPath, prefabJson);
|
||||
this.savedFilePath = fullPath;
|
||||
|
||||
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
|
||||
if (this.assetRegistry) {
|
||||
const guid = await this.assetRegistry.registerAsset(fullPath);
|
||||
this.savedGuid = guid;
|
||||
console.log(`[CreatePrefabCommand] Registered prefab asset with GUID: ${guid}`);
|
||||
}
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:created', {
|
||||
path: fullPath,
|
||||
guid: this.savedGuid,
|
||||
name: this.options.name,
|
||||
sourceEntityId: this.sourceEntity.id,
|
||||
sourceEntityName: this.sourceEntity.name
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 预制体创建是一个文件系统操作,撤销意味着删除文件
|
||||
// Prefab creation is a file system operation, undo means deleting the file
|
||||
// 但为了安全,我们不自动删除文件,只是清除引用
|
||||
// But for safety, we don't auto-delete the file, just clear the reference
|
||||
this.savedFilePath = null;
|
||||
|
||||
// TODO: 如果需要完整撤销,可以实现文件删除
|
||||
// TODO: If full undo is needed, implement file deletion
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建预制体: ${this.options.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存的文件路径
|
||||
* Get saved file path
|
||||
*/
|
||||
getSavedFilePath(): string | null {
|
||||
return this.savedFilePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*
|
||||
* 从预制体资产创建实体实例。
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 实例化预制体命令选项
|
||||
* Instantiate prefab command options
|
||||
*/
|
||||
export interface InstantiatePrefabOptions {
|
||||
/** 父实体 | Parent entity */
|
||||
parent?: Entity;
|
||||
/** 实例名称(可选,默认使用预制体名称) | Instance name (optional, defaults to prefab name) */
|
||||
name?: string;
|
||||
/** 位置覆盖 | Position override */
|
||||
position?: { x: number; y: number };
|
||||
/** 是否追踪为预制体实例 | Whether to track as prefab instance */
|
||||
trackInstance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*/
|
||||
export class InstantiatePrefabCommand extends BaseCommand {
|
||||
private createdEntity: Entity | null = null;
|
||||
private createdEntityIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private prefabData: PrefabData,
|
||||
private options: InstantiatePrefabOptions = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
this.prefabData,
|
||||
scene,
|
||||
componentRegistry,
|
||||
{
|
||||
parentId: this.options.parent?.id,
|
||||
name: this.options.name,
|
||||
position: this.options.position,
|
||||
trackInstance: this.options.trackInstance ?? true
|
||||
}
|
||||
);
|
||||
|
||||
// 收集所有创建的实体 ID(用于撤销) | Collect all created entity IDs (for undo)
|
||||
this.collectEntityIds(this.createdEntity);
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 选中创建的实体 | Select created entity
|
||||
this.entityStore.selectEntity(this.createdEntity);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:added', { entity: this.createdEntity });
|
||||
this.messageHub.publish('prefab:instantiated', {
|
||||
entity: this.createdEntity,
|
||||
prefabName: this.prefabData.metadata.name,
|
||||
prefabGuid: this.prefabData.metadata.guid
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdEntity) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 移除所有创建的实体 | Remove all created entities
|
||||
for (const entityId of this.createdEntityIds) {
|
||||
const entity = scene.findEntityById(entityId);
|
||||
if (entity) {
|
||||
scene.entities.remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:removed', { entityId: this.createdEntity.id });
|
||||
|
||||
this.createdEntity = null;
|
||||
this.createdEntityIds = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const name = this.options.name || this.prefabData.metadata.name;
|
||||
return `实例化预制体: ${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建的根实体
|
||||
* Get created root entity
|
||||
*/
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.createdEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集实体 ID
|
||||
* Recursively collect entity IDs
|
||||
*/
|
||||
private collectEntityIds(entity: Entity): void {
|
||||
this.createdEntityIds.push(entity.id);
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (hierarchySystem) {
|
||||
const children = hierarchySystem.getChildren(entity);
|
||||
for (const child of children) {
|
||||
this.collectEntityIds(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*
|
||||
* 将预制体实例还原为源预制体的状态。
|
||||
* Reverts a prefab instance to the state of the source prefab.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 组件快照
|
||||
* Component snapshot
|
||||
*/
|
||||
interface ComponentSnapshot {
|
||||
typeName: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*/
|
||||
export class RevertPrefabCommand extends BaseCommand {
|
||||
private previousSnapshots: ComponentSnapshot[] = [];
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
|
||||
// 保存所有修改的属性当前值 | Save current values of all modified properties
|
||||
this.previousSnapshots = [];
|
||||
for (const key of comp.modifiedProperties) {
|
||||
const [componentType, ...pathParts] = key.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
const value = this.getNestedValue(compInstance, propertyPath);
|
||||
this.previousSnapshots.push({
|
||||
typeName: key,
|
||||
data: { value: this.deepClone(value) }
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行还原操作 | Execute revert operation
|
||||
this.success = await this.prefabService.revertInstance(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to revert prefab instance');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改的属性值 | Restore modified property values
|
||||
for (const snapshot of this.previousSnapshots) {
|
||||
const [componentType, ...pathParts] = snapshot.typeName.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
this.setNestedValue(compInstance, propertyPath, snapshot.data.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `还原预制体实例: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取嵌套属性值
|
||||
* Get nested property value
|
||||
*/
|
||||
private getNestedValue(obj: any, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置嵌套属性值
|
||||
* Set nested property value
|
||||
*/
|
||||
private setNestedValue(obj: any, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (current[key] === null || current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝值
|
||||
* Deep clone value
|
||||
*/
|
||||
private deepClone(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 预制体命令导出
|
||||
* Prefab commands export
|
||||
*/
|
||||
|
||||
export { CreatePrefabCommand } from './CreatePrefabCommand';
|
||||
export type { CreatePrefabOptions } from './CreatePrefabCommand';
|
||||
|
||||
export { InstantiatePrefabCommand } from './InstantiatePrefabCommand';
|
||||
export type { InstantiatePrefabOptions } from './InstantiatePrefabCommand';
|
||||
|
||||
export { ApplyPrefabCommand } from './ApplyPrefabCommand';
|
||||
export { RevertPrefabCommand } from './RevertPrefabCommand';
|
||||
export { BreakPrefabLinkCommand } from './BreakPrefabLinkCommand';
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* Transform 状态快照
|
||||
* Transform state snapshot
|
||||
*/
|
||||
export interface TransformState {
|
||||
// TransformComponent
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
positionZ?: number;
|
||||
rotationX?: number;
|
||||
rotationY?: number;
|
||||
rotationZ?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
scaleZ?: number;
|
||||
// UITransformComponent
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
uiScaleX?: number;
|
||||
uiScaleY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变换操作类型
|
||||
* Transform operation type
|
||||
*/
|
||||
export type TransformOperationType = 'move' | 'rotate' | 'scale';
|
||||
|
||||
/**
|
||||
* 变换命令
|
||||
* Transform command for undo/redo support
|
||||
*/
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private readonly componentType: 'transform' | 'uiTransform';
|
||||
private readonly timestamp: number;
|
||||
|
||||
constructor(
|
||||
private readonly messageHub: MessageHub,
|
||||
private readonly entity: Entity,
|
||||
private readonly component: Component,
|
||||
private readonly operationType: TransformOperationType,
|
||||
private readonly oldState: TransformState,
|
||||
private newState: TransformState
|
||||
) {
|
||||
super();
|
||||
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.applyState(this.newState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.applyState(this.oldState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const opNames: Record<TransformOperationType, string> = {
|
||||
move: '移动',
|
||||
rotate: '旋转',
|
||||
scale: '缩放'
|
||||
};
|
||||
return `${opNames[this.operationType]} ${this.entity.name || 'Entity'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以与另一个命令合并
|
||||
* 只有相同实体、相同操作类型、且在短时间内的命令可以合并
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof TransformCommand)) return false;
|
||||
|
||||
// 相同实体、相同组件、相同操作类型
|
||||
if (this.entity !== other.entity) return false;
|
||||
if (this.component !== other.component) return false;
|
||||
if (this.operationType !== other.operationType) return false;
|
||||
|
||||
// 时间间隔小于 500ms 才能合并(连续拖动)
|
||||
const timeDiff = other.timestamp - this.timestamp;
|
||||
return timeDiff < 500;
|
||||
}
|
||||
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof TransformCommand)) {
|
||||
throw new Error('无法合并不同类型的命令');
|
||||
}
|
||||
|
||||
// 保留原始 oldState,使用新命令的 newState
|
||||
return new TransformCommand(
|
||||
this.messageHub,
|
||||
this.entity,
|
||||
this.component,
|
||||
this.operationType,
|
||||
this.oldState,
|
||||
other.newState
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用变换状态
|
||||
* Apply transform state
|
||||
*/
|
||||
private applyState(state: TransformState): void {
|
||||
if (this.componentType === 'transform') {
|
||||
const transform = this.component as TransformComponent;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
} else {
|
||||
const uiTransform = this.component as UITransformComponent;
|
||||
if (state.x !== undefined) uiTransform.x = state.x;
|
||||
if (state.y !== undefined) uiTransform.y = state.y;
|
||||
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
|
||||
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
|
||||
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知属性变更
|
||||
* Notify property change
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
const propertyName = this.operationType === 'move'
|
||||
? (this.componentType === 'transform' ? 'position' : 'x')
|
||||
: this.operationType === 'rotate'
|
||||
? 'rotation'
|
||||
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName,
|
||||
value: this.componentType === 'transform'
|
||||
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
|
||||
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
|
||||
});
|
||||
|
||||
// 通知 Inspector 刷新 | Notify Inspector to refresh
|
||||
this.messageHub.publish('entity:select', { entityId: this.entity.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 TransformComponent 捕获状态
|
||||
* Capture state from TransformComponent
|
||||
*/
|
||||
static captureTransformState(transform: TransformComponent): TransformState {
|
||||
return {
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
positionZ: transform.position.z,
|
||||
rotationX: transform.rotation.x,
|
||||
rotationY: transform.rotation.y,
|
||||
rotationZ: transform.rotation.z,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y,
|
||||
scaleZ: transform.scale.z
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 UITransformComponent 捕获状态
|
||||
* Capture state from UITransformComponent
|
||||
*/
|
||||
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
|
||||
return {
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation,
|
||||
uiScaleX: uiTransform.scaleX,
|
||||
uiScaleY: uiTransform.scaleY
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,44 +5,33 @@
|
||||
* Provides build settings interface for managing platform builds,
|
||||
* scenes, and player settings.
|
||||
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
|
||||
*
|
||||
* 使用 Zustand store 管理状态,避免 useEffect 过多导致的重渲染问题
|
||||
* Uses Zustand store for state management to avoid re-render issues from too many useEffects
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
|
||||
Plus, Minus, ChevronDown, ChevronRight, Settings,
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check, FolderOpen
|
||||
} from 'lucide-react';
|
||||
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService, ProjectService, BuildSettingsConfig } from '@esengine/editor-core';
|
||||
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import { BuildStatus } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
useBuildSettingsStore,
|
||||
type PlatformType,
|
||||
type BuildProfile,
|
||||
type BuildSettings,
|
||||
} from '../stores/BuildSettingsStore';
|
||||
import '../styles/BuildSettingsPanel.css';
|
||||
|
||||
// ==================== Types | 类型定义 ====================
|
||||
|
||||
/** Platform type | 平台类型 */
|
||||
type PlatformType =
|
||||
| 'windows'
|
||||
| 'macos'
|
||||
| 'linux'
|
||||
| 'android'
|
||||
| 'ios'
|
||||
| 'web'
|
||||
| 'wechat-minigame';
|
||||
|
||||
/** Build profile | 构建配置 */
|
||||
interface BuildProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: PlatformType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Scene entry | 场景条目 */
|
||||
interface SceneEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
// 类型定义已移至 BuildSettingsStore.ts
|
||||
// Type definitions moved to BuildSettingsStore.ts
|
||||
|
||||
/** Platform configuration | 平台配置 */
|
||||
interface PlatformConfig {
|
||||
@@ -52,21 +41,6 @@ interface PlatformConfig {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Build settings | 构建设置 */
|
||||
interface BuildSettings {
|
||||
scenes: SceneEntry[];
|
||||
scriptingDefines: string[];
|
||||
companyName: string;
|
||||
productName: string;
|
||||
version: string;
|
||||
// Platform-specific | 平台特定
|
||||
developmentBuild: boolean;
|
||||
sourceMap: boolean;
|
||||
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
|
||||
/** Web build mode | Web 构建模式 */
|
||||
buildMode: 'split-bundles' | 'single-bundle' | 'single-file';
|
||||
}
|
||||
|
||||
// ==================== Constants | 常量 ====================
|
||||
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
@@ -79,18 +53,6 @@ const PLATFORMS: PlatformConfig[] = [
|
||||
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
|
||||
];
|
||||
|
||||
const DEFAULT_SETTINGS: BuildSettings = {
|
||||
scenes: [],
|
||||
scriptingDefines: [],
|
||||
companyName: 'DefaultCompany',
|
||||
productName: 'MyGame',
|
||||
version: '0.1.0',
|
||||
developmentBuild: false,
|
||||
sourceMap: false,
|
||||
compressionMethod: 'Default',
|
||||
buildMode: 'split-bundles',
|
||||
};
|
||||
|
||||
// ==================== Status Key Mapping | 状态键映射 ====================
|
||||
|
||||
/** Map BuildStatus to translation key | 将 BuildStatus 映射到翻译键 */
|
||||
@@ -202,269 +164,81 @@ export function BuildSettingsPanel({
|
||||
}: BuildSettingsPanelProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// State | 状态
|
||||
const [profiles, setProfiles] = useState<BuildProfile[]>([
|
||||
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
|
||||
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
|
||||
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
|
||||
]);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
|
||||
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
|
||||
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
sceneList: true,
|
||||
scriptingDefines: true,
|
||||
platformSettings: true,
|
||||
playerSettings: true,
|
||||
});
|
||||
// 使用 Zustand store 替代本地状态(使用 useShallow 避免不必要的重渲染)
|
||||
// Use Zustand store instead of local state (use useShallow to avoid unnecessary re-renders)
|
||||
const {
|
||||
profiles,
|
||||
selectedPlatform,
|
||||
selectedProfile,
|
||||
settings,
|
||||
expandedSections,
|
||||
isBuilding,
|
||||
buildProgress,
|
||||
buildResult,
|
||||
showBuildProgress,
|
||||
} = useBuildSettingsStore(useShallow(state => ({
|
||||
profiles: state.profiles,
|
||||
selectedPlatform: state.selectedPlatform,
|
||||
selectedProfile: state.selectedProfile,
|
||||
settings: state.settings,
|
||||
expandedSections: state.expandedSections,
|
||||
isBuilding: state.isBuilding,
|
||||
buildProgress: state.buildProgress,
|
||||
buildResult: state.buildResult,
|
||||
showBuildProgress: state.showBuildProgress,
|
||||
})));
|
||||
|
||||
// Build state | 构建状态
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [buildResult, setBuildResult] = useState<{
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
duration: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [showBuildProgress, setShowBuildProgress] = useState(false);
|
||||
const buildAbortRef = useRef<AbortController | null>(null);
|
||||
// 获取 store actions(通过 getState 获取,这些不会触发重渲染)
|
||||
// Get store actions via getState (these don't trigger re-renders)
|
||||
const store = useBuildSettingsStore.getState();
|
||||
const {
|
||||
setSelectedPlatform: handlePlatformSelect,
|
||||
setSelectedProfile: handleProfileSelect,
|
||||
addProfile: handleAddProfile,
|
||||
updateSettings,
|
||||
setSceneEnabled,
|
||||
addDefine,
|
||||
removeDefine: handleRemoveDefine,
|
||||
toggleSection,
|
||||
cancelBuild: handleCancelBuild,
|
||||
closeBuildProgress: handleCloseBuildProgress,
|
||||
} = store;
|
||||
|
||||
// Handlers | 处理函数
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePlatformSelect = useCallback((platform: PlatformType) => {
|
||||
setSelectedPlatform(platform);
|
||||
// Find first profile for this platform | 查找此平台的第一个配置
|
||||
const profile = profiles.find(p => p.platform === platform);
|
||||
setSelectedProfile(profile || null);
|
||||
}, [profiles]);
|
||||
|
||||
const handleProfileSelect = useCallback((profile: BuildProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setSelectedPlatform(profile.platform);
|
||||
}, []);
|
||||
|
||||
const handleAddProfile = useCallback(() => {
|
||||
const newProfile: BuildProfile = {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: `${selectedPlatform} - New Profile`,
|
||||
platform: selectedPlatform,
|
||||
};
|
||||
setProfiles(prev => [...prev, newProfile]);
|
||||
setSelectedProfile(newProfile);
|
||||
}, [selectedPlatform]);
|
||||
|
||||
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
|
||||
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
|
||||
const platformMap: Record<PlatformType, BuildPlatform> = {
|
||||
'web': BuildPlatform.Web,
|
||||
'wechat-minigame': BuildPlatform.WeChatMiniGame,
|
||||
'windows': BuildPlatform.Desktop,
|
||||
'macos': BuildPlatform.Desktop,
|
||||
'linux': BuildPlatform.Desktop,
|
||||
'android': BuildPlatform.Android,
|
||||
'ios': BuildPlatform.iOS
|
||||
};
|
||||
return platformMap[platformType];
|
||||
}, []);
|
||||
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) {
|
||||
return;
|
||||
// 初始化 store(仅在 mount 时)
|
||||
// Initialize store (only on mount)
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
useBuildSettingsStore.getState().initialize({
|
||||
projectPath,
|
||||
buildService,
|
||||
projectService,
|
||||
availableScenes,
|
||||
});
|
||||
}
|
||||
return () => useBuildSettingsStore.getState().cleanup();
|
||||
}, [projectPath]); // 只依赖 projectPath,避免频繁重初始化
|
||||
|
||||
// Call external handler if provided | 如果提供了外部处理程序则调用
|
||||
// 当前平台的配置列表(使用 useMemo 避免每次重新过滤)
|
||||
// Profiles for current platform (use useMemo to avoid re-filtering every time)
|
||||
const platformProfiles = useMemo(
|
||||
() => profiles.filter(p => p.platform === selectedPlatform),
|
||||
[profiles, selectedPlatform]
|
||||
);
|
||||
|
||||
// 构建处理 | Build handler
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) return;
|
||||
|
||||
// Call external handler if provided
|
||||
if (onBuild) {
|
||||
onBuild(selectedProfile, settings);
|
||||
}
|
||||
|
||||
// Use BuildService if available | 如果可用则使用 BuildService
|
||||
if (buildService) {
|
||||
setIsBuilding(true);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
setShowBuildProgress(true);
|
||||
|
||||
try {
|
||||
const platform = getPlatformEnum(selectedProfile.platform);
|
||||
const baseConfig = {
|
||||
platform,
|
||||
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
|
||||
isRelease: !settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
|
||||
};
|
||||
|
||||
// Build platform-specific config | 构建平台特定配置
|
||||
let buildConfig: BuildConfig;
|
||||
if (platform === BuildPlatform.Web) {
|
||||
const webConfig: WebBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.Web,
|
||||
buildMode: settings.buildMode,
|
||||
generateHtml: true,
|
||||
minify: !settings.developmentBuild,
|
||||
generateAssetCatalog: true,
|
||||
assetLoadingStrategy: 'on-demand'
|
||||
};
|
||||
buildConfig = webConfig;
|
||||
} else if (platform === BuildPlatform.WeChatMiniGame) {
|
||||
const wechatConfig: WeChatBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.WeChatMiniGame,
|
||||
appId: '',
|
||||
useSubpackages: false,
|
||||
mainPackageLimit: 4096,
|
||||
usePlugins: false
|
||||
};
|
||||
buildConfig = wechatConfig;
|
||||
} else {
|
||||
buildConfig = baseConfig;
|
||||
}
|
||||
|
||||
// Execute build with progress callback | 执行构建并传入进度回调
|
||||
const result = await buildService.build(buildConfig, (progress) => {
|
||||
setBuildProgress(progress);
|
||||
});
|
||||
|
||||
// Set result | 设置结果
|
||||
setBuildResult({
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
duration: result.duration,
|
||||
warnings: result.warnings,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
setBuildResult({
|
||||
success: false,
|
||||
outputPath: '',
|
||||
duration: 0,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
|
||||
|
||||
// Load saved build settings from project config
|
||||
// 从项目配置加载已保存的构建设置
|
||||
useEffect(() => {
|
||||
if (!projectService) return;
|
||||
|
||||
const savedSettings = projectService.getBuildSettings();
|
||||
if (savedSettings) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: savedSettings.scriptingDefines || [],
|
||||
companyName: savedSettings.companyName || prev.companyName,
|
||||
productName: savedSettings.productName || prev.productName,
|
||||
version: savedSettings.version || prev.version,
|
||||
developmentBuild: savedSettings.developmentBuild ?? prev.developmentBuild,
|
||||
sourceMap: savedSettings.sourceMap ?? prev.sourceMap,
|
||||
compressionMethod: savedSettings.compressionMethod || prev.compressionMethod,
|
||||
buildMode: savedSettings.buildMode || prev.buildMode
|
||||
}));
|
||||
}
|
||||
}, [projectService]);
|
||||
|
||||
// Initialize scenes from availableScenes prop and saved settings
|
||||
// 从 availableScenes prop 和已保存设置初始化场景列表
|
||||
useEffect(() => {
|
||||
if (availableScenes && availableScenes.length > 0) {
|
||||
const savedSettings = projectService?.getBuildSettings();
|
||||
const savedScenes = savedSettings?.scenes || [];
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: availableScenes.map(path => ({
|
||||
path,
|
||||
enabled: savedScenes.length > 0 ? savedScenes.includes(path) : true
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}, [availableScenes, projectService]);
|
||||
|
||||
// Auto-save build settings when changed
|
||||
// 设置变化时自动保存
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
if (!projectService) return;
|
||||
|
||||
// Debounce save to avoid too many writes
|
||||
// 防抖保存,避免频繁写入
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
const configToSave: BuildSettingsConfig = {
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path),
|
||||
scriptingDefines: settings.scriptingDefines,
|
||||
companyName: settings.companyName,
|
||||
productName: settings.productName,
|
||||
version: settings.version,
|
||||
developmentBuild: settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
compressionMethod: settings.compressionMethod,
|
||||
buildMode: settings.buildMode
|
||||
};
|
||||
projectService.updateBuildSettings(configToSave);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings, projectService]);
|
||||
|
||||
// Monitor build progress from service | 从服务监控构建进度
|
||||
useEffect(() => {
|
||||
if (!buildService || !isBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const task = buildService.getCurrentTask();
|
||||
if (task) {
|
||||
setBuildProgress(task.progress);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [buildService, isBuilding]);
|
||||
|
||||
const handleCancelBuild = useCallback(() => {
|
||||
if (buildService) {
|
||||
buildService.cancelBuild();
|
||||
}
|
||||
}, [buildService]);
|
||||
|
||||
const handleCloseBuildProgress = useCallback(() => {
|
||||
if (!isBuilding) {
|
||||
setShowBuildProgress(false);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
}
|
||||
}, [isBuilding]);
|
||||
|
||||
// Get status message | 获取状态消息
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
return t(buildStatusKeys[status]) || status;
|
||||
}, [t]);
|
||||
// 使用 store 的构建操作 | Use store's build action
|
||||
await useBuildSettingsStore.getState().startBuild();
|
||||
}, [selectedProfile, projectPath, onBuild, settings]);
|
||||
|
||||
// 添加当前场景 | Add current scene
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (!sceneManager) {
|
||||
console.warn('SceneManagerService not available');
|
||||
@@ -479,36 +253,29 @@ export function BuildSettingsPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scene is already in the list | 检查场景是否已在列表中
|
||||
// 检查场景是否已在列表中 | Check if scene is already in the list
|
||||
const exists = settings.scenes.some(s => s.path === currentScenePath);
|
||||
if (exists) {
|
||||
console.log('Scene already in list:', currentScenePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current scene to the list | 将当前场景添加到列表中
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
|
||||
}));
|
||||
// 使用 store 添加场景 | Use store to add scene
|
||||
useBuildSettingsStore.getState().addScene(currentScenePath);
|
||||
}, [sceneManager, settings.scenes]);
|
||||
|
||||
// 添加脚本定义(带 prompt)| Add scripting define (with prompt)
|
||||
const handleAddDefine = useCallback(() => {
|
||||
const define = prompt('Enter scripting define:');
|
||||
if (define) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: [...prev.scriptingDefines, define]
|
||||
}));
|
||||
addDefine(define);
|
||||
}
|
||||
}, []);
|
||||
}, [addDefine]);
|
||||
|
||||
const handleRemoveDefine = useCallback((index: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
// 获取状态消息 | Get status message
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
return t(buildStatusKeys[status]) || status;
|
||||
}, [t]);
|
||||
|
||||
// Get platform config | 获取平台配置
|
||||
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
|
||||
@@ -634,14 +401,7 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scene.enabled}
|
||||
onChange={e => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: prev.scenes.map((s, i) =>
|
||||
i === index ? { ...s, enabled: e.target.checked } : s
|
||||
)
|
||||
}));
|
||||
}}
|
||||
onChange={e => setSceneEnabled(index, e.target.checked)}
|
||||
/>
|
||||
<span>{scene.path}</span>
|
||||
</div>
|
||||
@@ -713,10 +473,7 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.developmentBuild}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
developmentBuild: e.target.checked
|
||||
}))}
|
||||
onChange={e => updateSettings({ developmentBuild: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
@@ -724,20 +481,14 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sourceMap}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
sourceMap: e.target.checked
|
||||
}))}
|
||||
onChange={e => updateSettings({ sourceMap: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.compressionMethod')}</label>
|
||||
<select
|
||||
value={settings.compressionMethod}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
compressionMethod: e.target.value as any
|
||||
}))}
|
||||
onChange={e => updateSettings({ compressionMethod: e.target.value as 'Default' | 'LZ4' | 'LZ4HC' })}
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
<option value="LZ4">LZ4</option>
|
||||
@@ -749,10 +500,7 @@ export function BuildSettingsPanel({
|
||||
<div className="build-settings-toggle-group">
|
||||
<select
|
||||
value={settings.buildMode}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file'
|
||||
}))}
|
||||
onChange={e => updateSettings({ buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file' })}
|
||||
>
|
||||
<option value="split-bundles">{t('buildSettings.splitBundles')}</option>
|
||||
<option value="single-bundle">{t('buildSettings.singleBundle')}</option>
|
||||
@@ -798,10 +546,7 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="text"
|
||||
value={settings.companyName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
companyName: e.target.value
|
||||
}))}
|
||||
onChange={e => updateSettings({ companyName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
@@ -809,10 +554,7 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="text"
|
||||
value={settings.productName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
productName: e.target.value
|
||||
}))}
|
||||
onChange={e => updateSettings({ productName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
@@ -820,10 +562,7 @@ export function BuildSettingsPanel({
|
||||
<input
|
||||
type="text"
|
||||
value={settings.version}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
version: e.target.value
|
||||
}))}
|
||||
onChange={e => updateSettings({ version: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
@@ -867,11 +606,11 @@ export function BuildSettingsPanel({
|
||||
{/* Status Icon | 状态图标 */}
|
||||
<div className="build-progress-status-icon">
|
||||
{isBuilding ? (
|
||||
<Loader2 size={48} className="build-progress-spinner" />
|
||||
<Loader2 size={36} className="build-progress-spinner" />
|
||||
) : buildResult?.success ? (
|
||||
<CheckCircle size={48} className="build-progress-success" />
|
||||
<CheckCircle size={40} className="build-progress-success" />
|
||||
) : (
|
||||
<XCircle size={48} className="build-progress-error" />
|
||||
<XCircle size={40} className="build-progress-error" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -950,12 +689,29 @@ export function BuildSettingsPanel({
|
||||
{t('buildSettings.cancel')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t('buildSettings.close')}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t('buildSettings.close')}
|
||||
</button>
|
||||
{buildResult?.success && buildResult.outputPath && (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={() => {
|
||||
// 使用 Tauri 打开文件夹
|
||||
// Use Tauri to open folder
|
||||
invoke('open_folder', { path: buildResult.outputPath }).catch(e => {
|
||||
console.error('Failed to open folder:', e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('buildSettings.openFolder')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 用于浏览和管理项目资产
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import {
|
||||
@@ -38,10 +38,13 @@ import {
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Database,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
X,
|
||||
FolderPlus,
|
||||
Inbox
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate } from '@esengine/editor-core';
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
@@ -126,6 +129,32 @@ function isRootManagedDirectory(folderPath: string, projectPath: string | null):
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮搜索文本
|
||||
* Highlight search text in a string
|
||||
*/
|
||||
function highlightSearchText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) return text;
|
||||
|
||||
const before = text.substring(0, index);
|
||||
const match = text.substring(index, index + query.length);
|
||||
const after = text.substring(index + query.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<span className="search-highlight">{match}</span>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取资产类型显示名称
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
@@ -179,6 +208,10 @@ export function ContentBrowser({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
// 隐藏的文件扩展名(默认隐藏 .meta)| Hidden file extensions (hide .meta by default)
|
||||
const [hiddenExtensions, setHiddenExtensions] = useState<Set<string>>(new Set(['meta']));
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||
|
||||
// Folder tree state
|
||||
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
@@ -474,11 +507,33 @@ export class ${className} {
|
||||
setDeleteConfirmDialog(asset);
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+A - 全选 | Select all
|
||||
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
// 计算当前过滤后的资产 | Calculate currently filtered assets
|
||||
const currentFiltered = searchQuery.trim()
|
||||
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: assets;
|
||||
const allPaths = new Set(currentFiltered.map(a => a.path));
|
||||
setSelectedPaths(allPaths);
|
||||
const lastItem = currentFiltered[currentFiltered.length - 1];
|
||||
if (lastItem) {
|
||||
setLastSelectedPath(lastItem.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape - 取消选择 | Deselect all
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSelectedPaths(new Set());
|
||||
setLastSelectedPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]);
|
||||
}, [selectedPaths, assets, searchQuery, renameDialog, deleteConfirmDialog, createFileDialog]);
|
||||
|
||||
const getTemplateLabel = (label: string): string => {
|
||||
// Map template labels to translation keys
|
||||
@@ -582,6 +637,21 @@ export class ${className} {
|
||||
}
|
||||
}, [currentPath, projectPath, loadAssets, buildFolderTree]);
|
||||
|
||||
// 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showFilterDropdown) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.cb-filter-wrapper')) {
|
||||
setShowFilterDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showFilterDropdown]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
@@ -618,6 +688,44 @@ export class ${className} {
|
||||
}
|
||||
}, [expandedFolders, projectPath, buildFolderTree]);
|
||||
|
||||
// Subscribe to asset change events to refresh content
|
||||
// 订阅资产变化事件以刷新内容
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const handleAssetChange = (data: { type: string; path: string; relativePath: string; guid: string }) => {
|
||||
// Check if the changed file is in the current directory
|
||||
// 检查变化的文件是否在当前目录中
|
||||
if (!currentPath || !data.path) return;
|
||||
|
||||
const normalizedPath = data.path.replace(/\\/g, '/');
|
||||
const normalizedCurrentPath = currentPath.replace(/\\/g, '/');
|
||||
const parentDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
|
||||
|
||||
if (parentDir === normalizedCurrentPath) {
|
||||
// Refresh current directory
|
||||
// 刷新当前目录
|
||||
loadAssets(currentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetsRefresh = () => {
|
||||
// Refresh current directory when generic refresh is requested
|
||||
// 当请求通用刷新时刷新当前目录
|
||||
if (currentPath) {
|
||||
loadAssets(currentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubChange = messageHub.subscribe('assets:changed', handleAssetChange);
|
||||
const unsubRefresh = messageHub.subscribe('assets:refresh', handleAssetsRefresh);
|
||||
|
||||
return () => {
|
||||
unsubChange();
|
||||
unsubRefresh();
|
||||
};
|
||||
}, [messageHub, currentPath, loadAssets]);
|
||||
|
||||
// Handle reveal path - navigate to folder and select file
|
||||
const prevRevealPath = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -788,7 +896,13 @@ export class ${className} {
|
||||
const handleFolderDragOver = useCallback((e: React.DragEvent, folderPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(folderPath);
|
||||
// 支持资产拖放和实体拖放 | Support asset drag and entity drag
|
||||
const hasAsset = e.dataTransfer.types.includes('asset-path');
|
||||
const hasEntity = e.dataTransfer.types.includes('entity-id');
|
||||
if (hasAsset || hasEntity) {
|
||||
e.dataTransfer.dropEffect = hasEntity ? 'copy' : 'move';
|
||||
setDragOverFolder(folderPath);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFolderDragLeave = useCallback((e: React.DragEvent) => {
|
||||
@@ -802,11 +916,75 @@ export class ${className} {
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(null);
|
||||
|
||||
// 检查是否是资产移动 | Check if it's asset move
|
||||
const sourcePath = e.dataTransfer.getData('asset-path');
|
||||
if (sourcePath) {
|
||||
await handleMoveAsset(sourcePath, targetFolderPath);
|
||||
return;
|
||||
}
|
||||
}, [handleMoveAsset]);
|
||||
|
||||
// 检查是否是实体拖放(创建预制体)| Check if it's entity drop (create prefab)
|
||||
const entityIdStr = e.dataTransfer.getData('entity-id');
|
||||
if (entityIdStr) {
|
||||
const entityId = parseInt(entityIdStr, 10);
|
||||
if (isNaN(entityId)) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entity = scene.findEntityById(entityId);
|
||||
if (!entity) return;
|
||||
|
||||
// 获取层级系统 | Get hierarchy system
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 创建预制体数据 | Create prefab data
|
||||
const prefabData = PrefabSerializer.createPrefab(
|
||||
entity,
|
||||
{
|
||||
name: entity.name,
|
||||
includeChildren: true
|
||||
},
|
||||
hierarchySystem ?? undefined
|
||||
);
|
||||
|
||||
// 序列化为 JSON | Serialize to JSON
|
||||
const prefabJson = PrefabSerializer.serialize(prefabData, true);
|
||||
|
||||
// 保存到目标文件夹 | Save to target folder
|
||||
const sep = targetFolderPath.includes('\\') ? '\\' : '/';
|
||||
const filePath = `${targetFolderPath}${sep}${entity.name}.prefab`;
|
||||
|
||||
try {
|
||||
await TauriAPI.writeFileContent(filePath, prefabJson);
|
||||
console.log(`[ContentBrowser] Prefab created: ${filePath}`);
|
||||
|
||||
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
let guid: string | null = null;
|
||||
if (assetRegistry) {
|
||||
guid = await assetRegistry.registerAsset(filePath);
|
||||
console.log(`[ContentBrowser] Registered prefab asset with GUID: ${guid}`);
|
||||
}
|
||||
|
||||
// 刷新目录 | Refresh directory
|
||||
if (currentPath === targetFolderPath) {
|
||||
await loadAssets(targetFolderPath);
|
||||
}
|
||||
|
||||
// 发布事件 | Publish event
|
||||
messageHub.publish('prefab:created', {
|
||||
path: filePath,
|
||||
guid,
|
||||
name: entity.name,
|
||||
sourceEntityId: entity.id,
|
||||
sourceEntityName: entity.name
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ContentBrowser] Failed to create prefab:', error);
|
||||
}
|
||||
}
|
||||
}, [handleMoveAsset, currentPath, loadAssets, messageHub]);
|
||||
|
||||
// Handle asset click
|
||||
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
|
||||
@@ -859,6 +1037,22 @@ export class ${className} {
|
||||
return;
|
||||
}
|
||||
|
||||
// 预制体文件进入预制体编辑模式
|
||||
// Open prefab file in prefab edit mode
|
||||
if (ext === 'prefab') {
|
||||
try {
|
||||
const sceneManager = Core.services.tryResolve(SceneManagerService);
|
||||
if (sceneManager) {
|
||||
await sceneManager.enterPrefabEditMode(asset.path);
|
||||
} else {
|
||||
console.error('SceneManagerService not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open prefab:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 脚本文件使用配置的编辑器打开
|
||||
// Open script files with configured editor
|
||||
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
|
||||
@@ -1092,9 +1286,10 @@ export class ${className} {
|
||||
onClick: async () => {
|
||||
if (currentPath) {
|
||||
try {
|
||||
console.log('[ContentBrowser] showInFolder (empty area) - currentPath:', currentPath);
|
||||
await TauriAPI.showInFolder(currentPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in folder:', error);
|
||||
console.error('Failed to show in folder:', error, 'Path:', currentPath);
|
||||
}
|
||||
}
|
||||
setContextMenu(null);
|
||||
@@ -1301,8 +1496,17 @@ export class ${className} {
|
||||
icon: <ExternalLink size={16} />,
|
||||
onClick: async () => {
|
||||
try {
|
||||
console.log('[ContentBrowser] showInFolder path:', asset.path);
|
||||
await TauriAPI.showInFolder(asset.path);
|
||||
// Ensure we use absolute path
|
||||
// 确保使用绝对路径
|
||||
const absolutePath = asset.path.includes(':') || asset.path.startsWith('\\\\')
|
||||
? asset.path
|
||||
: (projectPath ? `${projectPath}/${asset.path}`.replace(/\//g, '\\') : asset.path);
|
||||
|
||||
console.log('[ContentBrowser] showInFolder - asset.path:', asset.path);
|
||||
console.log('[ContentBrowser] showInFolder - projectPath:', projectPath);
|
||||
console.log('[ContentBrowser] showInFolder - absolutePath:', absolutePath);
|
||||
|
||||
await TauriAPI.showInFolder(absolutePath);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in folder:', error, 'Path:', asset.path);
|
||||
}
|
||||
@@ -1405,9 +1609,10 @@ export class ${className} {
|
||||
icon: <ExternalLink size={16} />,
|
||||
onClick: async () => {
|
||||
try {
|
||||
console.log('[ContentBrowser] showInFolder (folder tree) - node.path:', node.path);
|
||||
await TauriAPI.showInFolder(node.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in explorer:', error);
|
||||
console.error('Failed to show in explorer:', error, 'Path:', node.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1466,10 +1671,51 @@ export class ${className} {
|
||||
);
|
||||
}, [currentPath, expandedFolders, handleFolderSelect, handleFolderTreeContextMenu, toggleFolderExpand, projectPath, t, dragOverFolder, handleFolderDragOver, handleFolderDragLeave, handleFolderDrop]);
|
||||
|
||||
// Filter assets by search
|
||||
const filteredAssets = searchQuery.trim()
|
||||
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: assets;
|
||||
// 收集当前目录所有唯一扩展名 | Collect all unique extensions in current directory
|
||||
const allExtensions = useMemo(() => {
|
||||
const exts = new Set<string>();
|
||||
assets.forEach(a => {
|
||||
if (a.extension) {
|
||||
exts.add(a.extension.toLowerCase());
|
||||
}
|
||||
});
|
||||
return Array.from(exts).sort();
|
||||
}, [assets]);
|
||||
|
||||
// 切换扩展名隐藏状态 | Toggle extension hidden state
|
||||
const toggleExtensionHidden = useCallback((ext: string) => {
|
||||
setHiddenExtensions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(ext)) {
|
||||
newSet.delete(ext);
|
||||
} else {
|
||||
newSet.add(ext);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Filter assets by search and hidden extensions
|
||||
// 按搜索词和隐藏扩展名过滤资产
|
||||
const filteredAssets = useMemo(() => {
|
||||
let result = assets;
|
||||
|
||||
// 过滤隐藏的扩展名 | Filter hidden extensions
|
||||
if (hiddenExtensions.size > 0) {
|
||||
result = result.filter(a => {
|
||||
if (a.type === 'folder') return true;
|
||||
const ext = a.extension?.toLowerCase();
|
||||
return !ext || !hiddenExtensions.has(ext);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索过滤 | Search filter
|
||||
if (searchQuery.trim()) {
|
||||
result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [assets, hiddenExtensions, searchQuery]);
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
@@ -1601,10 +1847,55 @@ export class ${className} {
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="cb-search-bar">
|
||||
<button className="cb-filter-btn">
|
||||
<SlidersHorizontal size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
<div className="cb-filter-wrapper">
|
||||
<button
|
||||
className={`cb-filter-btn ${hiddenExtensions.size > 0 ? 'has-filter' : ''}`}
|
||||
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
|
||||
title={hiddenExtensions.size > 0 ? `${hiddenExtensions.size} hidden` : 'Filter'}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
<ChevronDown size={10} />
|
||||
{hiddenExtensions.size > 0 && (
|
||||
<span className="cb-filter-badge">{hiddenExtensions.size}</span>
|
||||
)}
|
||||
</button>
|
||||
{showFilterDropdown && (
|
||||
<div className="cb-filter-dropdown">
|
||||
<div className="cb-filter-header">
|
||||
<span>{t('contentBrowser.hiddenExtensions') || 'Hidden Extensions'}</span>
|
||||
{hiddenExtensions.size > 0 && (
|
||||
<button
|
||||
className="cb-filter-clear"
|
||||
onClick={() => setHiddenExtensions(new Set())}
|
||||
>
|
||||
{t('common.clearAll') || 'Clear All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="cb-filter-list">
|
||||
{allExtensions.length === 0 ? (
|
||||
<div className="cb-filter-empty">
|
||||
{t('contentBrowser.noExtensions') || 'No file types'}
|
||||
</div>
|
||||
) : (
|
||||
allExtensions.map(ext => (
|
||||
<label key={ext} className="cb-filter-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hiddenExtensions.has(ext)}
|
||||
onChange={() => toggleExtensionHidden(ext)}
|
||||
/>
|
||||
<span className="cb-filter-ext">.{ext}</span>
|
||||
<span className="cb-filter-count">
|
||||
({assets.filter(a => a.extension?.toLowerCase() === ext).length})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cb-search-input-wrapper">
|
||||
<Search size={14} className="cb-search-icon" />
|
||||
<input
|
||||
@@ -1613,7 +1904,23 @@ export class ${className} {
|
||||
placeholder={`${t('contentBrowser.search')} ${breadcrumbs[breadcrumbs.length - 1]?.name || ''}`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && searchQuery) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="cb-search-clear"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title={t('common.clear') || 'Clear'}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="cb-view-options">
|
||||
<button
|
||||
@@ -1635,11 +1942,52 @@ export class ${className} {
|
||||
<div
|
||||
className={`cb-asset-grid ${viewMode}`}
|
||||
onContextMenu={(e) => handleContextMenu(e)}
|
||||
onDragOver={(e) => {
|
||||
// 允许实体拖放到当前目录 | Allow entity drop to current directory
|
||||
if (e.dataTransfer.types.includes('entity-id') && currentPath) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
// 在当前目录创建预制体 | Create prefab in current directory
|
||||
if (currentPath && e.dataTransfer.types.includes('entity-id')) {
|
||||
handleFolderDrop(e, currentPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="cb-loading">Loading...</div>
|
||||
<div className="cb-loading">
|
||||
<div className="cb-loading-spinner" />
|
||||
<span>{t('contentBrowser.loading') || 'Loading...'}</span>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="cb-empty">{t('contentBrowser.empty')}</div>
|
||||
<div className="cb-empty">
|
||||
<Inbox size={48} className="cb-empty-icon" />
|
||||
<span className="cb-empty-title">
|
||||
{searchQuery.trim()
|
||||
? t('contentBrowser.noSearchResults')
|
||||
: t('contentBrowser.empty')}
|
||||
</span>
|
||||
<span className="cb-empty-hint">
|
||||
{searchQuery.trim()
|
||||
? t('contentBrowser.noSearchResultsHint')
|
||||
: t('contentBrowser.emptyHint')}
|
||||
</span>
|
||||
{!searchQuery.trim() && (
|
||||
<button
|
||||
className="cb-empty-action"
|
||||
onClick={() => setContextMenu({
|
||||
position: { x: window.innerWidth / 2, y: window.innerHeight / 2 },
|
||||
asset: null,
|
||||
isBackground: true
|
||||
})}
|
||||
>
|
||||
<Plus size={12} style={{ marginRight: 4 }} />
|
||||
{t('contentBrowser.createNew') || 'Create New'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredAssets.map(asset => {
|
||||
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
|
||||
@@ -1692,7 +2040,7 @@ export class ${className} {
|
||||
</div>
|
||||
<div className="cb-asset-info">
|
||||
<div className="cb-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
{highlightSearchText(asset.name, searchQuery)}
|
||||
</div>
|
||||
<div className="cb-asset-type">
|
||||
{getAssetTypeName(asset)}
|
||||
@@ -1706,7 +2054,23 @@ export class ${className} {
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="cb-status-bar">
|
||||
<span>{filteredAssets.length} {t('contentBrowser.items')}</span>
|
||||
<span>
|
||||
{searchQuery.trim() ? (
|
||||
// 搜索模式:显示找到的结果数 | Search mode: show found results
|
||||
t('contentBrowser.searchResults', {
|
||||
found: filteredAssets.length,
|
||||
total: assets.length
|
||||
})
|
||||
) : (
|
||||
// 正常模式 | Normal mode
|
||||
`${filteredAssets.length} ${t('contentBrowser.items')}`
|
||||
)}
|
||||
</span>
|
||||
{selectedPaths.size > 1 && (
|
||||
<span className="cb-status-selected">
|
||||
{t('contentBrowser.selectedCount', { count: selectedPaths.size })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1730,8 +2094,8 @@ export class ${className} {
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{renameDialog && (
|
||||
<div className="cb-dialog-overlay" onClick={() => setRenameDialog(null)}>
|
||||
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cb-dialog-overlay">
|
||||
<div className="cb-dialog">
|
||||
<div className="cb-dialog-header">
|
||||
<h3>{t('contentBrowser.dialogs.renameTitle')}</h3>
|
||||
</div>
|
||||
@@ -1764,8 +2128,8 @@ export class ${className} {
|
||||
|
||||
{/* Delete Confirm Dialog */}
|
||||
{deleteConfirmDialog && (
|
||||
<div className="cb-dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
|
||||
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cb-dialog-overlay">
|
||||
<div className="cb-dialog">
|
||||
<div className="cb-dialog-header">
|
||||
<h3>{t('contentBrowser.deleteConfirmTitle')}</h3>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,9 @@ export interface ContextMenuItem {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
/** 快捷键提示文本 */
|
||||
/** 快捷键提示文本 | Shortcut hint text */
|
||||
shortcut?: string;
|
||||
/** 子菜单项 */
|
||||
/** 子菜单项 | Submenu items */
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
@@ -24,43 +24,94 @@ interface SubMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
parentRect: DOMRect;
|
||||
onClose: () => void;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算子菜单位置,处理屏幕边界
|
||||
* Calculate submenu position, handle screen boundaries
|
||||
*/
|
||||
function calculateSubmenuPosition(
|
||||
parentRect: DOMRect,
|
||||
menuWidth: number,
|
||||
menuHeight: number
|
||||
): { x: number; y: number; flipHorizontal: boolean } {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const padding = 10;
|
||||
|
||||
let x = parentRect.right;
|
||||
let y = parentRect.top;
|
||||
let flipHorizontal = false;
|
||||
|
||||
// 检查右侧空间是否足够 | Check if there's enough space on the right
|
||||
if (x + menuWidth > viewportWidth - padding) {
|
||||
// 尝试显示在左侧 | Try to show on the left side
|
||||
const leftPosition = parentRect.left - menuWidth;
|
||||
if (leftPosition >= padding) {
|
||||
x = leftPosition;
|
||||
flipHorizontal = true;
|
||||
} else {
|
||||
// 两侧都不够,选择空间更大的一侧 | Neither side has enough space, choose the larger one
|
||||
if (parentRect.left > viewportWidth - parentRect.right) {
|
||||
x = padding;
|
||||
flipHorizontal = true;
|
||||
} else {
|
||||
x = viewportWidth - menuWidth - padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查底部空间是否足够 | Check if there's enough space at the bottom
|
||||
if (y + menuHeight > viewportHeight - padding) {
|
||||
y = Math.max(padding, viewportHeight - menuHeight - padding);
|
||||
}
|
||||
|
||||
// 确保不超出顶部 | Ensure it doesn't go above the top
|
||||
if (y < padding) {
|
||||
y = padding;
|
||||
}
|
||||
|
||||
return { x, y, flipHorizontal };
|
||||
}
|
||||
|
||||
/**
|
||||
* 子菜单组件
|
||||
* SubMenu component
|
||||
*/
|
||||
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
function SubMenu({ items, parentRect, onClose, level }: SubMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 计算位置 | Calculate position
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// 默认在父菜单右侧显示
|
||||
let x = parentRect.right;
|
||||
let y = parentRect.top;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = parentRect.left - rect.width;
|
||||
}
|
||||
|
||||
// 如果底部空间不足,向上调整
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = Math.max(0, viewportHeight - rect.height - 10);
|
||||
}
|
||||
|
||||
const { x, y } = calculateSubmenuPosition(parentRect, rect.width, rect.height);
|
||||
setPosition({ x, y });
|
||||
}
|
||||
}, [parentRect]);
|
||||
|
||||
// 清理定时器 | Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
// 清除关闭定时器 | Clear close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -71,14 +122,38 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 延迟关闭子菜单,给用户时间移动到子菜单
|
||||
// Delay closing submenu to give user time to move to it
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}, 150);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmenuMouseEnter = useCallback(() => {
|
||||
// 鼠标进入子菜单区域,取消关闭定时器
|
||||
// Mouse entered submenu area, cancel close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始位置在屏幕外,等待计算后显示
|
||||
// Initial position off-screen, wait for calculation before showing
|
||||
const style: React.CSSProperties = position
|
||||
? { left: `${position.x}px`, top: `${position.y}px`, opacity: 1 }
|
||||
: { left: '-9999px', top: '-9999px', opacity: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu submenu"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`
|
||||
}}
|
||||
style={style}
|
||||
onMouseEnter={handleSubmenuMouseEnter}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
@@ -90,19 +165,16 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => handleItemMouseLeave(item)}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
@@ -113,6 +185,7 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
level={level + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,10 +197,12 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 计算调整后的位置 | Calculate adjusted position
|
||||
useEffect(() => {
|
||||
const adjustPosition = () => {
|
||||
if (menuRef.current) {
|
||||
@@ -138,24 +213,29 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
|
||||
const STATUS_BAR_HEIGHT = 28;
|
||||
const TITLE_BAR_HEIGHT = 32;
|
||||
const padding = 10;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
if (x + rect.width > viewportWidth - 10) {
|
||||
x = Math.max(10, viewportWidth - rect.width - 10);
|
||||
// 检查右边界 | Check right boundary
|
||||
if (x + rect.width > viewportWidth - padding) {
|
||||
x = Math.max(padding, viewportWidth - rect.width - padding);
|
||||
}
|
||||
|
||||
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - 10) {
|
||||
y = Math.max(TITLE_BAR_HEIGHT + 10, viewportHeight - STATUS_BAR_HEIGHT - rect.height - 10);
|
||||
// 检查下边界 | Check bottom boundary
|
||||
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - padding) {
|
||||
y = Math.max(TITLE_BAR_HEIGHT + padding, viewportHeight - STATUS_BAR_HEIGHT - rect.height - padding);
|
||||
}
|
||||
|
||||
if (x < 10) {
|
||||
x = 10;
|
||||
// 确保不超出左边界 | Ensure not beyond left boundary
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
if (y < TITLE_BAR_HEIGHT + 10) {
|
||||
y = TITLE_BAR_HEIGHT + 10;
|
||||
// 确保不超出上边界 | Ensure not beyond top boundary
|
||||
if (y < TITLE_BAR_HEIGHT + padding) {
|
||||
y = TITLE_BAR_HEIGHT + padding;
|
||||
}
|
||||
|
||||
setAdjustedPosition({ x, y });
|
||||
@@ -168,6 +248,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [position]);
|
||||
|
||||
// 点击外部关闭 | Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
@@ -181,6 +262,8 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 mousedown 而不是 click,以便更快响应
|
||||
// Use mousedown instead of click for faster response
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
@@ -190,7 +273,22 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// 清理定时器 | Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
// 清除关闭定时器 | Clear close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -201,14 +299,38 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 延迟关闭子菜单,给用户时间移动到子菜单
|
||||
// Delay closing submenu to give user time to move to it
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}, 150);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmenuMouseEnter = useCallback(() => {
|
||||
// 鼠标进入子菜单区域,取消关闭定时器
|
||||
// Mouse entered submenu area, cancel close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始位置在屏幕外,等待计算后显示
|
||||
// Initial position off-screen, wait for calculation before showing
|
||||
const style: React.CSSProperties = adjustedPosition
|
||||
? { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1 }
|
||||
: { left: '-9999px', top: '-9999px', opacity: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
style={{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y}px`
|
||||
}}
|
||||
style={style}
|
||||
onMouseEnter={handleSubmenuMouseEnter}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
@@ -220,19 +342,16 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => handleItemMouseLeave(item)}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
@@ -243,6 +362,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
level={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { useCallback, useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
@@ -11,6 +11,81 @@ import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
|
||||
|
||||
export type { FlexDockPanel };
|
||||
|
||||
/** LocalStorage key for persisting layout | 持久化布局的 localStorage 键 */
|
||||
const LAYOUT_STORAGE_KEY = 'esengine-editor-layout';
|
||||
|
||||
/** Layout version for migration | 布局版本用于迁移 */
|
||||
const LAYOUT_VERSION = 1;
|
||||
|
||||
/** Saved layout data structure | 保存的布局数据结构 */
|
||||
interface SavedLayoutData {
|
||||
version: number;
|
||||
layout: IJsonModel;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save layout to localStorage.
|
||||
* 保存布局到 localStorage。
|
||||
*/
|
||||
function saveLayoutToStorage(layout: IJsonModel): void {
|
||||
try {
|
||||
const data: SavedLayoutData = {
|
||||
version: LAYOUT_VERSION,
|
||||
layout,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save layout to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load layout from localStorage.
|
||||
* 从 localStorage 加载布局。
|
||||
*/
|
||||
function loadLayoutFromStorage(): IJsonModel | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
|
||||
const data: SavedLayoutData = JSON.parse(saved);
|
||||
|
||||
// Version check for future migrations
|
||||
if (data.version !== LAYOUT_VERSION) {
|
||||
console.info('Layout version mismatch, using default layout');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.layout;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load layout from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved layout from localStorage.
|
||||
* 从 localStorage 清除保存的布局。
|
||||
*/
|
||||
function clearLayoutStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(LAYOUT_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear layout from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public handle for FlexLayoutDockContainer.
|
||||
* FlexLayoutDockContainer 的公开句柄。
|
||||
*/
|
||||
export interface FlexLayoutDockContainerHandle {
|
||||
/** Reset layout to default | 重置布局到默认状态 */
|
||||
resetLayout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel IDs that should persist in DOM when switching tabs.
|
||||
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
|
||||
@@ -94,11 +169,14 @@ interface FlexLayoutDockContainerProps {
|
||||
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
|
||||
}
|
||||
|
||||
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) {
|
||||
export const FlexLayoutDockContainer = forwardRef<FlexLayoutDockContainerHandle, FlexLayoutDockContainerProps>(
|
||||
function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) {
|
||||
const layoutRef = useRef<Layout>(null);
|
||||
const previousLayoutJsonRef = useRef<string | null>(null);
|
||||
const previousPanelIdsRef = useRef<string>('');
|
||||
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
|
||||
/** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */
|
||||
const skipNextSaveRef = useRef(false);
|
||||
|
||||
// Persistent panel state | 持久化面板状态
|
||||
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
|
||||
@@ -116,14 +194,52 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
|
||||
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
|
||||
}, [panels, activePanelId]);
|
||||
|
||||
/**
|
||||
* Try to load saved layout and merge with current panels.
|
||||
* 尝试加载保存的布局并与当前面板合并。
|
||||
*/
|
||||
const loadSavedLayoutOrDefault = useCallback((): IJsonModel => {
|
||||
const savedLayout = loadLayoutFromStorage();
|
||||
if (savedLayout) {
|
||||
try {
|
||||
// Merge saved layout with current panels (handle new/removed panels)
|
||||
const defaultLayout = createDefaultLayout();
|
||||
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
|
||||
return mergedLayout;
|
||||
} catch (error) {
|
||||
console.warn('Failed to merge saved layout, using default:', error);
|
||||
}
|
||||
}
|
||||
return createDefaultLayout();
|
||||
}, [createDefaultLayout, panels]);
|
||||
|
||||
const [model, setModel] = useState<Model>(() => {
|
||||
try {
|
||||
return Model.fromJson(createDefaultLayout());
|
||||
return Model.fromJson(loadSavedLayoutOrDefault());
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create layout model: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.warn('Failed to load saved layout, using default:', error);
|
||||
return Model.fromJson(createDefaultLayout());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reset layout to default and clear saved layout.
|
||||
* 重置布局到默认状态并清除保存的布局。
|
||||
*/
|
||||
const resetLayout = useCallback(() => {
|
||||
clearLayoutStorage();
|
||||
skipNextSaveRef.current = true;
|
||||
previousLayoutJsonRef.current = null;
|
||||
previousPanelIdsRef.current = '';
|
||||
const defaultLayout = createDefaultLayout();
|
||||
setModel(Model.fromJson(defaultLayout));
|
||||
}, [createDefaultLayout]);
|
||||
|
||||
// Expose resetLayout method via ref | 通过 ref 暴露 resetLayout 方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetLayout
|
||||
}), [resetLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
|
||||
@@ -168,26 +284,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
|
||||
previousPanelIdsRef.current = currentPanelIds;
|
||||
|
||||
// 如果已经有布局且只是添加新面板,使用Action动态添加
|
||||
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
|
||||
// 检查新面板是否需要独立 tabset(如 bottom 位置的面板)
|
||||
// Check if new panels require separate tabset (e.g., bottom position panels)
|
||||
const newPanelsWithConfig = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
const hasSpecialLayoutPanels = newPanelsWithConfig.some((p) =>
|
||||
p.layout?.requiresSeparateTabset || p.layout?.position === 'bottom'
|
||||
);
|
||||
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds && !hasSpecialLayoutPanels) {
|
||||
// 找到要添加的面板
|
||||
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
|
||||
// 找到中心区域的tabset ID
|
||||
// 构建面板位置映射 | Build panel position map
|
||||
const panelPositionMap = new Map(panels.map((p) => [p.id, p.layout?.position || 'center']));
|
||||
|
||||
// 找到中心区域的tabset ID | Find center tabset ID
|
||||
let centerTabsetId: string | null = null;
|
||||
|
||||
model.visitNodes((node: any) => {
|
||||
if (node.getType() === 'tabset') {
|
||||
const tabset = node as any;
|
||||
// 检查是否是中心tabset
|
||||
// 检查是否是中心tabset(包含 center 位置的面板)
|
||||
// Check if this is center tabset (contains center position panels)
|
||||
const children = tabset.getChildren();
|
||||
const hasNonSidePanel = children.some((child: any) => {
|
||||
const hasCenterPanel = children.some((child: any) => {
|
||||
const id = child.getId();
|
||||
return !id.includes('hierarchy') &&
|
||||
!id.includes('asset') &&
|
||||
!id.includes('inspector') &&
|
||||
!id.includes('console');
|
||||
const position = panelPositionMap.get(id);
|
||||
return position === 'center' || position === undefined;
|
||||
});
|
||||
if (hasNonSidePanel && !centerTabsetId) {
|
||||
if (hasCenterPanel && !centerTabsetId) {
|
||||
centerTabsetId = tabset.getId();
|
||||
}
|
||||
}
|
||||
@@ -229,7 +353,9 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
|
||||
const defaultLayout = createDefaultLayout();
|
||||
|
||||
// 如果有保存的布局,尝试合并
|
||||
if (previousLayoutJsonRef.current && previousIds) {
|
||||
// 注意:如果新面板需要特殊布局(独立 tabset),直接使用默认布局
|
||||
// Note: If new panels need special layout (separate tabset), use default layout directly
|
||||
if (previousLayoutJsonRef.current && previousIds && !hasSpecialLayoutPanels) {
|
||||
try {
|
||||
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
|
||||
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
|
||||
@@ -340,6 +466,13 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
|
||||
const layoutJson = newModel.toJson();
|
||||
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
|
||||
|
||||
// Save to localStorage (unless skipped) | 保存到 localStorage(除非跳过)
|
||||
if (skipNextSaveRef.current) {
|
||||
skipNextSaveRef.current = false;
|
||||
} else {
|
||||
saveLayoutToStorage(layoutJson);
|
||||
}
|
||||
|
||||
// Check if any tabset is maximized
|
||||
let hasMaximized = false;
|
||||
newModel.visitNodes((node) => {
|
||||
@@ -390,7 +523,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Container for persistent panel content.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Component, Core, getComponentInstanceTypeName, PrefabInstanceComponent, Entity } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry, PrefabService } from '@esengine/editor-core';
|
||||
import { ChevronRight, ChevronDown, Lock } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
|
||||
import { AssetField } from './inspectors/fields/AssetField';
|
||||
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/PropertyInspector.css';
|
||||
|
||||
const animationClipsEditor = new AnimationClipsFieldEditor();
|
||||
|
||||
interface PropertyInspectorProps {
|
||||
component: Component;
|
||||
entity?: any;
|
||||
entity?: Entity;
|
||||
version?: number;
|
||||
onChange?: (propertyName: string, value: any) => void;
|
||||
onAction?: (actionId: string, propertyName: string, component: Component) => void;
|
||||
@@ -21,9 +22,47 @@ interface PropertyInspectorProps {
|
||||
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
|
||||
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
|
||||
void version;
|
||||
|
||||
// 获取预制体服务和组件名称 | Get prefab service and component name
|
||||
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
|
||||
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
|
||||
|
||||
// 获取预制体实例组件 | Get prefab instance component
|
||||
const prefabInstanceComp = useMemo(() => {
|
||||
return entity?.getComponent(PrefabInstanceComponent) ?? null;
|
||||
}, [entity, version]);
|
||||
|
||||
// 检查属性是否被覆盖 | Check if property is overridden
|
||||
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
|
||||
if (!prefabInstanceComp) return false;
|
||||
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
|
||||
}, [prefabInstanceComp, componentTypeName]);
|
||||
|
||||
// 处理属性右键菜单 | Handle property context menu
|
||||
const handlePropertyContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
|
||||
if (!isPropertyOverridden(propertyName)) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
|
||||
}, [isPropertyOverridden]);
|
||||
|
||||
// 还原属性 | Revert property
|
||||
const handleRevertProperty = useCallback(async () => {
|
||||
if (!contextMenu || !prefabService || !entity) return;
|
||||
|
||||
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, prefabService, entity, componentTypeName]);
|
||||
|
||||
// 关闭右键菜单 | Close context menu
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Scan entity for components that control this component's properties
|
||||
useEffect(() => {
|
||||
if (!entity) return;
|
||||
@@ -236,7 +275,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
const canCreate = creationMapping !== null;
|
||||
|
||||
return (
|
||||
<div key={propertyName} className="property-field">
|
||||
<div key={propertyName} className="property-field property-field-asset">
|
||||
<label className="property-label">
|
||||
{label}
|
||||
{controlledBy && (
|
||||
@@ -300,6 +339,28 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
/>
|
||||
);
|
||||
|
||||
case 'array': {
|
||||
const arrayMeta = metadata as {
|
||||
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
reorderable?: boolean;
|
||||
};
|
||||
return (
|
||||
<ArrayField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? []}
|
||||
itemType={arrayMeta.itemType}
|
||||
minLength={arrayMeta.minLength}
|
||||
maxLength={arrayMeta.maxLength}
|
||||
reorderable={arrayMeta.reorderable}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -307,8 +368,36 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
|
||||
return (
|
||||
<div className="property-inspector">
|
||||
{Object.entries(properties).map(([propertyName, metadata]) =>
|
||||
renderProperty(propertyName, metadata)
|
||||
{Object.entries(properties).map(([propertyName, metadata]) => {
|
||||
const overridden = isPropertyOverridden(propertyName);
|
||||
return (
|
||||
<div
|
||||
key={propertyName}
|
||||
className={`property-row ${overridden ? 'overridden' : ''}`}
|
||||
onContextMenu={(e) => handlePropertyContextMenu(e, propertyName)}
|
||||
>
|
||||
{renderProperty(propertyName, metadata)}
|
||||
{overridden && (
|
||||
<span className="property-override-indicator" title="Modified from prefab" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 右键菜单 | Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="property-context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="property-context-menu-item"
|
||||
onClick={handleRevertProperty}
|
||||
>
|
||||
<span>↩</span>
|
||||
<span>Revert to Prefab</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -331,8 +420,17 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 同步外部值 | Sync external value
|
||||
useEffect(() => {
|
||||
if (!isFocused && !isDragging) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused, isDragging]);
|
||||
|
||||
const renderActionButton = (action: PropertyAction) => {
|
||||
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
|
||||
return (
|
||||
@@ -389,6 +487,33 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
let val = parseFloat(localValue) || 0;
|
||||
if (min !== undefined) val = Math.max(min, val);
|
||||
if (max !== undefined) val = Math.min(max, val);
|
||||
if (isInteger) val = Math.round(val);
|
||||
onChange(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label
|
||||
@@ -402,16 +527,15 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value}
|
||||
value={localValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value) || 0;
|
||||
onChange(isInteger ? Math.round(val) : val);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="property-actions">
|
||||
@@ -430,16 +554,42 @@ interface StringFieldProps {
|
||||
}
|
||||
|
||||
function StringField({ label, value, readOnly, onChange }: StringFieldProps) {
|
||||
const [localValue, setLocalValue] = useState(value ?? '');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(value ?? '');
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(value ?? '');
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-text"
|
||||
value={value}
|
||||
value={localValue}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
e.target.select();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
onChange(localValue);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -695,7 +845,17 @@ interface DraggableAxisInputProps {
|
||||
|
||||
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 同步外部值(不在聚焦或拖动时)| Sync external value (not when focused or dragging)
|
||||
useEffect(() => {
|
||||
if (!isFocused && !isDragging) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused, isDragging]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
@@ -730,6 +890,37 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
|
||||
const axisClass = `property-vector-axis-${axis}`;
|
||||
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 确认输入并失焦 | Confirm input and blur
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
// 取消输入,恢复原值 | Cancel input, restore original value
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
// Tab 键使用浏览器默认行为 | Tab uses browser default behavior
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
const parsed = parseFloat(localValue);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(Math.round(parsed * 1000) / 1000);
|
||||
} else {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
|
||||
<span
|
||||
@@ -740,13 +931,16 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
|
||||
{axis.toUpperCase()}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={value ?? 0}
|
||||
value={localValue}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -954,3 +1148,158 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
|
||||
);
|
||||
}
|
||||
|
||||
// ============= ArrayField 数组字段组件 =============
|
||||
|
||||
interface ArrayFieldProps {
|
||||
label: string;
|
||||
value: any[];
|
||||
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
reorderable?: boolean;
|
||||
readOnly?: boolean;
|
||||
onChange: (value: any[]) => void;
|
||||
}
|
||||
|
||||
function ArrayField({
|
||||
label,
|
||||
value,
|
||||
itemType,
|
||||
minLength = 0,
|
||||
maxLength = 100,
|
||||
reorderable = true,
|
||||
readOnly,
|
||||
onChange
|
||||
}: ArrayFieldProps) {
|
||||
const { t } = useLocale();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
|
||||
const safeValue = Array.isArray(value) ? value : [];
|
||||
const canAdd = !readOnly && safeValue.length < maxLength;
|
||||
const canRemove = !readOnly && safeValue.length > minLength;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!canAdd) return;
|
||||
let defaultValue: any = '';
|
||||
if (itemType?.type === 'number') defaultValue = 0;
|
||||
if (itemType?.type === 'boolean') defaultValue = false;
|
||||
onChange([...safeValue, defaultValue]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
if (!canRemove) return;
|
||||
const newValue = [...safeValue];
|
||||
newValue.splice(index, 1);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, newItemValue: any) => {
|
||||
const newValue = [...safeValue];
|
||||
newValue[index] = newItemValue;
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
if (!reorderable || readOnly) return;
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex === null || dragIndex === index) return;
|
||||
|
||||
const newValue = [...safeValue];
|
||||
const [removed] = newValue.splice(dragIndex, 1);
|
||||
newValue.splice(index, 0, removed);
|
||||
onChange(newValue);
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
};
|
||||
|
||||
// 渲染数组项 | Render array item
|
||||
const renderItem = (item: any, index: number) => {
|
||||
const isAsset = itemType?.type === 'asset';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`array-field-item ${dragIndex === index ? 'dragging' : ''}`}
|
||||
draggable={reorderable && !readOnly}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{reorderable && !readOnly && (
|
||||
<span className="array-field-drag-handle" title={t('inspector.array.dragToReorder')}>⋮⋮</span>
|
||||
)}
|
||||
<span className="array-field-index">[{index}]</span>
|
||||
<div className="array-field-value">
|
||||
{isAsset ? (
|
||||
<AssetField
|
||||
value={item ?? null}
|
||||
onChange={(newValue) => handleItemChange(index, newValue || '')}
|
||||
fileExtension={itemType?.extensions?.[0] || ''}
|
||||
placeholder={t('inspector.array.dropAsset')}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-text"
|
||||
value={item ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => handleItemChange(index, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
className="array-field-remove"
|
||||
onClick={() => handleRemove(index)}
|
||||
title={t('inspector.array.remove')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field property-field-array">
|
||||
<div className="array-field-header">
|
||||
<button
|
||||
className="property-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<label className="property-label">{label}</label>
|
||||
<span className="array-field-count">[{safeValue.length}]</span>
|
||||
{canAdd && (
|
||||
<button
|
||||
className="array-field-add"
|
||||
onClick={handleAdd}
|
||||
title={t('inspector.array.add')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="array-field-items">
|
||||
{safeValue.length === 0 ? (
|
||||
<div className="array-field-empty">{t('inspector.array.empty')}</div>
|
||||
) : (
|
||||
safeValue.map((item, index) => renderItem(item, index))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle, Terminal } from 'lucide-react';
|
||||
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
|
||||
import { StartupLogo } from './StartupLogo';
|
||||
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
|
||||
@@ -35,6 +36,10 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
|
||||
const [showEnvStatus, setShowEnvStatus] = useState(false);
|
||||
const [showEsbuildInstall, setShowEsbuildInstall] = useState(false);
|
||||
const [isInstallingEsbuild, setIsInstallingEsbuild] = useState(false);
|
||||
const [installProgress, setInstallProgress] = useState('');
|
||||
const [installError, setInstallError] = useState('');
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,15 +75,74 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
console.log('[Environment] Ready ✓');
|
||||
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
|
||||
} else {
|
||||
// 环境有问题,显示提示
|
||||
setShowEnvStatus(true);
|
||||
// esbuild 未安装,显示安装对话框
|
||||
console.warn('[Environment] Not ready:', result.esbuild.error);
|
||||
setShowEsbuildInstall(true);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('[Environment] Check failed:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 监听 esbuild 安装进度事件
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
// 监听安装进度
|
||||
unlisten = await listen<string>('esbuild-install:progress', (event) => {
|
||||
setInstallProgress(event.payload);
|
||||
});
|
||||
|
||||
// 监听安装成功
|
||||
const unlistenSuccess = await listen('esbuild-install:success', async () => {
|
||||
// 重新检测环境
|
||||
const result = await TauriAPI.checkEnvironment();
|
||||
setEnvCheck(result);
|
||||
if (result.ready) {
|
||||
setShowEsbuildInstall(false);
|
||||
setIsInstallingEsbuild(false);
|
||||
setInstallProgress('');
|
||||
setInstallError('');
|
||||
}
|
||||
});
|
||||
|
||||
// 监听安装错误
|
||||
const unlistenError = await listen<string>('esbuild-install:error', (event) => {
|
||||
setInstallError(event.payload);
|
||||
setIsInstallingEsbuild(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
unlistenSuccess();
|
||||
unlistenError();
|
||||
};
|
||||
};
|
||||
|
||||
setupListeners();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理 esbuild 安装
|
||||
const handleInstallEsbuild = async () => {
|
||||
setIsInstallingEsbuild(true);
|
||||
setInstallProgress(t('startup.installingEsbuild'));
|
||||
setInstallError('');
|
||||
|
||||
try {
|
||||
await TauriAPI.installEsbuild();
|
||||
// 成功会通过事件处理
|
||||
} catch (error) {
|
||||
console.error('[Environment] Failed to install esbuild:', error);
|
||||
setInstallError(String(error));
|
||||
setIsInstallingEsbuild(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setIsInstalling(true);
|
||||
const success = await installUpdate();
|
||||
@@ -343,6 +407,57 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* esbuild 安装对话框 | esbuild Installation Dialog */}
|
||||
{showEsbuildInstall && (
|
||||
<div className="startup-dialog-overlay">
|
||||
<div className="startup-dialog">
|
||||
<div className="startup-dialog-header">
|
||||
<Terminal size={20} className="dialog-icon-info" />
|
||||
<h3>{t('startup.esbuildNotInstalled')}</h3>
|
||||
</div>
|
||||
<div className="startup-dialog-body">
|
||||
<p>{t('startup.esbuildRequired')}</p>
|
||||
<p className="startup-dialog-info">{t('startup.esbuildInstallPrompt')}</p>
|
||||
|
||||
{/* 安装进度 | Installation Progress */}
|
||||
{isInstallingEsbuild && (
|
||||
<div className="startup-dialog-progress">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>{installProgress}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 | Error Message */}
|
||||
{installError && (
|
||||
<div className="startup-dialog-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>{installError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="startup-dialog-footer">
|
||||
<button
|
||||
className="startup-dialog-btn primary"
|
||||
onClick={handleInstallEsbuild}
|
||||
disabled={isInstallingEsbuild}
|
||||
>
|
||||
{isInstallingEsbuild ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('startup.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={14} />
|
||||
{t('startup.installNow')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X } from 'lucide-react';
|
||||
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
|
||||
import type { MessageHub, LogService } from '@esengine/editor-core';
|
||||
import { ContentBrowser } from './ContentBrowser';
|
||||
import { OutputLogPanel } from './OutputLogPanel';
|
||||
@@ -14,6 +14,10 @@ interface StatusBarProps {
|
||||
locale?: string;
|
||||
projectPath?: string | null;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
|
||||
onDockContentBrowser?: () => void;
|
||||
/** 重置布局回调 | Callback to reset layout */
|
||||
onResetLayout?: () => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'output' | 'cmd';
|
||||
@@ -25,7 +29,9 @@ export function StatusBar({
|
||||
logService,
|
||||
locale = 'en',
|
||||
projectPath,
|
||||
onOpenScene
|
||||
onOpenScene,
|
||||
onDockContentBrowser,
|
||||
onResetLayout
|
||||
}: StatusBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [consoleInput, setConsoleInput] = useState('');
|
||||
@@ -224,6 +230,11 @@ export function StatusBar({
|
||||
onOpenScene={onOpenScene}
|
||||
isDrawer={true}
|
||||
revealPath={revealPath}
|
||||
onDockInLayout={() => {
|
||||
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
|
||||
setContentDrawerOpen(false);
|
||||
onDockContentBrowser?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,6 +314,13 @@ export function StatusBar({
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<div className="status-bar-icon-group">
|
||||
<button
|
||||
className="status-bar-icon-btn"
|
||||
title={t('statusBar.resetLayout')}
|
||||
onClick={onResetLayout}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
|
||||
<Wifi size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
|
||||
import { UIRegistry, MessageHub, PluginManager, CommandManager } from '@esengine/editor-core';
|
||||
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
@@ -21,6 +21,7 @@ interface TitleBarProps {
|
||||
uiRegistry?: UIRegistry;
|
||||
messageHub?: MessageHub;
|
||||
pluginManager?: PluginManager;
|
||||
commandManager?: CommandManager;
|
||||
onNewScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onSaveScene?: () => void;
|
||||
@@ -44,6 +45,7 @@ export function TitleBar({
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
commandManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
@@ -65,9 +67,42 @@ export function TitleBar({
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
// Update undo/redo state | 更新撤销/重做状态
|
||||
const updateUndoRedoState = useCallback(() => {
|
||||
if (commandManager) {
|
||||
setCanUndo(commandManager.canUndo());
|
||||
setCanRedo(commandManager.canRedo());
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
// Handle undo | 处理撤销
|
||||
const handleUndo = useCallback(() => {
|
||||
if (commandManager && commandManager.canUndo()) {
|
||||
commandManager.undo();
|
||||
updateUndoRedoState();
|
||||
}
|
||||
}, [commandManager, updateUndoRedoState]);
|
||||
|
||||
// Handle redo | 处理重做
|
||||
const handleRedo = useCallback(() => {
|
||||
if (commandManager && commandManager.canRedo()) {
|
||||
commandManager.redo();
|
||||
updateUndoRedoState();
|
||||
}
|
||||
}, [commandManager, updateUndoRedoState]);
|
||||
|
||||
// Update undo/redo state periodically | 定期更新撤销/重做状态
|
||||
useEffect(() => {
|
||||
updateUndoRedoState();
|
||||
const interval = setInterval(updateUndoRedoState, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
@@ -135,8 +170,8 @@ export function TitleBar({
|
||||
{ label: t('menu.file.exit'), onClick: onExit }
|
||||
],
|
||||
edit: [
|
||||
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: true },
|
||||
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: true },
|
||||
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: !canUndo, onClick: handleUndo },
|
||||
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: !canRedo, onClick: handleRedo },
|
||||
{ separator: true },
|
||||
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
|
||||
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
|
||||
|
||||
@@ -2,14 +2,17 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
|
||||
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
|
||||
Magnet, ZoomIn
|
||||
Magnet, ZoomIn, Save, X, PackageOpen
|
||||
} from 'lucide-react';
|
||||
import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
|
||||
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
@@ -17,6 +20,7 @@ import { TauriAPI } from '../api/tauri';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
import { collectAssetReferences } from '@esengine/asset-system';
|
||||
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
@@ -52,39 +56,53 @@ function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleM
|
||||
|
||||
// Generate user runtime loading code
|
||||
// 生成用户运行时加载代码
|
||||
// Now we only load @esengine/sdk as a single global
|
||||
// 现在只加载 @esengine/sdk 作为单一全局变量
|
||||
const userRuntimeCode = hasUserRuntime ? `
|
||||
updateLoading('Loading user scripts...');
|
||||
try {
|
||||
// Import ECS framework and set up global for user-runtime.js shim
|
||||
// 导入 ECS 框架并为 user-runtime.js 设置全局变量
|
||||
const ecsFramework = await import('@esengine/ecs-framework');
|
||||
window.__ESENGINE__ = window.__ESENGINE__ || {};
|
||||
window.__ESENGINE__.ecsFramework = ecsFramework;
|
||||
// Load unified SDK and set global
|
||||
// 加载统一 SDK 并设置全局变量
|
||||
console.log('[Preview] Loading @esengine/sdk...');
|
||||
const sdk = await import('@esengine/sdk');
|
||||
window.__ESENGINE_SDK__ = sdk;
|
||||
console.log('[Preview] SDK loaded successfully');
|
||||
|
||||
// Check SDK is valid
|
||||
// 检查 SDK 是否有效
|
||||
if (!sdk.Component || !sdk.ComponentRegistry) {
|
||||
throw new Error('SDK missing critical exports (Component, ComponentRegistry)');
|
||||
}
|
||||
|
||||
// Load user-runtime.js which contains compiled user components
|
||||
// 加载 user-runtime.js,其中包含编译的用户组件
|
||||
console.log('[Preview] Loading user-runtime.js...');
|
||||
const userRuntimeScript = document.createElement('script');
|
||||
userRuntimeScript.src = './user-runtime.js?_=' + Date.now();
|
||||
await new Promise((resolve, reject) => {
|
||||
userRuntimeScript.onload = resolve;
|
||||
userRuntimeScript.onerror = reject;
|
||||
userRuntimeScript.onerror = (e) => reject(new Error('Failed to load user-runtime.js: ' + e.message));
|
||||
document.head.appendChild(userRuntimeScript);
|
||||
});
|
||||
console.log('[Preview] user-runtime.js loaded successfully');
|
||||
|
||||
// Register user components to ComponentRegistry
|
||||
// 将用户组件注册到 ComponentRegistry
|
||||
if (window.__USER_RUNTIME_EXPORTS__) {
|
||||
const { ComponentRegistry, Component } = ecsFramework;
|
||||
const { ComponentRegistry, Component } = window.__ESENGINE_SDK__;
|
||||
const exports = window.__USER_RUNTIME_EXPORTS__;
|
||||
for (const [name, exported] of Object.entries(exports)) {
|
||||
if (typeof exported === 'function' && exported.prototype instanceof Component) {
|
||||
ComponentRegistry.register(exported);
|
||||
console.log('[Preview] Registered user component:', name);
|
||||
if (ComponentRegistry && Component) {
|
||||
for (const [name, exported] of Object.entries(exports)) {
|
||||
if (typeof exported === 'function' && exported.prototype instanceof Component) {
|
||||
ComponentRegistry.register(exported);
|
||||
console.log('[Preview] Registered user component:', name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Preview] Failed to load user scripts:', e.message);
|
||||
console.error('[Preview] Failed to load user scripts:', e.message, e);
|
||||
throw e; // Re-throw to show error in UI
|
||||
}
|
||||
` : '';
|
||||
|
||||
@@ -146,12 +164,13 @@ ${importMapScript}
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
function showError(title, msg) {
|
||||
function showError(title, msg, error) {
|
||||
loading.style.display = 'none';
|
||||
errorTitle.textContent = title || 'Failed to start';
|
||||
errorMessage.textContent = msg;
|
||||
const stack = error?.stack || '';
|
||||
errorMessage.textContent = msg + (stack ? '\\n\\nStack:\\n' + stack : '');
|
||||
errorDiv.classList.add('show');
|
||||
console.error('[Preview]', msg);
|
||||
console.error('[Preview]', msg, error || '');
|
||||
}
|
||||
|
||||
function updateLoading(msg) {
|
||||
@@ -191,7 +210,7 @@ ${userRuntimeCode}
|
||||
});
|
||||
console.log('[Preview] Started successfully');
|
||||
} catch (error) {
|
||||
showError(null, error.message || String(error));
|
||||
showError(null, error.message || String(error), error);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -205,9 +224,10 @@ export type PlayState = 'stopped' | 'playing' | 'paused';
|
||||
interface ViewportProps {
|
||||
locale?: string;
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
export function Viewport({ locale = 'en', messageHub, commandManager }: ViewportProps) {
|
||||
const { t } = useLocale();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -221,6 +241,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
|
||||
const runMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prefab edit mode state | 预制体编辑模式状态
|
||||
const [prefabEditMode, setPrefabEditMode] = useState<{
|
||||
isActive: boolean;
|
||||
prefabName: string;
|
||||
prefabPath: string;
|
||||
} | null>(null);
|
||||
|
||||
// Snap settings
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [gridSnapValue, setGridSnapValue] = useState(10);
|
||||
@@ -237,10 +264,15 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||||
const playStateRef = useRef<PlayState>('stopped');
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
playStateRef.current = playState;
|
||||
}, [playState]);
|
||||
// Live transform display state | 实时变换显示状态
|
||||
const [liveTransform, setLiveTransform] = useState<{
|
||||
type: 'move' | 'rotate' | 'scale';
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Rust engine hook with multi-viewport support
|
||||
const engine = useEngine({
|
||||
@@ -261,40 +293,28 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
const selectedEntityRef = useRef<Entity | null>(null);
|
||||
const messageHubRef = useRef<MessageHub | null>(null);
|
||||
const commandManagerRef = useRef<CommandManager | null>(null);
|
||||
const transformModeRef = useRef<TransformMode>('select');
|
||||
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
|
||||
const initialTransformStateRef = useRef<TransformState | null>(null);
|
||||
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
|
||||
const snapEnabledRef = useRef(true);
|
||||
const gridSnapRef = useRef(10);
|
||||
const rotationSnapRef = useRef(15);
|
||||
const scaleSnapRef = useRef(0.25);
|
||||
|
||||
// Keep refs in sync with state
|
||||
// Keep refs in sync with state for stable event handler closures
|
||||
// 保持 refs 与 state 同步,以便事件处理器闭包稳定
|
||||
useEffect(() => {
|
||||
playStateRef.current = playState;
|
||||
camera2DZoomRef.current = camera2DZoom;
|
||||
}, [camera2DZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
camera2DOffsetRef.current = camera2DOffset;
|
||||
}, [camera2DOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
transformModeRef.current = transformMode;
|
||||
}, [transformMode]);
|
||||
|
||||
useEffect(() => {
|
||||
snapEnabledRef.current = snapEnabled;
|
||||
}, [snapEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
gridSnapRef.current = gridSnapValue;
|
||||
}, [gridSnapValue]);
|
||||
|
||||
useEffect(() => {
|
||||
rotationSnapRef.current = rotationSnapValue;
|
||||
}, [rotationSnapValue]);
|
||||
|
||||
useEffect(() => {
|
||||
scaleSnapRef.current = scaleSnapValue;
|
||||
}, [scaleSnapValue]);
|
||||
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
|
||||
|
||||
// Snap helper functions
|
||||
const snapToGrid = useCallback((value: number): number => {
|
||||
@@ -351,6 +371,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
|
||||
useEffect(() => {
|
||||
commandManagerRef.current = commandManager ?? null;
|
||||
}, [commandManager]);
|
||||
|
||||
// Canvas setup and input handling
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -415,6 +440,21 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
// In transform mode, left click transforms entity
|
||||
isDraggingTransformRef.current = true;
|
||||
canvas.style.cursor = 'move';
|
||||
|
||||
// Capture initial transform state for undo/redo
|
||||
// 捕获初始变换状态用于撤销/重做
|
||||
const entity = selectedEntityRef.current;
|
||||
if (entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (transform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
|
||||
transformComponentRef.current = transform;
|
||||
} else if (uiTransform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
|
||||
transformComponentRef.current = uiTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
@@ -468,6 +508,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transform display | 更新实时变换显示
|
||||
setLiveTransform({
|
||||
type: mode as 'move' | 'rotate' | 'scale',
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
rotation: transform.rotation.z * 180 / Math.PI,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y
|
||||
});
|
||||
|
||||
if (messageHubRef.current) {
|
||||
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
|
||||
const value = propertyName === 'position' ? transform.position :
|
||||
@@ -517,6 +567,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transform display for UI | 更新 UI 的实时变换显示
|
||||
setLiveTransform({
|
||||
type: mode as 'move' | 'rotate' | 'scale',
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation * 180 / Math.PI,
|
||||
scaleX: uiTransform.scaleX,
|
||||
scaleY: uiTransform.scaleY
|
||||
});
|
||||
|
||||
if (messageHubRef.current) {
|
||||
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
|
||||
messageHubRef.current.publish('component:property:changed', {
|
||||
@@ -542,6 +602,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
if (isDraggingTransformRef.current) {
|
||||
isDraggingTransformRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
// Clear live transform display | 清除实时变换显示
|
||||
setLiveTransform(null);
|
||||
|
||||
// Apply snap on mouse up
|
||||
const entity = selectedEntityRef.current;
|
||||
@@ -574,6 +636,36 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
|
||||
const initialState = initialTransformStateRef.current;
|
||||
const component = transformComponentRef.current;
|
||||
const hub = messageHubRef.current;
|
||||
const cmdManager = commandManagerRef.current;
|
||||
|
||||
if (entity && initialState && component && hub && cmdManager) {
|
||||
const mode = transformModeRef.current as TransformOperationType;
|
||||
let newState: TransformState;
|
||||
|
||||
if (component instanceof TransformComponent) {
|
||||
newState = TransformCommand.captureTransformState(component);
|
||||
} else {
|
||||
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
|
||||
}
|
||||
|
||||
// Only create command if state actually changed | 只有状态实际改变时才创建命令
|
||||
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
|
||||
if (hasChanged) {
|
||||
const cmd = new TransformCommand(hub, entity, component, mode, initialState, newState);
|
||||
// Push to undo stack without re-executing (already applied during drag)
|
||||
// 推入撤销栈但不重新执行(拖动时已应用)
|
||||
cmdManager.pushWithoutExecute(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear refs | 清除引用
|
||||
initialTransformStateRef.current = null;
|
||||
transformComponentRef.current = null;
|
||||
|
||||
// Notify Inspector to refresh after transform change
|
||||
if (messageHubRef.current && selectedEntityRef.current) {
|
||||
messageHubRef.current.publish('entity:selected', {
|
||||
@@ -839,8 +931,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(assetsDir);
|
||||
}
|
||||
|
||||
// Collect all asset paths from scene
|
||||
// 从场景中收集所有资产路径
|
||||
// Collect all asset references from scene using generic collector
|
||||
// 使用通用收集器从场景中收集所有资产引用
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const assetPaths = new Set<string>();
|
||||
// GUID 到路径的映射,用于需要通过 GUID 加载的资产
|
||||
@@ -850,69 +942,65 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
// Get asset registry for resolving GUIDs
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService);
|
||||
|
||||
// Scan all components for asset references
|
||||
if (sceneObj.entities) {
|
||||
for (const entity of sceneObj.entities) {
|
||||
if (entity.components) {
|
||||
for (const comp of entity.components) {
|
||||
// Sprite textures
|
||||
if (comp.type === 'Sprite' && comp.data?.texture) {
|
||||
assetPaths.add(comp.data.texture);
|
||||
}
|
||||
// Behavior tree assets
|
||||
if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) {
|
||||
assetPaths.add(comp.data.treeAssetId);
|
||||
}
|
||||
// Tilemap assets
|
||||
if (comp.type === 'Tilemap' && comp.data?.tmxPath) {
|
||||
assetPaths.add(comp.data.tmxPath);
|
||||
}
|
||||
// Audio assets
|
||||
if (comp.type === 'AudioSource' && comp.data?.clip) {
|
||||
assetPaths.add(comp.data.clip);
|
||||
}
|
||||
// Particle assets - resolve GUID to path
|
||||
if (comp.type === 'ParticleSystem' && comp.data?.particleAssetGuid) {
|
||||
const guid = comp.data.particleAssetGuid;
|
||||
if (assetRegistry) {
|
||||
const relativePath = assetRegistry.getPathByGuid(guid);
|
||||
if (relativePath && projectPath) {
|
||||
// Convert relative path to absolute path
|
||||
// 将相对路径转换为绝对路径
|
||||
const absolutePath = `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
|
||||
assetPaths.add(absolutePath);
|
||||
guidToPath.set(guid, absolutePath);
|
||||
// Use generic asset collector to find all asset references
|
||||
// 使用通用资产收集器找到所有资产引用
|
||||
const assetReferences = collectAssetReferences(sceneObj);
|
||||
|
||||
// Also check for texture referenced in particle asset
|
||||
// 同时检查粒子资产中引用的纹理
|
||||
try {
|
||||
const particleContent = await TauriAPI.readFileContent(absolutePath);
|
||||
const particleData = JSON.parse(particleContent);
|
||||
const textureRef = particleData.textureGuid || particleData.texturePath;
|
||||
if (textureRef) {
|
||||
// Check if it's a GUID or a path
|
||||
if (textureRef.includes('-') && textureRef.length > 30) {
|
||||
// Looks like a GUID
|
||||
const textureRelPath = assetRegistry.getPathByGuid(textureRef);
|
||||
if (textureRelPath && projectPath) {
|
||||
const textureAbsPath = `${projectPath}\\${textureRelPath.replace(/\//g, '\\')}`;
|
||||
assetPaths.add(textureAbsPath);
|
||||
guidToPath.set(textureRef, textureAbsPath);
|
||||
}
|
||||
} else {
|
||||
// It's a path
|
||||
const textureAbsPath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
|
||||
assetPaths.add(textureAbsPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
// Helper: check if value looks like a GUID
|
||||
const isGuidLike = (value: string) =>
|
||||
value.includes('-') && value.length >= 30 && value.length <= 40;
|
||||
|
||||
// Helper: resolve GUID to absolute path
|
||||
const resolveGuidToPath = (guid: string): string | null => {
|
||||
if (!assetRegistry || !projectPath) return null;
|
||||
const relativePath = assetRegistry.getPathByGuid(guid);
|
||||
if (!relativePath) return null;
|
||||
return `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
|
||||
};
|
||||
|
||||
// Helper: load particle asset and extract texture references
|
||||
const loadParticleTextures = async (particlePath: string) => {
|
||||
try {
|
||||
const particleContent = await TauriAPI.readFileContent(particlePath);
|
||||
const particleData = JSON.parse(particleContent);
|
||||
const textureRef = particleData.textureGuid || particleData.texturePath;
|
||||
if (textureRef) {
|
||||
if (isGuidLike(textureRef)) {
|
||||
const texturePath = resolveGuidToPath(textureRef);
|
||||
if (texturePath) {
|
||||
assetPaths.add(texturePath);
|
||||
guidToPath.set(textureRef, texturePath);
|
||||
}
|
||||
} else if (projectPath) {
|
||||
const texturePath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
|
||||
assetPaths.add(texturePath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
// Process collected asset references
|
||||
// 处理收集的资产引用
|
||||
for (const ref of assetReferences) {
|
||||
const value = ref.guid;
|
||||
|
||||
// Check if it's a GUID that needs resolution
|
||||
if (isGuidLike(value)) {
|
||||
const absolutePath = resolveGuidToPath(value);
|
||||
if (absolutePath) {
|
||||
assetPaths.add(absolutePath);
|
||||
guidToPath.set(value, absolutePath);
|
||||
|
||||
// If it's a particle asset, also load its texture references
|
||||
if (absolutePath.endsWith('.particle') || absolutePath.endsWith('.particle.json')) {
|
||||
await loadParticleTextures(absolutePath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a direct path
|
||||
assetPaths.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,9 +1019,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
|
||||
// Get filename and determine relative path
|
||||
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
|
||||
// Path format: relative to assets directory, without 'assets/' prefix
|
||||
const filename = assetPath.split(/[/\\]/).pop() || '';
|
||||
const destPath = `${assetsDir}\\${filename}`;
|
||||
const relativePath = `assets/${filename}`;
|
||||
const relativePath = filename;
|
||||
|
||||
// Copy file
|
||||
await TauriAPI.copyFile(assetPath, destPath);
|
||||
@@ -1200,6 +1290,68 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
// Subscribe to prefab edit mode changes | 监听预制体编辑模式变化
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribePrefabEditMode = messageHub.subscribe('prefab:editMode:changed', (data: {
|
||||
isActive: boolean;
|
||||
prefabPath?: string;
|
||||
prefabName?: string;
|
||||
}) => {
|
||||
if (data.isActive && data.prefabName && data.prefabPath) {
|
||||
setPrefabEditMode({
|
||||
isActive: true,
|
||||
prefabName: data.prefabName,
|
||||
prefabPath: data.prefabPath
|
||||
});
|
||||
} else {
|
||||
setPrefabEditMode(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Check initial prefab edit mode state | 检查初始预制体编辑模式状态
|
||||
const sceneManager = Core.services.tryResolve(SceneManagerService);
|
||||
if (sceneManager) {
|
||||
const prefabState = sceneManager.getPrefabEditModeState?.();
|
||||
if (prefabState?.isActive) {
|
||||
setPrefabEditMode({
|
||||
isActive: true,
|
||||
prefabName: prefabState.prefabName,
|
||||
prefabPath: prefabState.prefabPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribePrefabEditMode();
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
// Handle prefab save | 处理预制体保存
|
||||
const handleSavePrefab = useCallback(async () => {
|
||||
const sceneManager = Core.services.tryResolve(SceneManagerService);
|
||||
if (sceneManager?.isPrefabEditMode?.()) {
|
||||
try {
|
||||
await sceneManager.savePrefab();
|
||||
} catch (error) {
|
||||
console.error('Failed to save prefab:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle exit prefab edit mode | 处理退出预制体编辑模式
|
||||
const handleExitPrefabEditMode = useCallback(async (save: boolean = false) => {
|
||||
const sceneManager = Core.services.tryResolve(SceneManagerService);
|
||||
if (sceneManager?.isPrefabEditMode?.()) {
|
||||
try {
|
||||
await sceneManager.exitPrefabEditMode(save);
|
||||
} catch (error) {
|
||||
console.error('Failed to exit prefab edit mode:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (containerRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
@@ -1271,8 +1423,110 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理视口拖放(用于预制体实例化)
|
||||
* Handle viewport drag-drop (for prefab instantiation)
|
||||
*/
|
||||
const handleViewportDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasAssetPath = e.dataTransfer.types.includes('asset-path');
|
||||
if (hasAssetPath) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
// 读取预制体文件 | Read prefab file
|
||||
const prefabJson = await TauriAPI.readFileContent(assetPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
// 获取服务 | Get services
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
|
||||
if (!entityStore || !messageHub || !commandManager) {
|
||||
console.error('[Viewport] Required services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
|
||||
const canvas = canvasRef.current;
|
||||
let worldPos = { x: 0, y: 0 };
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
const canvasX = screenX * dpr;
|
||||
const canvasY = screenY * dpr;
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
worldPos = {
|
||||
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
|
||||
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
|
||||
};
|
||||
}
|
||||
|
||||
// 创建实例化命令 | Create instantiate command
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
prefabData,
|
||||
{
|
||||
position: worldPos,
|
||||
trackInstance: true
|
||||
}
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
|
||||
} catch (error) {
|
||||
console.error('[Viewport] Failed to instantiate prefab:', error);
|
||||
}
|
||||
}, [messageHub, commandManager]);
|
||||
|
||||
return (
|
||||
<div className="viewport" ref={containerRef}>
|
||||
<div
|
||||
className={`viewport ${prefabEditMode?.isActive ? 'prefab-edit-mode' : ''}`}
|
||||
ref={containerRef}
|
||||
onDragOver={handleViewportDragOver}
|
||||
onDrop={handleViewportDrop}
|
||||
>
|
||||
{/* Prefab Edit Mode Toolbar | 预制体编辑模式工具栏 */}
|
||||
{prefabEditMode?.isActive && (
|
||||
<div className="viewport-prefab-toolbar">
|
||||
<div className="viewport-prefab-toolbar-left">
|
||||
<PackageOpen size={14} />
|
||||
<span className="prefab-name">{t('viewport.prefab.editing') || 'Editing'}: {prefabEditMode.prefabName}</span>
|
||||
</div>
|
||||
<div className="viewport-prefab-toolbar-right">
|
||||
<button
|
||||
className="viewport-prefab-btn save"
|
||||
onClick={handleSavePrefab}
|
||||
title={t('viewport.prefab.save') || 'Save Prefab'}
|
||||
>
|
||||
<Save size={14} />
|
||||
<span>{t('viewport.prefab.save') || 'Save'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="viewport-prefab-btn exit"
|
||||
onClick={() => handleExitPrefabEditMode(false)}
|
||||
title={t('viewport.prefab.exit') || 'Exit Edit Mode'}
|
||||
>
|
||||
<X size={14} />
|
||||
<span>{t('viewport.prefab.exit') || 'Exit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Internal Overlay Toolbar */}
|
||||
<div className="viewport-internal-toolbar">
|
||||
<div className="viewport-internal-toolbar-left">
|
||||
@@ -1505,6 +1759,34 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Transform Display | 实时变换显示 */}
|
||||
{liveTransform && (
|
||||
<div className="viewport-live-transform">
|
||||
{liveTransform.type === 'move' && (
|
||||
<>
|
||||
<span className="live-transform-label">X:</span>
|
||||
<span className="live-transform-value">{liveTransform.x.toFixed(1)}</span>
|
||||
<span className="live-transform-label">Y:</span>
|
||||
<span className="live-transform-value">{liveTransform.y.toFixed(1)}</span>
|
||||
</>
|
||||
)}
|
||||
{liveTransform.type === 'rotate' && (
|
||||
<>
|
||||
<span className="live-transform-label">R:</span>
|
||||
<span className="live-transform-value">{liveTransform.rotation?.toFixed(1)}°</span>
|
||||
</>
|
||||
)}
|
||||
{liveTransform.type === 'scale' && (
|
||||
<>
|
||||
<span className="live-transform-label">SX:</span>
|
||||
<span className="live-transform-value">{liveTransform.scaleX?.toFixed(2)}</span>
|
||||
<span className="live-transform-label">SY:</span>
|
||||
<span className="live-transform-value">{liveTransform.scaleY?.toFixed(2)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QRCodeDialog
|
||||
url={devicePreviewUrl}
|
||||
isOpen={showQRDialog}
|
||||
|
||||
@@ -1,164 +1,41 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { TauriAPI } from '../../api/tauri';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
import { InspectorProps, InspectorTarget, AssetFileInfo, RemoteEntity } from './types';
|
||||
/**
|
||||
* 检查器面板组件
|
||||
* Inspector panel component
|
||||
*
|
||||
* 使用 InspectorStore 管理状态,减少 useEffect 数量
|
||||
* Uses InspectorStore for state management to reduce useEffect count
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInspectorStore } from '../../stores';
|
||||
import { InspectorProps } from './types';
|
||||
import { getProfilerService } from './utils';
|
||||
import {
|
||||
EmptyInspector,
|
||||
ExtensionInspector,
|
||||
AssetFileInspector,
|
||||
RemoteEntityInspector,
|
||||
EntityInspector
|
||||
EntityInspector,
|
||||
PrefabInspector
|
||||
} from './views';
|
||||
|
||||
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) {
|
||||
const [target, setTarget] = useState<InspectorTarget>(null);
|
||||
const [componentVersion, setComponentVersion] = useState(0);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [decimalPlaces, setDecimalPlaces] = useState(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
return settings.get<number>('inspector.decimalPlaces', 4);
|
||||
});
|
||||
const targetRef = useRef<InspectorTarget>(null);
|
||||
// ===== 从 InspectorStore 获取状态 | Get state from InspectorStore =====
|
||||
const {
|
||||
target,
|
||||
componentVersion,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
decimalPlaces,
|
||||
} = useInspectorStore();
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current = target;
|
||||
}, [target]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const changedSettings = customEvent.detail;
|
||||
if ('inspector.decimalPlaces' in changedSettings) {
|
||||
setDecimalPlaces(changedSettings['inspector.decimalPlaces']);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChanged);
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEntitySelection = (data: { entity: Entity | null }) => {
|
||||
if (data.entity) {
|
||||
setTarget({ type: 'entity', data: data.entity });
|
||||
} else {
|
||||
setTarget(null);
|
||||
}
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const handleRemoteEntitySelection = (data: { entity: RemoteEntity }) => {
|
||||
setTarget({ type: 'remote-entity', data: data.entity });
|
||||
const profilerService = getProfilerService();
|
||||
if (profilerService && data.entity?.id !== undefined) {
|
||||
profilerService.requestEntityDetails(data.entity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntityDetails = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const details = customEvent.detail;
|
||||
const currentTarget = targetRef.current;
|
||||
if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) {
|
||||
setTarget({ ...currentTarget, details });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionSelection = (data: { data: unknown }) => {
|
||||
setTarget({ type: 'extension', data: data.data as Record<string, any> });
|
||||
};
|
||||
|
||||
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||
const fileInfo = data.fileInfo;
|
||||
|
||||
if (fileInfo.isDirectory) {
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
return;
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'txt',
|
||||
'json',
|
||||
'md',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'css',
|
||||
'html',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'log',
|
||||
'btree',
|
||||
'ecs',
|
||||
'mat',
|
||||
'shader',
|
||||
'tilemap',
|
||||
'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
|
||||
if (isTextFile) {
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
setTarget({ type: 'asset-file', data: fileInfo, content });
|
||||
} catch (error) {
|
||||
console.error('Failed to read file content:', error);
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
}
|
||||
} else if (isImageFile) {
|
||||
setTarget({ type: 'asset-file', data: fileInfo, isImage: true });
|
||||
} else {
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentChange = () => {
|
||||
setComponentVersion((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSceneRestored = () => {
|
||||
// 场景恢复后,清除当前选中的实体(因为旧引用已无效)
|
||||
// 用户需要重新选择实体
|
||||
setTarget(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
return () => {
|
||||
unsubEntitySelect();
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
// Ref 用于 profiler 回调访问最新状态 | Ref for profiler callback to access latest state
|
||||
const targetRef = useRef(target);
|
||||
targetRef.current = target;
|
||||
|
||||
// 自动刷新远程实体详情 | Auto-refresh remote entity details
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || target?.type !== 'remote-entity') {
|
||||
return;
|
||||
@@ -183,6 +60,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
};
|
||||
}, [autoRefresh, target?.type]);
|
||||
|
||||
// ===== 渲染 | Render =====
|
||||
if (!target) {
|
||||
return <EmptyInspector />;
|
||||
}
|
||||
@@ -192,12 +70,17 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'asset-file') {
|
||||
// Check if a plugin provides a custom inspector for this asset type
|
||||
// 预制体文件使用专用检查器 | Prefab files use dedicated inspector
|
||||
if (target.data.extension?.toLowerCase() === 'prefab') {
|
||||
return <PrefabInspector fileInfo={target.data} messageHub={messageHub} commandManager={commandManager} />;
|
||||
}
|
||||
|
||||
// 检查插件是否提供自定义检查器 | Check if a plugin provides a custom inspector
|
||||
const customInspector = inspectorRegistry.render(target, { target, projectPath });
|
||||
if (customInspector) {
|
||||
return customInspector;
|
||||
}
|
||||
// Fall back to default asset file inspector
|
||||
// 回退到默认资产文件检查器 | Fall back to default asset file inspector
|
||||
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||
}
|
||||
|
||||
@@ -217,7 +100,16 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'entity') {
|
||||
return <EntityInspector entity={target.data} messageHub={messageHub} commandManager={commandManager} componentVersion={componentVersion} />;
|
||||
return (
|
||||
<EntityInspector
|
||||
entity={target.data}
|
||||
messageHub={messageHub}
|
||||
commandManager={commandManager}
|
||||
componentVersion={componentVersion}
|
||||
isLocked={isLocked}
|
||||
onLockChange={setIsLocked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 预制体实例信息组件
|
||||
* Prefab instance info component
|
||||
*
|
||||
* 显示预制体实例状态和操作按钮(Open, Select, Revert, Apply)。
|
||||
* Displays prefab instance status and action buttons.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService, CommandManager } from '@esengine/editor-core';
|
||||
import { ApplyPrefabCommand, RevertPrefabCommand, BreakPrefabLinkCommand } from '../../../application/commands/prefab';
|
||||
import { useLocale } from '../../../hooks/useLocale';
|
||||
import '../../../styles/PrefabInstanceInfo.css';
|
||||
|
||||
interface PrefabInstanceInfoProps {
|
||||
entity: Entity;
|
||||
prefabService: PrefabService;
|
||||
messageHub: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体实例信息组件
|
||||
* Prefab instance info component
|
||||
*/
|
||||
export function PrefabInstanceInfo({
|
||||
entity,
|
||||
prefabService,
|
||||
messageHub,
|
||||
commandManager
|
||||
}: PrefabInstanceInfoProps) {
|
||||
const { t } = useLocale();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 获取预制体实例组件 | Get prefab instance component
|
||||
const prefabComp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!prefabComp) return null;
|
||||
|
||||
// 只显示根实例的完整信息 | Only show full info for root instances
|
||||
if (!prefabComp.isRoot) return null;
|
||||
|
||||
// 提取预制体名称 | Extract prefab name
|
||||
const prefabPath = prefabComp.sourcePrefabPath;
|
||||
const prefabName = prefabPath
|
||||
? prefabPath.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab'
|
||||
: 'Unknown';
|
||||
|
||||
// 修改数量 | Modification count
|
||||
const modificationCount = prefabComp.modifiedProperties.length;
|
||||
const hasModifications = modificationCount > 0;
|
||||
|
||||
// 打开预制体编辑模式 | Open prefab edit mode
|
||||
const handleOpen = useCallback(() => {
|
||||
messageHub.publish('prefab:editMode:enter', {
|
||||
prefabPath: prefabComp.sourcePrefabPath
|
||||
});
|
||||
}, [messageHub, prefabComp.sourcePrefabPath]);
|
||||
|
||||
// 在内容浏览器中选择 | Select in content browser
|
||||
const handleSelect = useCallback(() => {
|
||||
messageHub.publish('content-browser:select', {
|
||||
path: prefabComp.sourcePrefabPath
|
||||
});
|
||||
}, [messageHub, prefabComp.sourcePrefabPath]);
|
||||
|
||||
// 还原所有修改 | Revert all modifications
|
||||
const handleRevert = useCallback(async () => {
|
||||
if (!hasModifications) return;
|
||||
|
||||
const confirmed = window.confirm(t('inspector.prefab.revertConfirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (commandManager) {
|
||||
const command = new RevertPrefabCommand(prefabService, messageHub, entity);
|
||||
await commandManager.execute(command);
|
||||
} else {
|
||||
await prefabService.revertInstance(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Revert failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [hasModifications, commandManager, prefabService, messageHub, entity, t]);
|
||||
|
||||
// 应用修改到预制体 | Apply modifications to prefab
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!hasModifications) return;
|
||||
|
||||
const confirmed = window.confirm(t('inspector.prefab.applyConfirm', { name: prefabName }));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (commandManager) {
|
||||
const command = new ApplyPrefabCommand(prefabService, messageHub, entity);
|
||||
await commandManager.execute(command);
|
||||
} else {
|
||||
await prefabService.applyToPrefab(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Apply failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [hasModifications, commandManager, prefabService, messageHub, entity, prefabName, t]);
|
||||
|
||||
// 解包预制体(断开链接)| Unpack prefab (break link)
|
||||
const handleUnpack = useCallback(() => {
|
||||
const confirmed = window.confirm(t('inspector.prefab.unpackConfirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
if (commandManager) {
|
||||
const command = new BreakPrefabLinkCommand(prefabService, messageHub, entity);
|
||||
commandManager.execute(command);
|
||||
} else {
|
||||
prefabService.breakPrefabLink(entity);
|
||||
}
|
||||
}, [commandManager, prefabService, messageHub, entity, t]);
|
||||
|
||||
return (
|
||||
<div className="prefab-instance-info">
|
||||
<div className="prefab-instance-header">
|
||||
<span className="prefab-icon">📦</span>
|
||||
<span className="prefab-label">{t('inspector.prefab.source')}:</span>
|
||||
<span className="prefab-name" title={prefabPath}>{prefabName}</span>
|
||||
{hasModifications && (
|
||||
<span className="prefab-modified-badge" title={t('inspector.prefab.modifications', { count: modificationCount })}>
|
||||
{modificationCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prefab-instance-actions">
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleOpen}
|
||||
title={t('inspector.prefab.open')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.open')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleSelect}
|
||||
title={t('inspector.prefab.select')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.select')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-revert"
|
||||
onClick={handleRevert}
|
||||
title={t('inspector.prefab.revertAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.revert')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-apply"
|
||||
onClick={handleApply}
|
||||
title={t('inspector.prefab.applyAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.apply')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-unpack"
|
||||
onClick={handleUnpack}
|
||||
title={t('inspector.prefab.unpack')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
⛓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
|
||||
@@ -119,18 +119,18 @@ export function AssetField({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (readonly) return;
|
||||
if (readonly || !assetRegistry) return;
|
||||
|
||||
// Try to get GUID from drag data first
|
||||
const assetGuid = e.dataTransfer.getData('asset-guid');
|
||||
if (assetGuid && isGUID(assetGuid)) {
|
||||
// Validate extension if needed
|
||||
if (fileExtension && assetRegistry) {
|
||||
if (fileExtension) {
|
||||
const path = assetRegistry.getPathByGuid(assetGuid);
|
||||
if (path && !path.endsWith(fileExtension)) {
|
||||
return; // Extension mismatch
|
||||
@@ -140,50 +140,63 @@ export function AssetField({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: handle asset-path and convert to GUID
|
||||
// Handle asset-path: convert to GUID or register
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
|
||||
// Try to get GUID from path
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [relativePath, relativePath.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found (backward compatibility)
|
||||
onChange(assetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file drops
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
if (file) {
|
||||
// For file drops, we still use filename (need to register first)
|
||||
onChange(file.name);
|
||||
|
||||
// GUID 不存在,尝试注册 | GUID not found, try to register
|
||||
const absolutePath = assetPath.includes(':') ? assetPath : null;
|
||||
if (absolutePath) {
|
||||
try {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered dropped asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register dropped asset:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped asset without GUID: "${assetPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text/plain drops (might be GUID or path)
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && (!fileExtension || text.endsWith(fileExtension))) {
|
||||
// Try to convert to GUID if it's a path
|
||||
if (assetRegistry && !isGUID(text)) {
|
||||
const guid = assetRegistry.getGuidByPath(text);
|
||||
if (isGUID(text)) {
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get GUID from path
|
||||
const pathVariants = [text, text.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onChange(text);
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped text without GUID: "${text}"`);
|
||||
}
|
||||
}, [onChange, fileExtension, readonly, assetRegistry]);
|
||||
|
||||
@@ -192,23 +205,60 @@ export function AssetField({
|
||||
setShowPicker(true);
|
||||
}, [readonly]);
|
||||
|
||||
const handlePickerSelect = useCallback((path: string) => {
|
||||
// Convert path to GUID if possible
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
const handlePickerSelect = useCallback(async (path: string) => {
|
||||
// Convert path to GUID - 必须使用 GUID,不能使用路径!
|
||||
// Must use GUID, cannot use path!
|
||||
if (!assetRegistry) {
|
||||
console.error(`[AssetField] AssetRegistry not available, cannot select asset`);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [
|
||||
relativePath,
|
||||
relativePath.replace(/\\/g, '/'), // 统一为正斜杠
|
||||
];
|
||||
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
console.log(`[AssetField] Found GUID for path "${path}": ${guid}`);
|
||||
onChange(guid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found
|
||||
onChange(path);
|
||||
|
||||
// GUID 不存在,尝试注册资产(创建 .meta 文件)
|
||||
// GUID not found, try to register asset (create .meta file)
|
||||
console.warn(`[AssetField] GUID not found for path "${path}", registering asset...`);
|
||||
|
||||
try {
|
||||
// 使用绝对路径注册 | Register using absolute path
|
||||
const absolutePath = path.includes(':') ? path : null;
|
||||
if (absolutePath) {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered new asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register asset:`, error);
|
||||
}
|
||||
|
||||
// 注册失败,不能使用路径(会导致打包后找不到)
|
||||
// Registration failed, cannot use path (will fail after build)
|
||||
console.error(`[AssetField] Cannot use asset without GUID: "${path}". Please ensure the asset is in a managed directory (assets/, scripts/, scenes/).`);
|
||||
setShowPicker(false);
|
||||
}, [onChange, assetRegistry]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Setting
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import { assetManager as globalAssetManager } from '@esengine/asset-system';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
@@ -77,7 +77,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const loaderFactory = globalAssetManager.getLoaderFactory();
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
const registeredTypes = loaderFactory?.getRegisteredTypes() || [];
|
||||
|
||||
// Combine built-in types with registered types (deduplicated)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry, PrefabService } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { NotificationService } from '../../../services/NotificationService';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import { PrefabInstanceInfo } from '../common/PrefabInstanceInfo';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
@@ -35,19 +36,49 @@ interface EntityInspectorProps {
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
componentVersion: number;
|
||||
/** 是否锁定检视器 | Whether inspector is locked */
|
||||
isLocked?: boolean;
|
||||
/** 锁定状态变化回调 | Lock state change callback */
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
|
||||
// 默认展开所有组件
|
||||
return new Set(entity.components.map((_, index) => index));
|
||||
export function EntityInspector({
|
||||
entity,
|
||||
messageHub,
|
||||
commandManager,
|
||||
componentVersion,
|
||||
isLocked = false,
|
||||
onLockChange
|
||||
}: EntityInspectorProps) {
|
||||
// 使用组件类型名追踪折叠状态(持久化到 localStorage)
|
||||
// Use component type names to track collapsed state (persisted to localStorage)
|
||||
const [collapsedComponentTypes, setCollapsedComponentTypes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('inspector-collapsed-components');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 保存折叠状态到 localStorage | Save collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'inspector-collapsed-components',
|
||||
JSON.stringify([...collapsedComponentTypes])
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [collapsedComponentTypes]);
|
||||
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [selectedComponentIndex, setSelectedComponentIndex] = useState(-1);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [propertySearchQuery, setPropertySearchQuery] = useState('');
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -56,29 +87,13 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length]);
|
||||
// 检查实体是否为预制体实例 | Check if entity is a prefab instance
|
||||
const isPrefabInstance = useMemo(() => {
|
||||
return entity.hasComponent(PrefabInstanceComponent);
|
||||
}, [entity, componentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -121,6 +136,46 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
return grouped;
|
||||
}, [availableComponents, searchQuery]);
|
||||
|
||||
// 创建扁平化的可见组件列表(用于键盘导航)
|
||||
// Create flat list of visible components for keyboard navigation
|
||||
const flatVisibleComponents = useMemo(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
for (const [category, components] of filteredAndGroupedComponents.entries()) {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
if (!isCollapsed) {
|
||||
result.push(...components);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [filteredAndGroupedComponents, collapsedCategories, searchQuery]);
|
||||
|
||||
// 重置选中索引当搜索变化时 | Reset selected index when search changes
|
||||
useEffect(() => {
|
||||
setSelectedComponentIndex(searchQuery ? 0 : -1);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 处理组件搜索的键盘导航 | Handle keyboard navigation for component search
|
||||
const handleComponentSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev =>
|
||||
prev < flatVisibleComponents.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter' && selectedComponentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selectedComponent = flatVisibleComponents[selectedComponentIndex];
|
||||
if (selectedComponent?.type) {
|
||||
handleAddComponent(selectedComponent.type);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowComponentMenu(false);
|
||||
}
|
||||
}, [flatVisibleComponents, selectedComponentIndex]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setCollapsedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -130,13 +185,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
});
|
||||
};
|
||||
|
||||
const toggleComponentExpanded = (index: number) => {
|
||||
setExpandedComponents((prev) => {
|
||||
const toggleComponentExpanded = (componentTypeName: string) => {
|
||||
setCollapsedComponentTypes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
if (newSet.has(componentTypeName)) {
|
||||
// 已折叠,展开它 | Was collapsed, expand it
|
||||
newSet.delete(componentTypeName);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
// 已展开,折叠它 | Was expanded, collapse it
|
||||
newSet.add(componentTypeName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -244,6 +301,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
// 过滤掉标记为隐藏的组件(如 Hierarchy, PrefabInstance)
|
||||
// Filter out components marked as hidden (e.g., Hierarchy, PrefabInstance)
|
||||
if (isComponentInstanceHiddenInInspector(component)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
@@ -271,7 +334,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<div className="inspector-header-left">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => setIsLocked(!isLocked)}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
@@ -282,6 +345,16 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<span className="inspector-object-count">1 object</span>
|
||||
</div>
|
||||
|
||||
{/* Prefab Instance Info | 预制体实例信息 */}
|
||||
{isPrefabInstance && prefabService && (
|
||||
<PrefabInstanceInfo
|
||||
entity={entity}
|
||||
prefabService={prefabService}
|
||||
messageHub={messageHub}
|
||||
commandManager={commandManager}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="inspector-search">
|
||||
<Search size={14} />
|
||||
@@ -290,7 +363,27 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="Search..."
|
||||
value={propertySearchQuery}
|
||||
onChange={(e) => setPropertySearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && propertySearchQuery) {
|
||||
e.preventDefault();
|
||||
setPropertySearchQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{propertySearchQuery && (
|
||||
<button
|
||||
className="inspector-search-clear"
|
||||
onClick={() => setPropertySearchQuery('')}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{propertySearchQuery && (
|
||||
<span className="inspector-search-count">
|
||||
{filteredComponents.length} / {entity.components.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
@@ -335,6 +428,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="搜索组件..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleComponentSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{filteredAndGroupedComponents.size === 0 ? (
|
||||
@@ -343,35 +437,45 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let globalIndex = 0;
|
||||
return Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
const startIndex = globalIndex;
|
||||
if (!isCollapsed) {
|
||||
globalIndex += components.length;
|
||||
}
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info, idx) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
const itemIndex = startIndex + idx;
|
||||
const isSelected = itemIndex === selectedComponentIndex;
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className={`component-dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
onMouseEnter={() => setSelectedComponentIndex(itemIndex)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,8 +490,9 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
) : (
|
||||
filteredComponents.map((component: Component) => {
|
||||
const originalIndex = entity.components.indexOf(component);
|
||||
const isExpanded = expandedComponents.has(originalIndex);
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
// 使用组件类型名判断展开状态(未在折叠集合中 = 展开)
|
||||
const isExpanded = !collapsedComponentTypes.has(componentName);
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
@@ -399,7 +504,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(originalIndex)}
|
||||
onClick={() => toggleComponentExpanded(componentName)}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 预制体检查器
|
||||
* Prefab Inspector
|
||||
*
|
||||
* 显示预制体文件的信息、实体层级预览和实例化功能。
|
||||
* Displays prefab file information, entity hierarchy preview, and instantiation features.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PackageOpen, Box, Layers, Clock, HardDrive, Tag, Play, ChevronRight, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Core, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, SerializedPrefabEntity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, CommandManager } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../../../api/tauri';
|
||||
import { InstantiatePrefabCommand } from '../../../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
|
||||
interface PrefabInspectorProps {
|
||||
fileInfo: AssetFileInfo;
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(timestamp?: number): string {
|
||||
if (!timestamp) return '未知';
|
||||
// 如果是毫秒级时间戳,不需要转换 | If millisecond timestamp, no conversion needed
|
||||
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体层级节点组件
|
||||
* Entity hierarchy node component
|
||||
*/
|
||||
function EntityNode({ entity, depth = 0 }: { entity: SerializedPrefabEntity; depth?: number }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
const hasChildren = entity.children && entity.children.length > 0;
|
||||
const componentCount = entity.components?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="prefab-entity-node">
|
||||
<div
|
||||
className="prefab-entity-row"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
>
|
||||
<span className="prefab-entity-expand">
|
||||
{hasChildren ? (
|
||||
expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
</span>
|
||||
<Box size={14} className="prefab-entity-icon" />
|
||||
<span className="prefab-entity-name">{entity.name}</span>
|
||||
<span className="prefab-entity-components">
|
||||
({componentCount} 组件)
|
||||
</span>
|
||||
</div>
|
||||
{expanded && hasChildren && (
|
||||
<div className="prefab-entity-children">
|
||||
{entity.children.map((child, index) => (
|
||||
<EntityNode
|
||||
key={child.id || index}
|
||||
entity={child as SerializedPrefabEntity}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrefabInspector({ fileInfo, messageHub, commandManager }: PrefabInspectorProps) {
|
||||
const [prefabData, setPrefabData] = useState<PrefabData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [instantiating, setInstantiating] = useState(false);
|
||||
|
||||
// 加载预制体数据 | Load prefab data
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadPrefab() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
const data = PrefabSerializer.deserialize(content);
|
||||
|
||||
// 验证预制体数据 | Validate prefab data
|
||||
const validation = PrefabSerializer.validate(data);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`无效的预制体: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setPrefabData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '加载预制体失败');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPrefab();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileInfo.path]);
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
const handleInstantiate = useCallback(async () => {
|
||||
if (!prefabData || instantiating) return;
|
||||
|
||||
setInstantiating(true);
|
||||
try {
|
||||
// 从 Core.services 获取服务,使用 tryResolve 避免类型问题
|
||||
// Get services from Core.services, use tryResolve to avoid type issues
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
const hub = messageHub || Core.services.tryResolve(MessageHub) as MessageHub | null;
|
||||
const cmdManager = commandManager;
|
||||
|
||||
if (!entityStore || !hub || !cmdManager) {
|
||||
throw new Error('必要的服务未初始化 | Required services not initialized');
|
||||
}
|
||||
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
hub,
|
||||
prefabData,
|
||||
{ trackInstance: true }
|
||||
);
|
||||
cmdManager.execute(command);
|
||||
|
||||
console.log(`[PrefabInspector] Prefab instantiated: ${prefabData.metadata.name}`);
|
||||
} catch (err) {
|
||||
console.error('[PrefabInspector] Failed to instantiate prefab:', err);
|
||||
} finally {
|
||||
setInstantiating(false);
|
||||
}
|
||||
}, [prefabData, instantiating, messageHub, commandManager]);
|
||||
|
||||
// 统计实体和组件数量 | Count entities and components
|
||||
const countEntities = useCallback((entity: SerializedPrefabEntity): { entities: number; components: number } => {
|
||||
let entities = 1;
|
||||
let components = entity.components?.length || 0;
|
||||
|
||||
if (entity.children) {
|
||||
for (const child of entity.children) {
|
||||
const childCounts = countEntities(child as SerializedPrefabEntity);
|
||||
entities += childCounts.entities;
|
||||
components += childCounts.components;
|
||||
}
|
||||
}
|
||||
|
||||
return { entities, components };
|
||||
}, []);
|
||||
|
||||
const counts = prefabData ? countEntities(prefabData.root) : { entities: 0, components: 0 };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#f87171' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#f87171' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="entity-inspector prefab-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{prefabData?.metadata.name || fileInfo.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="inspector-content">
|
||||
{/* 预制体信息 | Prefab Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">预制体信息</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">版本</label>
|
||||
<span className="property-value-text">v{prefabData?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Layers size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
实体数量
|
||||
</label>
|
||||
<span className="property-value-text">{counts.entities}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Box size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
组件总数
|
||||
</label>
|
||||
<span className="property-value-text">{counts.components}</span>
|
||||
</div>
|
||||
|
||||
{prefabData?.metadata.description && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">描述</label>
|
||||
<span className="property-value-text">{prefabData.metadata.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.tags && prefabData.metadata.tags.length > 0 && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Tag size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
标签
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{prefabData.metadata.tags.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 | File Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">文件信息</div>
|
||||
|
||||
{fileInfo.size !== undefined && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
大小
|
||||
</label>
|
||||
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.createdAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
创建时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.modifiedAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
修改时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.modifiedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 组件类型 | Component Types */}
|
||||
{prefabData?.metadata.componentTypes && prefabData.metadata.componentTypes.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件类型</div>
|
||||
<div className="prefab-component-types">
|
||||
{prefabData.metadata.componentTypes.map((type) => (
|
||||
<span key={type} className="prefab-component-type-tag">
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实体层级 | Entity Hierarchy */}
|
||||
{prefabData?.root && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">实体层级</div>
|
||||
<div className="prefab-hierarchy">
|
||||
<EntityNode entity={prefabData.root} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 | Action Buttons */}
|
||||
<div className="inspector-section">
|
||||
<button
|
||||
className="prefab-instantiate-btn"
|
||||
onClick={handleInstantiate}
|
||||
disabled={instantiating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
backgroundColor: '#4ade80',
|
||||
color: '#1a1a1a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: instantiating ? 'wait' : 'pointer',
|
||||
opacity: instantiating ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
<Play size={14} />
|
||||
{instantiating ? '实例化中...' : '实例化到场景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { ExtensionInspector } from './ExtensionInspector';
|
||||
export { AssetFileInspector } from './AssetFileInspector';
|
||||
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||
export { EntityInspector } from './EntityInspector';
|
||||
export { PrefabInspector } from './PrefabInspector';
|
||||
|
||||
@@ -82,8 +82,8 @@ export function useAssetSystem() {
|
||||
}, [assetManager]);
|
||||
|
||||
/**
|
||||
* Load texture for sprite component
|
||||
* 为精灵组件加载纹理
|
||||
* 通过路径为精灵组件加载纹理(用户脚本使用)
|
||||
* Load texture for sprite component by path (for user scripts)
|
||||
*/
|
||||
const loadTextureForSprite = useCallback(async (path: string): Promise<number> => {
|
||||
if (!engineIntegration) return 0;
|
||||
@@ -96,6 +96,21 @@ export function useAssetSystem() {
|
||||
}
|
||||
}, [engineIntegration]);
|
||||
|
||||
/**
|
||||
* 通过 GUID 为精灵组件加载纹理(内部引用使用)
|
||||
* Load texture for sprite component by GUID (for internal references)
|
||||
*/
|
||||
const loadTextureByGuid = useCallback(async (guid: string): Promise<number> => {
|
||||
if (!engineIntegration) return 0;
|
||||
|
||||
try {
|
||||
return await engineIntegration.loadTextureByGuid(guid);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load texture by GUID ${guid}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}, [engineIntegration]);
|
||||
|
||||
/**
|
||||
* Create asset reference
|
||||
* 创建资产引用
|
||||
@@ -133,6 +148,7 @@ export function useAssetSystem() {
|
||||
loadProgress,
|
||||
loadAssetByPath,
|
||||
loadTextureForSprite,
|
||||
loadTextureByGuid,
|
||||
createAssetReference,
|
||||
unloadUnusedAssets,
|
||||
getStatistics
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Store 订阅 Hook
|
||||
* Store subscriptions hook
|
||||
*
|
||||
* 集中管理所有服务和 MessageHub 订阅,初始化 Zustand stores
|
||||
* Centrally manages all service and MessageHub subscriptions, initializes Zustand stores
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Core, HierarchyComponent, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { useHierarchyStore } from '../stores/HierarchyStore';
|
||||
import { useInspectorStore } from '../stores/InspectorStore';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import type { AssetFileInfo } from '../components/inspectors/types';
|
||||
|
||||
interface UseStoreSubscriptionsOptions {
|
||||
messageHub: MessageHub | null;
|
||||
entityStore: EntityStoreService | null;
|
||||
sceneManager: SceneManagerService | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 store 订阅
|
||||
* Initialize store subscriptions
|
||||
*
|
||||
* 在 App.tsx 中调用一次,集中管理所有订阅
|
||||
* Call once in App.tsx to centrally manage all subscriptions
|
||||
*/
|
||||
export function useStoreSubscriptions({
|
||||
messageHub,
|
||||
entityStore,
|
||||
sceneManager,
|
||||
enabled,
|
||||
}: UseStoreSubscriptionsOptions): void {
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// ===== HierarchyStore 订阅 | HierarchyStore subscriptions =====
|
||||
useEffect(() => {
|
||||
if (!enabled || !messageHub || !entityStore) return;
|
||||
|
||||
const {
|
||||
setSceneInfo,
|
||||
setPrefabEditMode,
|
||||
setEntities,
|
||||
setSelectedIds,
|
||||
setExpandedIds,
|
||||
} = useHierarchyStore.getState();
|
||||
|
||||
// 更新场景信息 | Update scene info
|
||||
const updateSceneInfo = () => {
|
||||
if (sceneManager) {
|
||||
const state = sceneManager.getSceneState();
|
||||
setSceneInfo({
|
||||
sceneName: state.sceneName,
|
||||
sceneFilePath: state.currentScenePath || null,
|
||||
isModified: state.isModified,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 更新实体列表 | Update entity list
|
||||
const updateEntities = () => {
|
||||
setEntities([...entityStore.getRootEntities()]);
|
||||
};
|
||||
|
||||
// 处理实体选择 | Handle entity selection
|
||||
const handleEntitySelection = (data: { entity: { id: number } | null }) => {
|
||||
if (data.entity) {
|
||||
setSelectedIds(new Set([data.entity.id]));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// 处理预制体编辑模式 | Handle prefab edit mode
|
||||
const handlePrefabEditMode = (data: {
|
||||
isActive: boolean;
|
||||
prefabPath?: string;
|
||||
prefabName?: string;
|
||||
}) => {
|
||||
if (data.isActive && data.prefabName && data.prefabPath) {
|
||||
setPrefabEditMode({
|
||||
isActive: true,
|
||||
prefabName: data.prefabName,
|
||||
prefabPath: data.prefabPath,
|
||||
});
|
||||
} else {
|
||||
setPrefabEditMode(null);
|
||||
}
|
||||
updateSceneInfo();
|
||||
};
|
||||
|
||||
// 初始化 | Initialize
|
||||
updateSceneInfo();
|
||||
updateEntities();
|
||||
|
||||
// 检查预制体编辑模式状态 | Check prefab edit mode state
|
||||
if (sceneManager) {
|
||||
const prefabState = (sceneManager as any).getPrefabEditModeState?.();
|
||||
if (prefabState?.isActive) {
|
||||
setPrefabEditMode({
|
||||
isActive: true,
|
||||
prefabName: prefabState.prefabName,
|
||||
prefabPath: prefabState.prefabPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅场景事件 | Subscribe to scene events
|
||||
const unsubLoaded = messageHub.subscribe('scene:loaded', (data: any) => {
|
||||
if (data.sceneName) {
|
||||
setSceneInfo({
|
||||
sceneName: data.sceneName,
|
||||
sceneFilePath: data.path || null,
|
||||
isModified: data.isModified || false,
|
||||
});
|
||||
} else {
|
||||
updateSceneInfo();
|
||||
}
|
||||
updateEntities();
|
||||
});
|
||||
const unsubNew = messageHub.subscribe('scene:new', () => {
|
||||
updateSceneInfo();
|
||||
updateEntities();
|
||||
});
|
||||
const unsubSaved = messageHub.subscribe('scene:saved', updateSceneInfo);
|
||||
const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo);
|
||||
const unsubRestored = messageHub.subscribe('scene:restored', updateEntities);
|
||||
|
||||
// 订阅实体事件 | Subscribe to entity events
|
||||
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
||||
const unsubRemove = messageHub.subscribe('entity:removed', updateEntities);
|
||||
const unsubClear = messageHub.subscribe('entities:cleared', updateEntities);
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||
const unsubReordered = messageHub.subscribe('entity:reordered', updateEntities);
|
||||
const unsubReparented = messageHub.subscribe('entity:reparented', updateEntities);
|
||||
|
||||
// 订阅预制体事件 | Subscribe to prefab events
|
||||
const unsubPrefabEditMode = messageHub.subscribe('prefab:editMode:changed', handlePrefabEditMode);
|
||||
const unsubPrefabEnter = messageHub.subscribe('prefab:editMode:enter', updateEntities);
|
||||
const unsubPrefabExit = messageHub.subscribe('prefab:editMode:exit', updateEntities);
|
||||
|
||||
return () => {
|
||||
unsubLoaded();
|
||||
unsubNew();
|
||||
unsubSaved();
|
||||
unsubModified();
|
||||
unsubRestored();
|
||||
unsubAdd();
|
||||
unsubRemove();
|
||||
unsubClear();
|
||||
unsubSelect();
|
||||
unsubReordered();
|
||||
unsubReparented();
|
||||
unsubPrefabEditMode();
|
||||
unsubPrefabEnter();
|
||||
unsubPrefabExit();
|
||||
};
|
||||
}, [enabled, messageHub, entityStore, sceneManager]);
|
||||
|
||||
// ===== HierarchyStore 远程订阅 | HierarchyStore remote subscriptions =====
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const profilerService = getProfilerService();
|
||||
if (!profilerService) return;
|
||||
|
||||
const {
|
||||
setIsRemoteConnected,
|
||||
setRemoteEntities,
|
||||
setRemoteSceneName,
|
||||
} = useHierarchyStore.getState();
|
||||
|
||||
// 初始状态 | Initial state
|
||||
setIsRemoteConnected(profilerService.isConnected());
|
||||
|
||||
const unsubscribe = profilerService.subscribe((data) => {
|
||||
const connected = profilerService.isConnected();
|
||||
setIsRemoteConnected(connected);
|
||||
|
||||
if (connected && data.entities && data.entities.length > 0) {
|
||||
setRemoteEntities((prev) => {
|
||||
if (prev.length !== data.entities!.length) {
|
||||
return data.entities!;
|
||||
}
|
||||
const hasChanged = data.entities!.some((entity, index) => {
|
||||
const prevEntity = prev[index];
|
||||
return !prevEntity ||
|
||||
prevEntity.id !== entity.id ||
|
||||
prevEntity.name !== entity.name ||
|
||||
prevEntity.componentCount !== entity.componentCount;
|
||||
});
|
||||
return hasChanged ? data.entities! : prev;
|
||||
});
|
||||
|
||||
// 获取远程场景名称 | Get remote scene name
|
||||
const currentRemoteName = useHierarchyStore.getState().remoteSceneName;
|
||||
if (!currentRemoteName && data.entities[0]) {
|
||||
profilerService.requestEntityDetails(data.entities[0].id);
|
||||
}
|
||||
} else if (!connected) {
|
||||
setRemoteEntities([]);
|
||||
setRemoteSceneName(null);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听实体详情获取远程场景名 | Listen for entity details to get remote scene name
|
||||
const handleEntityDetails = ((event: CustomEvent) => {
|
||||
const details = event.detail;
|
||||
if (details?.sceneName) {
|
||||
setRemoteSceneName(details.sceneName);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// ===== InspectorStore 订阅 | InspectorStore subscriptions =====
|
||||
useEffect(() => {
|
||||
if (!enabled || !messageHub) return;
|
||||
|
||||
const {
|
||||
setEntityTarget,
|
||||
setRemoteEntityTarget,
|
||||
setAssetFileTarget,
|
||||
setExtensionTarget,
|
||||
clearTarget,
|
||||
updateRemoteEntityDetails,
|
||||
incrementComponentVersion,
|
||||
setDecimalPlaces,
|
||||
} = useInspectorStore.getState();
|
||||
|
||||
// 初始化设置 | Initialize settings
|
||||
const settings = SettingsService.getInstance();
|
||||
setDecimalPlaces(settings.get<number>('inspector.decimalPlaces', 4));
|
||||
|
||||
// 设置变更处理 | Handle settings change
|
||||
const handleSettingsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const changedSettings = customEvent.detail;
|
||||
if ('inspector.decimalPlaces' in changedSettings) {
|
||||
setDecimalPlaces(changedSettings['inspector.decimalPlaces']);
|
||||
}
|
||||
};
|
||||
|
||||
// 实体选择处理 | Handle entity selection
|
||||
const handleEntitySelection = (data: { entity: any | null }) => {
|
||||
if (data.entity) {
|
||||
setEntityTarget(data.entity);
|
||||
} else {
|
||||
clearTarget();
|
||||
}
|
||||
};
|
||||
|
||||
// 远程实体选择处理 | Handle remote entity selection
|
||||
const handleRemoteEntitySelection = (data: { entity: any }) => {
|
||||
setRemoteEntityTarget(data.entity);
|
||||
const profilerService = getProfilerService();
|
||||
if (profilerService && data.entity?.id !== undefined) {
|
||||
profilerService.requestEntityDetails(data.entity.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 实体详情处理 | Handle entity details
|
||||
const handleEntityDetails = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const details = customEvent.detail;
|
||||
if (details?.id !== undefined) {
|
||||
updateRemoteEntityDetails(details.id, details);
|
||||
}
|
||||
};
|
||||
|
||||
// 扩展选择处理 | Handle extension selection
|
||||
const handleExtensionSelection = (data: { data: unknown }) => {
|
||||
setExtensionTarget(data.data as Record<string, unknown>);
|
||||
};
|
||||
|
||||
// 资产文件选择处理 | Handle asset file selection
|
||||
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||
const fileInfo = data.fileInfo;
|
||||
|
||||
if (fileInfo.isDirectory) {
|
||||
setAssetFileTarget(fileInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'txt', 'json', 'md', 'ts', 'tsx', 'js', 'jsx', 'css', 'html', 'xml',
|
||||
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'btree', 'ecs',
|
||||
'mat', 'shader', 'tilemap', 'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
|
||||
if (isTextFile) {
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
setAssetFileTarget(fileInfo, content);
|
||||
} catch (error) {
|
||||
console.error('Failed to read file content:', error);
|
||||
setAssetFileTarget(fileInfo);
|
||||
}
|
||||
} else if (isImageFile) {
|
||||
setAssetFileTarget(fileInfo, undefined, true);
|
||||
} else {
|
||||
setAssetFileTarget(fileInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// 场景恢复处理 | Handle scene restored
|
||||
const handleSceneRestored = () => {
|
||||
clearTarget();
|
||||
};
|
||||
|
||||
// 订阅 | Subscribe
|
||||
window.addEventListener('settings:changed', handleSettingsChanged);
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', incrementComponentVersion);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', incrementComponentVersion);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', incrementComponentVersion);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChanged);
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
unsubEntitySelect();
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
};
|
||||
}, [enabled, messageHub]);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"hierarchy": {
|
||||
"visibility": "Toggle Visibility",
|
||||
"hideEntity": "Hide Entity",
|
||||
"showEntity": "Show Entity",
|
||||
"emptyHint": "No entities in scene"
|
||||
},
|
||||
"behaviorTree": {
|
||||
"title": "Behavior Tree Editor",
|
||||
"close": "Close",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"hierarchy": {
|
||||
"visibility": "切换可见性",
|
||||
"hideEntity": "隐藏实体",
|
||||
"showEntity": "显示实体",
|
||||
"emptyHint": "场景中没有实体"
|
||||
},
|
||||
"behaviorTree": {
|
||||
"title": "行为树编辑器",
|
||||
"close": "关闭",
|
||||
|
||||
@@ -42,7 +42,8 @@ export const en: Translations = {
|
||||
type: 'Type',
|
||||
value: 'Value',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
disabled: 'Disabled',
|
||||
clear: 'Clear'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -73,7 +74,8 @@ export const en: Translations = {
|
||||
forum: 'Forum',
|
||||
console: 'Console',
|
||||
assetBrowser: 'Asset Browser',
|
||||
profiler: 'Profiler'
|
||||
profiler: 'Profiler',
|
||||
contentBrowser: 'Content Browser'
|
||||
},
|
||||
|
||||
hierarchy: {
|
||||
@@ -88,6 +90,7 @@ export const en: Translations = {
|
||||
createFolder: 'Create Folder',
|
||||
deleteEntity: 'Delete Entity',
|
||||
deleteConfirm: 'Are you sure you want to delete entity "{{name}}"?',
|
||||
deleteConfirmWithChildren: 'Are you sure you want to delete entity "{{name}}" and its {{count}} child(ren)?',
|
||||
renameEntity: 'Rename Entity',
|
||||
duplicateEntity: 'Duplicate Entity',
|
||||
localScene: 'Local Scene',
|
||||
@@ -113,7 +116,8 @@ export const en: Translations = {
|
||||
tilemap: 'Tilemap',
|
||||
camera2d: 'Camera 2D',
|
||||
particleEffect: 'Particle Effect'
|
||||
}
|
||||
},
|
||||
editingPrefab: 'Editing Prefab'
|
||||
},
|
||||
|
||||
inspector: {
|
||||
@@ -132,6 +136,43 @@ export const en: Translations = {
|
||||
empty: 'No components',
|
||||
add: 'Add Component',
|
||||
remove: 'Remove'
|
||||
},
|
||||
prefab: {
|
||||
instance: 'Prefab Instance',
|
||||
source: 'Prefab',
|
||||
open: 'Open',
|
||||
select: 'Select',
|
||||
selectAsset: 'Select Prefab Asset',
|
||||
revert: 'Revert',
|
||||
revertAll: 'Revert All',
|
||||
revertTo: 'Revert to Prefab',
|
||||
apply: 'Apply',
|
||||
applyAll: 'Apply All',
|
||||
applyTo: "Apply to '{{name}}'",
|
||||
unpack: 'Unpack',
|
||||
modified: 'Modified',
|
||||
modifications: '{{count}} modification(s)',
|
||||
noModifications: 'No modifications',
|
||||
revertProperty: 'Revert to prefab value',
|
||||
applyConfirm: 'Apply changes to prefab "{{name}}"?',
|
||||
revertConfirm: 'Revert all changes to prefab defaults?',
|
||||
unpackConfirm: 'Unpack prefab instance? This will remove the link to the source prefab.',
|
||||
applyTitle: 'Apply to Prefab',
|
||||
revertTitle: 'Revert to Prefab',
|
||||
unpackTitle: 'Unpack Prefab',
|
||||
applySuccess: 'Changes applied to prefab',
|
||||
applyFailed: 'Failed to apply changes to prefab',
|
||||
revertSuccess: 'Instance reverted to prefab defaults',
|
||||
revertFailed: 'Failed to revert instance',
|
||||
unpackSuccess: 'Prefab link broken',
|
||||
unpackFailed: 'Failed to unpack prefab'
|
||||
},
|
||||
array: {
|
||||
empty: 'Empty array',
|
||||
add: 'Add',
|
||||
remove: 'Remove',
|
||||
dragToReorder: 'Drag to reorder',
|
||||
dropAsset: 'Drop asset'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -186,6 +227,15 @@ export const en: Translations = {
|
||||
},
|
||||
notifications: {
|
||||
noScene: 'No Scene'
|
||||
},
|
||||
prefab: {
|
||||
editing: 'Editing',
|
||||
save: 'Save Prefab',
|
||||
exit: 'Exit Edit Mode',
|
||||
saveAndExit: 'Save & Exit',
|
||||
discardChanges: 'Discard Changes',
|
||||
savedSuccess: 'Prefab saved: {{name}}',
|
||||
saveFailed: 'Failed to save prefab'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -238,7 +288,8 @@ export const en: Translations = {
|
||||
network: 'Network',
|
||||
sourceControl: 'Source Control',
|
||||
allSaved: 'All Saved',
|
||||
revisionControl: 'Revision Control'
|
||||
revisionControl: 'Revision Control',
|
||||
resetLayout: 'Reset Layout'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -459,9 +510,16 @@ export const en: Translations = {
|
||||
saveAll: 'Save All',
|
||||
search: 'Search',
|
||||
items: 'items',
|
||||
searchResults: 'Found {{found}} of {{total}} items',
|
||||
selectedCount: '{{count}} selected',
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
emptyHint: 'Drag files here or right-click to create new assets',
|
||||
loading: 'Loading...',
|
||||
noSearchResults: 'No results found',
|
||||
noSearchResultsHint: 'Try a different search term',
|
||||
createNew: 'Create New',
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New',
|
||||
managedDirectoryTooltip: 'GUID-managed directory - Assets here get unique IDs for references',
|
||||
@@ -684,7 +742,12 @@ export const en: Translations = {
|
||||
envReady: 'Environment Ready',
|
||||
envNotReady: 'Environment Issue',
|
||||
esbuildReady: 'esbuild ready',
|
||||
esbuildMissing: 'esbuild not found'
|
||||
esbuildMissing: 'esbuild not found',
|
||||
esbuildNotInstalled: 'esbuild Installation Required',
|
||||
esbuildRequired: 'esbuild is required for compiling TypeScript code.',
|
||||
esbuildInstallPrompt: 'Click the button below to install esbuild globally via npm.',
|
||||
installingEsbuild: 'Installing esbuild...',
|
||||
installNow: 'Install Now'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -826,7 +889,8 @@ export const en: Translations = {
|
||||
settings: 'Settings',
|
||||
copyError: 'Copy error',
|
||||
showDetails: 'Show details',
|
||||
collapse: 'Collapse'
|
||||
collapse: 'Collapse',
|
||||
openFolder: 'Open Folder'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -42,7 +42,8 @@ export const zh: Translations = {
|
||||
type: '类型',
|
||||
value: '值',
|
||||
enabled: '启用',
|
||||
disabled: '禁用'
|
||||
disabled: '禁用',
|
||||
clear: '清除'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -73,7 +74,8 @@ export const zh: Translations = {
|
||||
forum: '论坛',
|
||||
console: '控制台',
|
||||
assetBrowser: '资源浏览器',
|
||||
profiler: '性能分析器'
|
||||
profiler: '性能分析器',
|
||||
contentBrowser: '内容管理器'
|
||||
},
|
||||
|
||||
hierarchy: {
|
||||
@@ -88,6 +90,7 @@ export const zh: Translations = {
|
||||
createFolder: '创建文件夹',
|
||||
deleteEntity: '删除实体',
|
||||
deleteConfirm: '确定要删除实体 "{{name}}" 吗?',
|
||||
deleteConfirmWithChildren: '确定要删除实体 "{{name}}" 及其 {{count}} 个子节点吗?',
|
||||
renameEntity: '重命名实体',
|
||||
duplicateEntity: '复制实体',
|
||||
localScene: '本地场景',
|
||||
@@ -113,7 +116,8 @@ export const zh: Translations = {
|
||||
tilemap: '瓦片地图',
|
||||
camera2d: '2D 相机',
|
||||
particleEffect: '粒子效果'
|
||||
}
|
||||
},
|
||||
editingPrefab: '编辑预制体'
|
||||
},
|
||||
|
||||
inspector: {
|
||||
@@ -132,6 +136,43 @@ export const zh: Translations = {
|
||||
empty: '无组件',
|
||||
add: '添加组件',
|
||||
remove: '移除'
|
||||
},
|
||||
prefab: {
|
||||
instance: '预制体实例',
|
||||
source: '预制体',
|
||||
open: '打开',
|
||||
select: '定位',
|
||||
selectAsset: '定位预制体资产',
|
||||
revert: '还原',
|
||||
revertAll: '全部还原',
|
||||
revertTo: '还原到预制体',
|
||||
apply: '应用',
|
||||
applyAll: '全部应用',
|
||||
applyTo: "应用到 '{{name}}'",
|
||||
unpack: '解包',
|
||||
modified: '已修改',
|
||||
modifications: '{{count}} 处修改',
|
||||
noModifications: '无修改',
|
||||
revertProperty: '还原为预制体值',
|
||||
applyConfirm: '将修改应用到预制体 "{{name}}"?',
|
||||
revertConfirm: '将所有修改还原为预制体默认值?',
|
||||
unpackConfirm: '解包预制体实例?这将断开与源预制体的链接。',
|
||||
applyTitle: '应用到预制体',
|
||||
revertTitle: '还原到预制体',
|
||||
unpackTitle: '解包预制体',
|
||||
applySuccess: '已应用修改到预制体',
|
||||
applyFailed: '应用修改到预制体失败',
|
||||
revertSuccess: '已还原为预制体默认值',
|
||||
revertFailed: '还原实例失败',
|
||||
unpackSuccess: '已断开预制体链接',
|
||||
unpackFailed: '解包预制体失败'
|
||||
},
|
||||
array: {
|
||||
empty: '空数组',
|
||||
add: '添加',
|
||||
remove: '删除',
|
||||
dragToReorder: '拖拽排序',
|
||||
dropAsset: '拖拽资源'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -186,6 +227,15 @@ export const zh: Translations = {
|
||||
},
|
||||
notifications: {
|
||||
noScene: '无场景'
|
||||
},
|
||||
prefab: {
|
||||
editing: '编辑中',
|
||||
save: '保存预制体',
|
||||
exit: '退出编辑',
|
||||
saveAndExit: '保存并退出',
|
||||
discardChanges: '放弃修改',
|
||||
savedSuccess: '预制体已保存:{{name}}',
|
||||
saveFailed: '保存预制体失败'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -238,7 +288,8 @@ export const zh: Translations = {
|
||||
network: '网络',
|
||||
sourceControl: '源代码管理',
|
||||
allSaved: '所有已保存',
|
||||
revisionControl: '版本控制'
|
||||
revisionControl: '版本控制',
|
||||
resetLayout: '重置布局'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -459,9 +510,16 @@ export const zh: Translations = {
|
||||
saveAll: '全部保存',
|
||||
search: '搜索',
|
||||
items: '项',
|
||||
searchResults: '找到 {{found}} / {{total}} 项',
|
||||
selectedCount: '已选 {{count}} 项',
|
||||
dockInLayout: '停靠到布局',
|
||||
noProject: '未加载项目',
|
||||
empty: '文件夹为空',
|
||||
emptyHint: '拖放文件到此处或右键创建新资产',
|
||||
loading: '加载中...',
|
||||
noSearchResults: '未找到结果',
|
||||
noSearchResultsHint: '尝试其他搜索词',
|
||||
createNew: '新建',
|
||||
newFolder: '新建文件夹',
|
||||
newPrefix: '新建',
|
||||
managedDirectoryTooltip: 'GUID 管理的目录 - 此处的资产会获得唯一 ID 以便引用',
|
||||
@@ -684,7 +742,12 @@ export const zh: Translations = {
|
||||
envReady: '环境就绪',
|
||||
envNotReady: '环境问题',
|
||||
esbuildReady: 'esbuild 就绪',
|
||||
esbuildMissing: '未找到 esbuild'
|
||||
esbuildMissing: '未找到 esbuild',
|
||||
esbuildNotInstalled: '需要安装 esbuild',
|
||||
esbuildRequired: 'esbuild 是编译 TypeScript 代码所必需的工具。',
|
||||
esbuildInstallPrompt: '点击下方按钮将使用 npm 全局安装 esbuild。',
|
||||
installingEsbuild: '正在安装 esbuild...',
|
||||
installNow: '立即安装'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -826,7 +889,8 @@ export const zh: Translations = {
|
||||
settings: '设置',
|
||||
copyError: '复制错误信息',
|
||||
showDetails: '显示详情',
|
||||
collapse: '收起'
|
||||
collapse: '收起',
|
||||
openFolder: '打开文件夹'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'reflect-metadata';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { setGlobalLogLevel, LogLevel } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
import './styles/index.css';
|
||||
@@ -10,6 +11,49 @@ import './i18n/config';
|
||||
// Set log level to Warn in production to reduce console noise
|
||||
setGlobalLogLevel(LogLevel.Warn);
|
||||
|
||||
// 写入错误日志到文件
|
||||
// Write error log to file
|
||||
async function logErrorToFile(type: string, error: unknown) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const errorStr = error instanceof Error
|
||||
? `${error.message}\n${error.stack || ''}`
|
||||
: String(error);
|
||||
const logEntry = `[${timestamp}] [${type}]\n${errorStr}\n${'='.repeat(80)}\n`;
|
||||
|
||||
// 写入用户目录下的日志文件
|
||||
// Write to log file in user directory
|
||||
const tempDir = await invoke<string>('get_temp_dir');
|
||||
const logPath = `${tempDir}/esengine-editor-crash.log`;
|
||||
await invoke('append_to_log', { path: logPath, content: logEntry });
|
||||
console.log(`[Error logged to ${logPath}]`);
|
||||
} catch (e) {
|
||||
console.error('Failed to write error log:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Global error handlers to prevent silent crashes
|
||||
// 全局错误处理器,防止静默崩溃
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('[Global Error]', event.error || event.message);
|
||||
logErrorToFile('Global Error', event.error || event.message);
|
||||
// Prevent default to stop page reload on uncaught errors
|
||||
// 阻止默认行为,防止未捕获错误导致页面刷新
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('[Unhandled Promise Rejection]', event.reason);
|
||||
logErrorToFile('Unhandled Promise Rejection', event.reason);
|
||||
// Prevent default to stop potential page reload
|
||||
// 阻止默认行为,防止潜在的页面刷新
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// 记录应用启动,方便判断是否发生了刷新
|
||||
// Log app start to help detect refreshes
|
||||
logErrorToFile('App Start', `Editor started at ${new Date().toISOString()}`);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
|
||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, type SystemContext } from '@esengine/editor-core';
|
||||
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger } from '@esengine/ecs-framework';
|
||||
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, PluginServiceRegistry } from '@esengine/ecs-framework';
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, PluginServiceRegistry, TransformTypeToken } from '@esengine/engine-core';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
@@ -18,11 +18,10 @@ import {
|
||||
EngineIntegration,
|
||||
AssetPathResolver,
|
||||
AssetPlatform,
|
||||
globalPathResolver,
|
||||
SceneResourceManager,
|
||||
assetManager as globalAssetManager,
|
||||
AssetType,
|
||||
AssetManagerToken
|
||||
AssetManagerToken,
|
||||
isValidGUID
|
||||
} from '@esengine/asset-system';
|
||||
import {
|
||||
GameRuntime,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
import { BehaviorTreeSystemToken } from '@esengine/behavior-tree';
|
||||
import { Physics2DSystemToken } from '@esengine/physics-rapier2d';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import { WebInputSubsystem } from '@esengine/platform-web';
|
||||
import { resetEngineState } from '../hooks/useEngine';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { IdGenerator } from '../utils/idGenerator';
|
||||
@@ -120,13 +120,17 @@ export class EngineService {
|
||||
};
|
||||
|
||||
// 创建编辑器平台适配器
|
||||
// Create editor platform adapter
|
||||
const platform = new EditorPlatformAdapter({
|
||||
wasmModule: esEngine,
|
||||
pathTransformer,
|
||||
gizmoDataProvider: (component, entity, isSelected) =>
|
||||
GizmoRegistry.getGizmoData(component, entity, isSelected),
|
||||
hasGizmoProvider: (component) =>
|
||||
GizmoRegistry.hasProvider(component.constructor as any)
|
||||
GizmoRegistry.hasProvider(component.constructor as any),
|
||||
// 提供输入子系统用于 Play 模式下的游戏输入
|
||||
// Provide input subsystem for game input in Play mode
|
||||
inputSubsystemFactory: () => new WebInputSubsystem()
|
||||
});
|
||||
|
||||
// 创建统一运行时
|
||||
@@ -212,6 +216,13 @@ export class EngineService {
|
||||
services.register(EngineIntegrationToken, this._engineIntegration);
|
||||
services.register(TransformTypeToken, TransformComponent);
|
||||
|
||||
// 注册 Canvas 元素(用于坐标转换等)
|
||||
// Register canvas element (for coordinate conversion, etc.)
|
||||
const canvas = this._runtime.platform.getCanvas();
|
||||
if (canvas) {
|
||||
services.register(CanvasElementToken, canvas);
|
||||
}
|
||||
|
||||
// 创建系统上下文
|
||||
const context: SystemContext = {
|
||||
isEditor: true,
|
||||
@@ -225,6 +236,10 @@ export class EngineService {
|
||||
// 插件注册完加载器后,重新同步资产(确保类型正确)
|
||||
await this._syncAssetRegistryToManager();
|
||||
|
||||
// Subscribe to asset changes to sync new assets to runtime
|
||||
// 订阅资产变化以将新资产同步到运行时
|
||||
this._subscribeToAssetChanges();
|
||||
|
||||
// 同步服务注册表到 GameRuntime(用于 start/stop 时启用/禁用系统)
|
||||
// Sync service registry to GameRuntime (for enabling/disabling systems on start/stop)
|
||||
const runtimeServices = this._runtime.getServiceRegistry();
|
||||
@@ -272,6 +287,10 @@ export class EngineService {
|
||||
* 清理模块系统
|
||||
*/
|
||||
clearModuleSystems(): void {
|
||||
// Unsubscribe from asset change events
|
||||
// 取消订阅资产变化事件
|
||||
this._unsubscribeFromAssetChanges();
|
||||
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (pluginManager) {
|
||||
pluginManager.clearSceneSystems();
|
||||
@@ -394,9 +413,9 @@ export class EngineService {
|
||||
*/
|
||||
private async _initializeAssetSystem(): Promise<void> {
|
||||
try {
|
||||
// Use global assetManager instance so all systems share the same manager
|
||||
// 使用全局 assetManager 实例,以便所有系统共享同一个管理器
|
||||
this._assetManager = globalAssetManager;
|
||||
// Create a new AssetManager instance for this editor session
|
||||
// 为此编辑器会话创建新的 AssetManager 实例
|
||||
this._assetManager = new AssetManager();
|
||||
|
||||
// Set up asset reader for Tauri environment.
|
||||
// 为 Tauri 环境设置资产读取器。
|
||||
@@ -413,8 +432,8 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync AssetRegistryService data to global assetManager's database
|
||||
// 将 AssetRegistryService 的数据同步到全局 assetManager 的数据库
|
||||
// Sync AssetRegistryService data to assetManager's database
|
||||
// 将 AssetRegistryService 的数据同步到 assetManager 的数据库
|
||||
await this._syncAssetRegistryToManager();
|
||||
|
||||
const pathTransformerFn = (path: string) => {
|
||||
@@ -440,11 +459,6 @@ export class EngineService {
|
||||
pathTransformer: pathTransformerFn
|
||||
});
|
||||
|
||||
globalPathResolver.updateConfig({
|
||||
platform: AssetPlatform.Editor,
|
||||
pathTransformer: pathTransformerFn
|
||||
});
|
||||
|
||||
if (this._runtime?.bridge) {
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
@@ -565,6 +579,103 @@ export class EngineService {
|
||||
logger.debug('Asset sync complete');
|
||||
}
|
||||
|
||||
/** Unsubscribe function for assets:changed event | assets:changed 事件的取消订阅函数 */
|
||||
private _assetsChangedUnsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Subscribe to assets:changed events and sync new assets to runtime AssetManager.
|
||||
* 订阅 assets:changed 事件,将新资产同步到运行时 AssetManager。
|
||||
*/
|
||||
private _subscribeToAssetChanges(): void {
|
||||
if (this._assetsChangedUnsubscribe) return; // Already subscribed
|
||||
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (!messageHub || !this._assetManager) return;
|
||||
|
||||
const database = this._assetManager.getDatabase();
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
const loaderFactory = this._assetManager.getLoaderFactory();
|
||||
|
||||
this._assetsChangedUnsubscribe = messageHub.subscribe(
|
||||
'assets:changed',
|
||||
async (data: { type: string; path: string; relativePath: string; guid: string }) => {
|
||||
if (data.type === 'add' || data.type === 'modify') {
|
||||
// Get full asset info from registry
|
||||
// 从注册表获取完整资产信息
|
||||
const asset = assetRegistry?.getAsset(data.guid);
|
||||
if (!asset) return;
|
||||
|
||||
// Determine asset type
|
||||
// 确定资产类型
|
||||
let assetType: string | null = null;
|
||||
|
||||
// 1. Try to get type from meta file
|
||||
const meta = assetRegistry?.metaManager.getMetaByGUID(data.guid);
|
||||
if (meta?.loaderType) {
|
||||
assetType = meta.loaderType;
|
||||
}
|
||||
|
||||
// 2. Try to get type from registered loaders
|
||||
if (!assetType) {
|
||||
assetType = loaderFactory?.getAssetTypeByPath?.(asset.path) ?? null;
|
||||
}
|
||||
|
||||
// 3. Fallback by extension
|
||||
if (!assetType) {
|
||||
const ext = asset.path.substring(asset.path.lastIndexOf('.')).toLowerCase();
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)) {
|
||||
assetType = AssetType.Texture;
|
||||
} else if (['.mp3', '.wav', '.ogg', '.m4a'].includes(ext)) {
|
||||
assetType = AssetType.Audio;
|
||||
} else if (['.json'].includes(ext)) {
|
||||
assetType = AssetType.Json;
|
||||
} else if (['.txt', '.md', '.xml', '.yaml'].includes(ext)) {
|
||||
assetType = AssetType.Text;
|
||||
} else {
|
||||
assetType = AssetType.Custom;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to runtime database
|
||||
// 添加到运行时数据库
|
||||
database.addAsset({
|
||||
guid: asset.guid,
|
||||
path: asset.path,
|
||||
type: assetType,
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
hash: asset.hash || '',
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
tags: new Map(),
|
||||
lastModified: asset.lastModified,
|
||||
version: 1
|
||||
});
|
||||
|
||||
logger.debug(`Asset synced to runtime: ${asset.path} (${data.guid})`);
|
||||
} else if (data.type === 'remove') {
|
||||
// Remove from runtime database
|
||||
// 从运行时数据库移除
|
||||
database.removeAsset(data.guid);
|
||||
logger.debug(`Asset removed from runtime: ${data.guid}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('Subscribed to assets:changed events');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from assets:changed events.
|
||||
* 取消订阅 assets:changed 事件。
|
||||
*/
|
||||
private _unsubscribeFromAssetChanges(): void {
|
||||
if (this._assetsChangedUnsubscribe) {
|
||||
this._assetsChangedUnsubscribe();
|
||||
this._assetsChangedUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup asset path resolver for EngineRenderSystem.
|
||||
* 为 EngineRenderSystem 设置资产路径解析器。
|
||||
@@ -578,10 +689,6 @@ export class EngineService {
|
||||
const renderSystem = this._runtime?.renderSystem;
|
||||
if (!renderSystem) return;
|
||||
|
||||
// UUID v4 regex for GUID detection
|
||||
// UUID v4 正则表达式用于 GUID 检测
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
renderSystem.setAssetPathResolver((guidOrPath: string): string => {
|
||||
// Skip if already a valid URL
|
||||
// 如果已经是有效的 URL 则跳过
|
||||
@@ -589,9 +696,9 @@ export class EngineService {
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
// Check if this is a GUID
|
||||
// 检查是否为 GUID
|
||||
if (uuidRegex.test(guidOrPath)) {
|
||||
// Check if this is a GUID using the unified validation function
|
||||
// 使用统一的验证函数检查是否为 GUID
|
||||
if (isValidGUID(guidOrPath)) {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (assetRegistry) {
|
||||
const relativePath = assetRegistry.getPathByGuid(guidOrPath);
|
||||
@@ -659,7 +766,8 @@ export class EngineService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture through asset system
|
||||
* 通过相对路径加载纹理资产(用户脚本使用)
|
||||
* Load texture asset by relative path (for user scripts)
|
||||
*/
|
||||
async loadTextureAsset(path: string): Promise<number> {
|
||||
if (!this._assetSystemInitialized || this._initializationError) {
|
||||
@@ -684,6 +792,29 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 GUID 加载纹理资产(内部引用使用)
|
||||
* Load texture asset by GUID (for internal references)
|
||||
*/
|
||||
async loadTextureAssetByGuid(guid: string): Promise<number> {
|
||||
if (!this._assetSystemInitialized || this._initializationError) {
|
||||
console.warn('Asset system not initialized');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!this._engineIntegration) {
|
||||
console.warn('Engine integration not available');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._engineIntegration.loadTextureByGuid(guid);
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture asset by GUID:', guid, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset manager
|
||||
*/
|
||||
@@ -875,7 +1006,15 @@ export class EngineService {
|
||||
* Save a snapshot of the current scene state.
|
||||
*/
|
||||
saveSceneSnapshot(): boolean {
|
||||
return this._runtime?.saveSceneSnapshot() ?? false;
|
||||
const success = this._runtime?.saveSceneSnapshot() ?? false;
|
||||
|
||||
if (success) {
|
||||
// 清除 UI 渲染缓存(因为纹理已被清除)
|
||||
// Clear UI render caches (since textures have been cleared)
|
||||
invalidateUIRenderCaches();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,56 +2,26 @@
|
||||
* Plugin SDK Registry
|
||||
* 插件 SDK 注册器
|
||||
*
|
||||
* 将编辑器核心模块暴露为全局变量,供插件使用。
|
||||
* 插件构建时将这些模块标记为 external,运行时从全局对象获取。
|
||||
*
|
||||
* Exposes editor core modules as global variables for plugin use.
|
||||
* Plugins mark these modules as external during build, and access them from global object at runtime.
|
||||
* 将统一 SDK 暴露为全局变量,供插件和用户代码使用。
|
||||
* Exposes unified SDK as global variable for plugins and user code.
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 编辑器启动时调用 PluginSDKRegistry.initialize()
|
||||
* 2. 插件构建配置中设置 external: getSDKPackageNames()
|
||||
* 3. 插件构建配置中设置 globals: getSDKGlobalsMapping()
|
||||
* 2. 用户代码使用 import { ... } from '@esengine/sdk'
|
||||
* 3. 运行时通过 window.__ESENGINE_SDK__ 访问
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type { ServiceToken } from '@esengine/engine-core';
|
||||
import type { ServiceToken } from '@esengine/ecs-framework';
|
||||
import {
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
EditorConfig,
|
||||
getSDKGlobalsMapping,
|
||||
getSDKPackageNames,
|
||||
getEnabledSDKModules,
|
||||
type ISDKModuleConfig
|
||||
EditorConfig
|
||||
} from '@esengine/editor-core';
|
||||
|
||||
// 动态导入所有 SDK 模块
|
||||
// Dynamic import all SDK modules
|
||||
import * as ecsFramework from '@esengine/ecs-framework';
|
||||
import * as editorRuntime from '@esengine/editor-runtime';
|
||||
import * as behaviorTree from '@esengine/behavior-tree';
|
||||
import * as engineCore from '@esengine/engine-core';
|
||||
import * as sprite from '@esengine/sprite';
|
||||
import * as camera from '@esengine/camera';
|
||||
import * as audio from '@esengine/audio';
|
||||
|
||||
/**
|
||||
* 模块实例映射
|
||||
* Module instance mapping
|
||||
*
|
||||
* 由于 ES 模块的静态导入限制,我们需要维护一个包名到模块的映射。
|
||||
* Due to ES module static import limitations, we need to maintain a mapping from package name to module.
|
||||
*/
|
||||
const MODULE_INSTANCES: Record<string, any> = {
|
||||
'@esengine/ecs-framework': ecsFramework,
|
||||
'@esengine/editor-runtime': editorRuntime,
|
||||
'@esengine/behavior-tree': behaviorTree,
|
||||
'@esengine/engine-core': engineCore,
|
||||
'@esengine/sprite': sprite,
|
||||
'@esengine/camera': camera,
|
||||
'@esengine/audio': audio,
|
||||
};
|
||||
// 统一 SDK 导入
|
||||
// Unified SDK import
|
||||
import * as sdk from '@esengine/sdk';
|
||||
|
||||
// 存储服务实例引用(在初始化时设置)
|
||||
// Service instance references (set during initialization)
|
||||
@@ -62,8 +32,8 @@ let messageHubInstance: MessageHub | null = null;
|
||||
* 插件 API 接口
|
||||
* Plugin API interface
|
||||
*
|
||||
* 为插件提供统一的访问接口,避免模块实例不一致的问题。
|
||||
* Provides unified access interface for plugins, avoiding module instance inconsistency issues.
|
||||
* 为插件提供统一的访问接口。
|
||||
* Provides unified access interface for plugins.
|
||||
*/
|
||||
export interface IPluginAPI {
|
||||
/** 获取当前场景 | Get current scene */
|
||||
@@ -83,32 +53,17 @@ export interface IPluginAPI {
|
||||
getCore(): typeof Core;
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK 全局对象类型
|
||||
* SDK global object type
|
||||
*/
|
||||
export interface ISDKGlobal {
|
||||
/** 动态模块加载 | Dynamic module loading */
|
||||
require: (moduleName: string) => any;
|
||||
/** 插件 API | Plugin API */
|
||||
api: IPluginAPI;
|
||||
/** 其他动态注册的模块 | Other dynamically registered modules */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件 SDK 注册器
|
||||
* Plugin SDK Registry
|
||||
*
|
||||
* 职责:
|
||||
* 1. 将 SDK 模块暴露到全局对象
|
||||
* 1. 将统一 SDK 暴露到全局对象 window.__ESENGINE_SDK__
|
||||
* 2. 提供插件 API
|
||||
* 3. 支持动态模块加载
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Expose SDK modules to global object
|
||||
* 1. Expose unified SDK to global object window.__ESENGINE_SDK__
|
||||
* 2. Provide plugin API
|
||||
* 3. Support dynamic module loading
|
||||
*/
|
||||
export class PluginSDKRegistry {
|
||||
private static initialized = false;
|
||||
@@ -117,8 +72,8 @@ export class PluginSDKRegistry {
|
||||
* 初始化 SDK 注册器
|
||||
* Initialize SDK registry
|
||||
*
|
||||
* 将所有配置的 SDK 模块暴露到全局对象。
|
||||
* Exposes all configured SDK modules to global object.
|
||||
* 将统一 SDK 暴露到全局对象。
|
||||
* Exposes unified SDK to global object.
|
||||
*/
|
||||
static initialize(): void {
|
||||
if (this.initialized) {
|
||||
@@ -137,28 +92,13 @@ export class PluginSDKRegistry {
|
||||
console.error('[PluginSDKRegistry] MessageHub not registered yet!');
|
||||
}
|
||||
|
||||
// 创建 SDK 全局对象
|
||||
// Create SDK global object
|
||||
const sdkGlobal: ISDKGlobal = {
|
||||
require: this.requireModule.bind(this),
|
||||
// 创建增强的 SDK 全局对象,包含 API
|
||||
// Create enhanced SDK global object with API
|
||||
const sdkGlobal = {
|
||||
...sdk,
|
||||
api: this.createPluginAPI(),
|
||||
};
|
||||
|
||||
// 从配置自动注册所有启用的模块
|
||||
// Auto-register all enabled modules from config
|
||||
const enabledModules = getEnabledSDKModules();
|
||||
for (const config of enabledModules) {
|
||||
const moduleInstance = MODULE_INSTANCES[config.packageName];
|
||||
if (moduleInstance) {
|
||||
sdkGlobal[config.globalKey] = moduleInstance;
|
||||
} else {
|
||||
console.warn(
|
||||
`[PluginSDKRegistry] Module "${config.packageName}" configured but not imported. ` +
|
||||
`Please add import statement for this module.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置全局对象
|
||||
// Set global object
|
||||
const sdkGlobalName = EditorConfig.globals.sdk;
|
||||
@@ -166,10 +106,7 @@ export class PluginSDKRegistry {
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
console.log(
|
||||
`[PluginSDKRegistry] Initialized with ${enabledModules.length} modules:`,
|
||||
enabledModules.map(m => m.globalKey)
|
||||
);
|
||||
console.log(`[PluginSDKRegistry] Initialized SDK at window.${sdkGlobalName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,24 +142,6 @@ export class PluginSDKRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态获取模块(用于 CommonJS 风格的插件)
|
||||
* Dynamic module loading (for CommonJS style plugins)
|
||||
*
|
||||
* @param moduleName 模块包名 | Module package name
|
||||
*/
|
||||
private static requireModule(moduleName: string): any {
|
||||
const module = MODULE_INSTANCES[moduleName];
|
||||
if (!module) {
|
||||
const availableModules = Object.keys(MODULE_INSTANCES).join(', ');
|
||||
throw new Error(
|
||||
`[PluginSDKRegistry] Unknown module: "${moduleName}". ` +
|
||||
`Available modules: ${availableModules}`
|
||||
);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
* Check if initialized
|
||||
@@ -232,26 +151,18 @@ export class PluginSDKRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的 SDK 模块名称
|
||||
* Get all available SDK module names
|
||||
*
|
||||
* @deprecated 使用 getSDKPackageNames() 代替 | Use getSDKPackageNames() instead
|
||||
* 获取 SDK 包名
|
||||
* Get SDK package name
|
||||
*/
|
||||
static getAvailableModules(): string[] {
|
||||
return getSDKPackageNames();
|
||||
static getSDKPackageName(): string {
|
||||
return '@esengine/sdk';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局变量映射(用于生成插件构建配置)
|
||||
* Get globals config (for generating plugin build config)
|
||||
*
|
||||
* @deprecated 使用 getSDKGlobalsMapping() 代替 | Use getSDKGlobalsMapping() instead
|
||||
* 获取 SDK 全局变量名
|
||||
* Get SDK global variable name
|
||||
*/
|
||||
static getGlobalsConfig(): Record<string, string> {
|
||||
return getSDKGlobalsMapping();
|
||||
static getSDKGlobalName(): string {
|
||||
return EditorConfig.globals.sdk;
|
||||
}
|
||||
}
|
||||
|
||||
// 重新导出辅助函数,方便插件构建工具使用
|
||||
// Re-export helper functions for plugin build tools
|
||||
export { getSDKGlobalsMapping, getSDKPackageNames, getEnabledSDKModules };
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ModuleManifest {
|
||||
wasmPaths?: string[];
|
||||
runtimeWasmPath?: string;
|
||||
externalDependencies?: string[];
|
||||
/** Global key for window.__ESENGINE__ (optional, defaults to camelCase of id) */
|
||||
globalKey?: string;
|
||||
}
|
||||
|
||||
export class RuntimeResolver {
|
||||
@@ -263,6 +265,7 @@ export class RuntimeResolver {
|
||||
const copiedModules: string[] = [];
|
||||
|
||||
// Copy each module's dist files
|
||||
const missingModules: string[] = [];
|
||||
for (const module of modules) {
|
||||
const moduleDistDir = `${this.engineModulesPath}\\${module.id}\\dist`;
|
||||
const moduleSrcFile = `${moduleDistDir}\\index.mjs`;
|
||||
@@ -294,9 +297,18 @@ export class RuntimeResolver {
|
||||
}
|
||||
|
||||
copiedModules.push(module.id);
|
||||
console.log(`[RuntimeResolver] Copied module: ${module.id} (${module.name})`);
|
||||
} else {
|
||||
missingModules.push(module.id);
|
||||
console.warn(`[RuntimeResolver] MISSING dist for module: ${module.id} (looked in ${moduleDistDir})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingModules.length > 0) {
|
||||
console.error(`[RuntimeResolver] ${missingModules.length} modules have missing dist files:`, missingModules);
|
||||
console.error('[RuntimeResolver] Please run: npm run build in the workspace to build all modules');
|
||||
}
|
||||
|
||||
// Copy external dependencies (e.g., rapier2d)
|
||||
await this.copyExternalDependencies(modules, libsDir, importMap);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type { ServiceToken } from '@esengine/engine-core';
|
||||
import type { ServiceToken } from '@esengine/ecs-framework';
|
||||
import { ProfilerServiceToken, type IProfilerService } from './tokens';
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* These services are defined in editor-app, so Tokens are also defined here.
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/engine-core';
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
|
||||
// ============================================================================
|
||||
// Profiler Data Types (定义在这里以避免循环依赖)
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { IJsonModel, IJsonTabSetNode, IJsonRowNode } from 'flexlayout-react';
|
||||
import type { FlexDockPanel } from './types';
|
||||
|
||||
// 固定宽度配置(像素)| Fixed width configuration (pixels)
|
||||
const RIGHT_PANEL_WIDTH = 320;
|
||||
const RIGHT_HIERARCHY_HEIGHT_RATIO = 40;
|
||||
const RIGHT_INSPECTOR_HEIGHT_RATIO = 60;
|
||||
// 布局比例配置 | Layout ratio configuration
|
||||
const DEFAULT_RIGHT_PANEL_WEIGHT = 20; // 右侧面板占 20%
|
||||
const DEFAULT_LEFT_PANEL_WEIGHT = 80; // 左侧面板占 80%
|
||||
const DEFAULT_VIEWPORT_HEIGHT_RATIO = 80; // Viewport 占 80%
|
||||
const DEFAULT_BOTTOM_PANEL_HEIGHT_RATIO = 20; // 底部面板占 20%
|
||||
const DEFAULT_RIGHT_TOP_HEIGHT_RATIO = 40;
|
||||
const DEFAULT_RIGHT_BOTTOM_HEIGHT_RATIO = 60;
|
||||
|
||||
export class LayoutBuilder {
|
||||
static createDefaultLayout(panels: FlexDockPanel[], activePanelId?: string): IJsonModel {
|
||||
const viewportPanels = panels.filter((p) => p.id === 'viewport');
|
||||
const hierarchyPanels = panels.filter((p) => p.id.includes('hierarchy'));
|
||||
const inspectorPanels = panels.filter((p) => p.id.includes('inspector'));
|
||||
const pluginPanels = panels.filter((p) =>
|
||||
!viewportPanels.includes(p) &&
|
||||
!hierarchyPanels.includes(p) &&
|
||||
!inspectorPanels.includes(p)
|
||||
);
|
||||
// 根据布局配置分组面板 | Group panels by layout config
|
||||
const centerPanels = panels.filter((p) => !p.layout?.position || p.layout.position === 'center');
|
||||
const bottomPanels = panels.filter((p) => p.layout?.position === 'bottom');
|
||||
const rightTopPanels = panels.filter((p) => p.layout?.position === 'right-top');
|
||||
const rightBottomPanels = panels.filter((p) => p.layout?.position === 'right-bottom');
|
||||
|
||||
const mainRowChildren = this.buildLayout(
|
||||
viewportPanels,
|
||||
pluginPanels,
|
||||
hierarchyPanels,
|
||||
inspectorPanels,
|
||||
centerPanels,
|
||||
bottomPanels,
|
||||
rightTopPanels,
|
||||
rightBottomPanels,
|
||||
activePanelId
|
||||
);
|
||||
|
||||
@@ -46,30 +46,39 @@ export class LayoutBuilder {
|
||||
}
|
||||
|
||||
private static buildLayout(
|
||||
viewportPanels: FlexDockPanel[],
|
||||
pluginPanels: FlexDockPanel[],
|
||||
hierarchyPanels: FlexDockPanel[],
|
||||
inspectorPanels: FlexDockPanel[],
|
||||
centerPanels: FlexDockPanel[],
|
||||
bottomPanels: FlexDockPanel[],
|
||||
rightTopPanels: FlexDockPanel[],
|
||||
rightBottomPanels: FlexDockPanel[],
|
||||
activePanelId?: string
|
||||
): (IJsonTabSetNode | IJsonRowNode)[] {
|
||||
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
|
||||
const leftPanels = [...viewportPanels, ...pluginPanels];
|
||||
if (leftPanels.length > 0) {
|
||||
// 构建左侧区域(Center + Bottom)| Build left area (Center + Bottom)
|
||||
const leftColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
|
||||
// Center 面板 | Center panels
|
||||
if (centerPanels.length > 0) {
|
||||
let activeTabIndex = 0;
|
||||
if (activePanelId) {
|
||||
const index = leftPanels.findIndex((p) => p.id === activePanelId);
|
||||
const index = centerPanels.findIndex((p) => p.id === activePanelId);
|
||||
if (index !== -1) {
|
||||
activeTabIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
mainRowChildren.push({
|
||||
// 计算底部面板的权重 | Calculate bottom panels weight
|
||||
const bottomWeight = bottomPanels.length > 0
|
||||
? (bottomPanels[0]?.layout?.weight ?? DEFAULT_BOTTOM_PANEL_HEIGHT_RATIO)
|
||||
: 0;
|
||||
const centerWeight = bottomPanels.length > 0 ? (100 - bottomWeight) : 100;
|
||||
|
||||
leftColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 100, // 占据剩余空间
|
||||
weight: centerWeight,
|
||||
selected: activeTabIndex,
|
||||
enableMaximize: true,
|
||||
children: leftPanels.map((p) => ({
|
||||
children: centerPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
@@ -79,14 +88,49 @@ export class LayoutBuilder {
|
||||
});
|
||||
}
|
||||
|
||||
// Bottom 面板(独立 tabset)| Bottom panels (separate tabset)
|
||||
if (bottomPanels.length > 0) {
|
||||
const bottomWeight = bottomPanels[0]?.layout?.weight ?? DEFAULT_BOTTOM_PANEL_HEIGHT_RATIO;
|
||||
leftColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: bottomWeight,
|
||||
enableMaximize: true,
|
||||
children: bottomPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有左侧内容,添加到主行 | If there's left content, add to main row
|
||||
if (leftColumnChildren.length > 0) {
|
||||
if (leftColumnChildren.length === 1) {
|
||||
// 只有一个面板组,直接添加 | Only one panel group, add directly
|
||||
const node = leftColumnChildren[0] as IJsonTabSetNode;
|
||||
node.weight = DEFAULT_LEFT_PANEL_WEIGHT;
|
||||
mainRowChildren.push(node);
|
||||
} else {
|
||||
// 多个面板组,包装成列 | Multiple panel groups, wrap in column
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: DEFAULT_LEFT_PANEL_WEIGHT,
|
||||
children: leftColumnChildren
|
||||
} as IJsonRowNode);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建右侧区域(Right-Top + Right-Bottom)| Build right area
|
||||
const rightColumnChildren: IJsonTabSetNode[] = [];
|
||||
|
||||
if (hierarchyPanels.length > 0) {
|
||||
if (rightTopPanels.length > 0) {
|
||||
rightColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: RIGHT_HIERARCHY_HEIGHT_RATIO,
|
||||
weight: DEFAULT_RIGHT_TOP_HEIGHT_RATIO,
|
||||
enableMaximize: true,
|
||||
children: hierarchyPanels.map((p) => ({
|
||||
children: rightTopPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
@@ -96,12 +140,12 @@ export class LayoutBuilder {
|
||||
});
|
||||
}
|
||||
|
||||
if (inspectorPanels.length > 0) {
|
||||
if (rightBottomPanels.length > 0) {
|
||||
rightColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: RIGHT_INSPECTOR_HEIGHT_RATIO,
|
||||
weight: DEFAULT_RIGHT_BOTTOM_HEIGHT_RATIO,
|
||||
enableMaximize: true,
|
||||
children: inspectorPanels.map((p) => ({
|
||||
children: rightBottomPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
@@ -114,7 +158,7 @@ export class LayoutBuilder {
|
||||
if (rightColumnChildren.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
width: RIGHT_PANEL_WIDTH, // 使用固定宽度而不是权重
|
||||
weight: DEFAULT_RIGHT_PANEL_WEIGHT,
|
||||
children: rightColumnChildren
|
||||
} as IJsonRowNode);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ export class LayoutMerger {
|
||||
|
||||
const newPanelTabs = this.findNewPanels(defaultLayout.layout, newPanelIds);
|
||||
|
||||
if (!this.addNewPanelsToCenter(mergedLayout.layout, newPanelTabs)) {
|
||||
// 构建面板位置映射 | Build panel position map
|
||||
const panelPositionMap = new Map(currentPanels.map((p) => [p.id, p.layout?.position || 'center']));
|
||||
|
||||
if (!this.addNewPanelsToCenter(mergedLayout.layout, newPanelTabs, panelPositionMap)) {
|
||||
return defaultLayout;
|
||||
}
|
||||
|
||||
@@ -102,19 +105,30 @@ export class LayoutMerger {
|
||||
return newPanelTabs;
|
||||
}
|
||||
|
||||
private static addNewPanelsToCenter(node: IJsonLayoutNode, newPanelTabs: IJsonTabNode[]): boolean {
|
||||
/**
|
||||
* 将新面板添加到中心区域
|
||||
* Add new panels to center area
|
||||
*
|
||||
* @param node - 布局节点 | Layout node
|
||||
* @param newPanelTabs - 新面板 tab 数据 | New panel tab data
|
||||
* @param panelPositionMap - 面板位置映射 | Panel position map
|
||||
* @returns 是否成功添加 | Whether successfully added
|
||||
*/
|
||||
private static addNewPanelsToCenter(
|
||||
node: IJsonLayoutNode,
|
||||
newPanelTabs: IJsonTabNode[],
|
||||
panelPositionMap: Map<string, string>
|
||||
): boolean {
|
||||
if (isTabsetNode(node)) {
|
||||
const hasNonSidePanel = node.children?.some((child) => {
|
||||
// 检查是否是中心 tabset(包含 center 位置的面板)
|
||||
// Check if this is center tabset (contains center position panels)
|
||||
const hasCenterPanel = node.children?.some((child) => {
|
||||
const id = child.id || '';
|
||||
return (
|
||||
!id.includes('hierarchy') &&
|
||||
!id.includes('asset') &&
|
||||
!id.includes('inspector') &&
|
||||
!id.includes('console')
|
||||
);
|
||||
const position = panelPositionMap.get(id);
|
||||
return position === 'center' || position === undefined;
|
||||
});
|
||||
|
||||
if (hasNonSidePanel && node.children) {
|
||||
if (hasCenterPanel && node.children) {
|
||||
node.children.push(...newPanelTabs);
|
||||
node.selected = node.children.length - 1;
|
||||
return true;
|
||||
@@ -123,7 +137,7 @@ export class LayoutMerger {
|
||||
|
||||
if (hasChildren(node)) {
|
||||
for (const child of node.children) {
|
||||
if (this.addNewPanelsToCenter(child as IJsonLayoutNode, newPanelTabs)) {
|
||||
if (this.addNewPanelsToCenter(child as IJsonLayoutNode, newPanelTabs, panelPositionMap)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,74 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface FlexDockPanel {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 面板布局位置
|
||||
* Panel layout position
|
||||
*/
|
||||
export type PanelLayoutPosition =
|
||||
| 'center' // 中心区域(Viewport 所在的 tabset)| Center area (Viewport's tabset)
|
||||
| 'bottom' // 中心区域下方独立 tabset | Separate tabset below center
|
||||
| 'right-top' // 右侧上方(Hierarchy)| Right top area
|
||||
| 'right-bottom'; // 右侧下方(Inspector)| Right bottom area
|
||||
|
||||
/**
|
||||
* 面板布局配置
|
||||
* Panel layout configuration
|
||||
*/
|
||||
export interface PanelLayoutConfig {
|
||||
/**
|
||||
* 布局位置
|
||||
* Layout position
|
||||
*
|
||||
* @default 'center'
|
||||
*/
|
||||
position?: PanelLayoutPosition;
|
||||
|
||||
/**
|
||||
* 布局权重(百分比)
|
||||
* Layout weight (percentage)
|
||||
*
|
||||
* 仅对 'bottom' 位置有效,表示占父容器的高度比例
|
||||
* Only effective for 'bottom' position, represents height ratio of parent container
|
||||
*
|
||||
* @default 20
|
||||
*/
|
||||
weight?: number;
|
||||
|
||||
/**
|
||||
* 是否需要独立的 tabset(不与其他面板共享)
|
||||
* Whether needs a separate tabset (not shared with other panels)
|
||||
*
|
||||
* 当为 true 时,重新添加面板会使用默认布局而不是合并
|
||||
* When true, re-adding panel will use default layout instead of merging
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
requiresSeparateTabset?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可停靠面板描述符
|
||||
* Dockable panel descriptor
|
||||
*/
|
||||
export interface FlexDockPanel {
|
||||
/** 面板唯一标识 | Panel unique ID */
|
||||
id: string;
|
||||
|
||||
/** 面板标题 | Panel title */
|
||||
title: string;
|
||||
|
||||
/** 面板内容 | Panel content */
|
||||
content: ReactNode;
|
||||
|
||||
/** 是否可关闭 | Whether closable */
|
||||
closable?: boolean;
|
||||
|
||||
/**
|
||||
* 布局配置
|
||||
* Layout configuration
|
||||
*
|
||||
* 定义面板在布局中的位置和行为
|
||||
* Defines panel's position and behavior in layout
|
||||
*/
|
||||
layout?: PanelLayoutConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Build Settings Store - 构建设置状态管理
|
||||
* Build Settings State Management
|
||||
*
|
||||
* 使用 Zustand 替代 BuildSettingsPanel 中的大量 useEffect 和 useState
|
||||
* Using Zustand to replace numerous useEffect and useState in BuildSettingsPanel
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type {
|
||||
BuildService,
|
||||
BuildProgress,
|
||||
BuildConfig,
|
||||
WebBuildConfig,
|
||||
WeChatBuildConfig,
|
||||
ProjectService,
|
||||
BuildSettingsConfig
|
||||
} from '@esengine/editor-core';
|
||||
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
export type PlatformType =
|
||||
| 'windows'
|
||||
| 'macos'
|
||||
| 'linux'
|
||||
| 'android'
|
||||
| 'ios'
|
||||
| 'web'
|
||||
| 'wechat-minigame';
|
||||
|
||||
export interface BuildProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: PlatformType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface SceneEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BuildSettings {
|
||||
scenes: SceneEntry[];
|
||||
scriptingDefines: string[];
|
||||
companyName: string;
|
||||
productName: string;
|
||||
version: string;
|
||||
developmentBuild: boolean;
|
||||
sourceMap: boolean;
|
||||
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
|
||||
buildMode: 'split-bundles' | 'single-bundle' | 'single-file';
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
duration: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BuildSettingsState {
|
||||
// 配置状态 | Profile state
|
||||
profiles: BuildProfile[];
|
||||
selectedPlatform: PlatformType;
|
||||
selectedProfile: BuildProfile | null;
|
||||
settings: BuildSettings;
|
||||
|
||||
// UI 状态 | UI state
|
||||
expandedSections: Record<string, boolean>;
|
||||
|
||||
// 构建状态 | Build state
|
||||
isBuilding: boolean;
|
||||
buildProgress: BuildProgress | null;
|
||||
buildResult: BuildResult | null;
|
||||
showBuildProgress: boolean;
|
||||
|
||||
// 服务引用 | Service references
|
||||
_buildService: BuildService | null;
|
||||
_projectService: ProjectService | null;
|
||||
_projectPath: string | null;
|
||||
|
||||
// 内部状态 | Internal state
|
||||
_initialized: boolean;
|
||||
_saveTimeout: NodeJS.Timeout | null;
|
||||
_progressInterval: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
interface BuildSettingsActions {
|
||||
// 初始化 | Initialization
|
||||
initialize: (params: {
|
||||
projectPath: string;
|
||||
buildService?: BuildService;
|
||||
projectService?: ProjectService;
|
||||
availableScenes?: string[];
|
||||
}) => void;
|
||||
cleanup: () => void;
|
||||
|
||||
// 配置操作 | Profile actions
|
||||
setSelectedPlatform: (platform: PlatformType) => void;
|
||||
setSelectedProfile: (profile: BuildProfile | null) => void;
|
||||
addProfile: () => void;
|
||||
|
||||
// 设置操作 | Settings actions
|
||||
updateSettings: (partial: Partial<BuildSettings>) => void;
|
||||
setSceneEnabled: (index: number, enabled: boolean) => void;
|
||||
addScene: (path: string) => void;
|
||||
addDefine: (define: string) => void;
|
||||
removeDefine: (index: number) => void;
|
||||
|
||||
// UI 操作 | UI actions
|
||||
toggleSection: (section: string) => void;
|
||||
|
||||
// 构建操作 | Build actions
|
||||
startBuild: () => Promise<void>;
|
||||
cancelBuild: () => void;
|
||||
closeBuildProgress: () => void;
|
||||
}
|
||||
|
||||
export type BuildSettingsStore = BuildSettingsState & BuildSettingsActions;
|
||||
|
||||
// ============= Constants =============
|
||||
|
||||
const DEFAULT_SETTINGS: BuildSettings = {
|
||||
scenes: [],
|
||||
scriptingDefines: [],
|
||||
companyName: 'DefaultCompany',
|
||||
productName: 'MyGame',
|
||||
version: '0.1.0',
|
||||
developmentBuild: false,
|
||||
sourceMap: false,
|
||||
compressionMethod: 'Default',
|
||||
buildMode: 'split-bundles',
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILES: BuildProfile[] = [
|
||||
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
|
||||
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
|
||||
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
|
||||
];
|
||||
|
||||
// ============= Helper Functions =============
|
||||
|
||||
const getPlatformEnum = (platformType: PlatformType): BuildPlatform => {
|
||||
const platformMap: Record<PlatformType, BuildPlatform> = {
|
||||
'web': BuildPlatform.Web,
|
||||
'wechat-minigame': BuildPlatform.WeChatMiniGame,
|
||||
'windows': BuildPlatform.Desktop,
|
||||
'macos': BuildPlatform.Desktop,
|
||||
'linux': BuildPlatform.Desktop,
|
||||
'android': BuildPlatform.Android,
|
||||
'ios': BuildPlatform.iOS
|
||||
};
|
||||
return platformMap[platformType];
|
||||
};
|
||||
|
||||
// ============= Store =============
|
||||
|
||||
export const useBuildSettingsStore = create<BuildSettingsStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// 初始状态 | Initial state
|
||||
profiles: DEFAULT_PROFILES,
|
||||
selectedPlatform: 'web',
|
||||
selectedProfile: DEFAULT_PROFILES[0] ?? null,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
expandedSections: {
|
||||
sceneList: true,
|
||||
scriptingDefines: true,
|
||||
platformSettings: true,
|
||||
playerSettings: true,
|
||||
},
|
||||
isBuilding: false,
|
||||
buildProgress: null,
|
||||
buildResult: null,
|
||||
showBuildProgress: false,
|
||||
_buildService: null,
|
||||
_projectService: null,
|
||||
_projectPath: null,
|
||||
_initialized: false,
|
||||
_saveTimeout: null,
|
||||
_progressInterval: null,
|
||||
|
||||
// ===== 初始化 | Initialization =====
|
||||
initialize: ({ projectPath, buildService, projectService, availableScenes }) => {
|
||||
const state = get();
|
||||
if (state._initialized) return;
|
||||
|
||||
set({
|
||||
_buildService: buildService || null,
|
||||
_projectService: projectService || null,
|
||||
_projectPath: projectPath,
|
||||
_initialized: true,
|
||||
});
|
||||
|
||||
// 从 projectService 加载已保存的设置
|
||||
// Load saved settings from projectService
|
||||
if (projectService) {
|
||||
const savedSettings = projectService.getBuildSettings();
|
||||
if (savedSettings) {
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scriptingDefines: savedSettings.scriptingDefines || [],
|
||||
companyName: savedSettings.companyName || prev.settings.companyName,
|
||||
productName: savedSettings.productName || prev.settings.productName,
|
||||
version: savedSettings.version || prev.settings.version,
|
||||
developmentBuild: savedSettings.developmentBuild ?? prev.settings.developmentBuild,
|
||||
sourceMap: savedSettings.sourceMap ?? prev.settings.sourceMap,
|
||||
compressionMethod: savedSettings.compressionMethod || prev.settings.compressionMethod,
|
||||
buildMode: savedSettings.buildMode || prev.settings.buildMode
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// 初始化场景列表
|
||||
// Initialize scene list
|
||||
if (availableScenes && availableScenes.length > 0) {
|
||||
const savedScenes = savedSettings?.scenes || [];
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scenes: availableScenes.map(path => ({
|
||||
path,
|
||||
enabled: savedScenes.length > 0 ? savedScenes.includes(path) : true
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cleanup: () => {
|
||||
const state = get();
|
||||
|
||||
// 清理定时器 | Clear timers
|
||||
if (state._saveTimeout) {
|
||||
clearTimeout(state._saveTimeout);
|
||||
}
|
||||
if (state._progressInterval) {
|
||||
clearInterval(state._progressInterval);
|
||||
}
|
||||
|
||||
set({
|
||||
_buildService: null,
|
||||
_projectService: null,
|
||||
_projectPath: null,
|
||||
_initialized: false,
|
||||
_saveTimeout: null,
|
||||
_progressInterval: null,
|
||||
isBuilding: false,
|
||||
buildProgress: null,
|
||||
buildResult: null,
|
||||
showBuildProgress: false,
|
||||
});
|
||||
},
|
||||
|
||||
// ===== 配置操作 | Profile actions =====
|
||||
setSelectedPlatform: (platform) => {
|
||||
const { profiles } = get();
|
||||
const profile = profiles.find(p => p.platform === platform);
|
||||
set({
|
||||
selectedPlatform: platform,
|
||||
selectedProfile: profile || null
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedProfile: (profile) => {
|
||||
set({
|
||||
selectedProfile: profile,
|
||||
selectedPlatform: profile?.platform || get().selectedPlatform
|
||||
});
|
||||
},
|
||||
|
||||
addProfile: () => {
|
||||
const { selectedPlatform, profiles } = get();
|
||||
const newProfile: BuildProfile = {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: `${selectedPlatform} - New Profile`,
|
||||
platform: selectedPlatform,
|
||||
};
|
||||
set({
|
||||
profiles: [...profiles, newProfile],
|
||||
selectedProfile: newProfile
|
||||
});
|
||||
},
|
||||
|
||||
// ===== 设置操作 | Settings actions =====
|
||||
updateSettings: (partial) => {
|
||||
set(prev => ({
|
||||
settings: { ...prev.settings, ...partial }
|
||||
}));
|
||||
|
||||
// 防抖保存 | Debounced save
|
||||
const state = get();
|
||||
if (state._saveTimeout) {
|
||||
clearTimeout(state._saveTimeout);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const { _projectService, settings } = get();
|
||||
if (_projectService) {
|
||||
const configToSave: BuildSettingsConfig = {
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path),
|
||||
scriptingDefines: settings.scriptingDefines,
|
||||
companyName: settings.companyName,
|
||||
productName: settings.productName,
|
||||
version: settings.version,
|
||||
developmentBuild: settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
compressionMethod: settings.compressionMethod,
|
||||
buildMode: settings.buildMode
|
||||
};
|
||||
_projectService.updateBuildSettings(configToSave);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
set({ _saveTimeout: timeout });
|
||||
},
|
||||
|
||||
setSceneEnabled: (index, enabled) => {
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scenes: prev.settings.scenes.map((s, i) =>
|
||||
i === index ? { ...s, enabled } : s
|
||||
)
|
||||
}
|
||||
}));
|
||||
// 触发保存 | Trigger save
|
||||
get().updateSettings({});
|
||||
},
|
||||
|
||||
addScene: (path) => {
|
||||
const { settings } = get();
|
||||
if (settings.scenes.some(s => s.path === path)) return;
|
||||
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scenes: [...prev.settings.scenes, { path, enabled: true }]
|
||||
}
|
||||
}));
|
||||
get().updateSettings({});
|
||||
},
|
||||
|
||||
addDefine: (define) => {
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scriptingDefines: [...prev.settings.scriptingDefines, define]
|
||||
}
|
||||
}));
|
||||
get().updateSettings({});
|
||||
},
|
||||
|
||||
removeDefine: (index) => {
|
||||
set(prev => ({
|
||||
settings: {
|
||||
...prev.settings,
|
||||
scriptingDefines: prev.settings.scriptingDefines.filter((_, i) => i !== index)
|
||||
}
|
||||
}));
|
||||
get().updateSettings({});
|
||||
},
|
||||
|
||||
// ===== UI 操作 | UI actions =====
|
||||
toggleSection: (section) => {
|
||||
set(prev => ({
|
||||
expandedSections: {
|
||||
...prev.expandedSections,
|
||||
[section]: !prev.expandedSections[section]
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
// ===== 构建操作 | Build actions =====
|
||||
startBuild: async () => {
|
||||
const { selectedProfile, settings, _buildService, _projectPath } = get();
|
||||
|
||||
if (!selectedProfile || !_projectPath || !_buildService) {
|
||||
console.warn('Cannot start build: missing profile, path, or service');
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
isBuilding: true,
|
||||
buildProgress: null,
|
||||
buildResult: null,
|
||||
showBuildProgress: true,
|
||||
});
|
||||
|
||||
// 启动进度轮询(但不用 setInterval,用 buildService 的回调)
|
||||
// Start progress polling (but use buildService callback, not setInterval)
|
||||
|
||||
try {
|
||||
const platform = getPlatformEnum(selectedProfile.platform);
|
||||
const baseConfig = {
|
||||
platform,
|
||||
outputPath: `${_projectPath}/build/${selectedProfile.platform}`,
|
||||
isRelease: !settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
|
||||
};
|
||||
|
||||
let buildConfig: BuildConfig;
|
||||
if (platform === BuildPlatform.Web) {
|
||||
// 从 AssetLoaderFactory 获取插件注册的扩展名
|
||||
// Get plugin-registered extensions from AssetLoaderFactory
|
||||
let assetExtensions: string[] | undefined;
|
||||
let assetTypeMap: Record<string, string> | undefined;
|
||||
|
||||
try {
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
if (assetManager) {
|
||||
const loaderFactory = assetManager.getLoaderFactory();
|
||||
assetExtensions = loaderFactory.getAllSupportedExtensions();
|
||||
assetTypeMap = loaderFactory.getExtensionTypeMap();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to get asset extensions from loader factory:', e);
|
||||
}
|
||||
|
||||
const webConfig: WebBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.Web,
|
||||
buildMode: settings.buildMode,
|
||||
generateHtml: true,
|
||||
minify: !settings.developmentBuild,
|
||||
generateAssetCatalog: true,
|
||||
assetLoadingStrategy: 'on-demand',
|
||||
assetExtensions,
|
||||
assetTypeMap
|
||||
};
|
||||
buildConfig = webConfig;
|
||||
} else if (platform === BuildPlatform.WeChatMiniGame) {
|
||||
const wechatConfig: WeChatBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.WeChatMiniGame,
|
||||
appId: '',
|
||||
useSubpackages: false,
|
||||
mainPackageLimit: 4096,
|
||||
usePlugins: false
|
||||
};
|
||||
buildConfig = wechatConfig;
|
||||
} else {
|
||||
buildConfig = baseConfig;
|
||||
}
|
||||
|
||||
// 使用回调更新进度,而不是轮询
|
||||
// Use callback to update progress, not polling
|
||||
const result = await _buildService.build(buildConfig, (progress) => {
|
||||
set({ buildProgress: progress });
|
||||
});
|
||||
|
||||
set({
|
||||
buildResult: {
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
duration: result.duration,
|
||||
warnings: result.warnings,
|
||||
error: result.error
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
set({
|
||||
buildResult: {
|
||||
success: false,
|
||||
outputPath: '',
|
||||
duration: 0,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
set({ isBuilding: false });
|
||||
}
|
||||
},
|
||||
|
||||
cancelBuild: () => {
|
||||
const { _buildService } = get();
|
||||
if (_buildService) {
|
||||
_buildService.cancelBuild();
|
||||
}
|
||||
},
|
||||
|
||||
closeBuildProgress: () => {
|
||||
const { isBuilding } = get();
|
||||
if (!isBuilding) {
|
||||
set({
|
||||
showBuildProgress: false,
|
||||
buildProgress: null,
|
||||
buildResult: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
// ============= Selectors =============
|
||||
|
||||
/** 获取当前平台的配置列表 | Get profiles for current platform */
|
||||
export const selectProfilesForPlatform = (state: BuildSettingsStore): BuildProfile[] => {
|
||||
return state.profiles.filter(p => p.platform === state.selectedPlatform);
|
||||
};
|
||||
|
||||
/** 获取启用的场景列表 | Get enabled scenes */
|
||||
export const selectEnabledScenes = (state: BuildSettingsStore): string[] => {
|
||||
return state.settings.scenes.filter(s => s.enabled).map(s => s.path);
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* ContentBrowser Store - 内容浏览器状态管理
|
||||
* Content Browser State Management
|
||||
*
|
||||
* 使用 Zustand 替代 ContentBrowser 中的大量 useEffect 和 useState
|
||||
* Using Zustand to replace numerous useEffect and useState in ContentBrowser
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
export interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
export interface FolderNode {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
interface ContentBrowserState {
|
||||
// 浏览状态 | Browsing state
|
||||
currentPath: string | null;
|
||||
assets: AssetItem[];
|
||||
loading: boolean;
|
||||
folderTree: FolderNode | null;
|
||||
expandedFolders: Set<string>;
|
||||
|
||||
// 选择状态 | Selection state
|
||||
selectedPaths: Set<string>;
|
||||
lastSelectedPath: string | null;
|
||||
|
||||
// 视图状态 | View state
|
||||
viewMode: 'grid' | 'list';
|
||||
sortBy: 'name' | 'type' | 'size' | 'modified';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
searchQuery: string;
|
||||
|
||||
// 刷新版本号(用于强制刷新)| Refresh version (for forcing refresh)
|
||||
refreshVersion: number;
|
||||
}
|
||||
|
||||
interface ContentBrowserActions {
|
||||
// 初始化 | Initialization
|
||||
initialize: (projectPath: string) => Promise<void>;
|
||||
cleanup: () => void;
|
||||
|
||||
// 导航操作 | Navigation actions
|
||||
setCurrentPath: (path: string) => void;
|
||||
navigateToFolder: (path: string) => Promise<void>;
|
||||
|
||||
// 资产操作 | Asset actions
|
||||
loadAssets: (path: string) => Promise<void>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
buildFolderTree: (rootPath: string) => Promise<void>;
|
||||
|
||||
// 文件夹展开 | Folder expansion
|
||||
toggleFolderExpand: (path: string) => void;
|
||||
setExpandedFolders: (folders: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
|
||||
// 选择操作 | Selection actions
|
||||
setSelectedPaths: (paths: Set<string>) => void;
|
||||
selectPath: (path: string, multiSelect?: boolean, rangeSelect?: boolean) => void;
|
||||
clearSelection: () => void;
|
||||
setLastSelectedPath: (path: string | null) => void;
|
||||
|
||||
// 视图操作 | View actions
|
||||
setViewMode: (mode: 'grid' | 'list') => void;
|
||||
setSortBy: (by: 'name' | 'type' | 'size' | 'modified') => void;
|
||||
toggleSortOrder: () => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
|
||||
// 强制刷新 | Force refresh
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export type ContentBrowserStore = ContentBrowserState & ContentBrowserActions;
|
||||
|
||||
// ============= Initial State =============
|
||||
|
||||
const initialState: ContentBrowserState = {
|
||||
currentPath: null,
|
||||
assets: [],
|
||||
loading: false,
|
||||
folderTree: null,
|
||||
expandedFolders: new Set(),
|
||||
selectedPaths: new Set(),
|
||||
lastSelectedPath: null,
|
||||
viewMode: 'grid',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
searchQuery: '',
|
||||
refreshVersion: 0,
|
||||
};
|
||||
|
||||
// ============= Store =============
|
||||
|
||||
/** 消息订阅取消函数 | Message subscription unsubscribe functions */
|
||||
let _unsubscribeAssetChanged: (() => void) | null = null;
|
||||
let _unsubscribeAssetRefresh: (() => void) | null = null;
|
||||
let _currentProjectPath: string | null = null;
|
||||
|
||||
/**
|
||||
* ContentBrowser Store
|
||||
* 内容浏览器全局状态
|
||||
*/
|
||||
export const useContentBrowserStore = create<ContentBrowserStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ===== 初始化 | Initialization =====
|
||||
initialize: async (projectPath: string) => {
|
||||
_currentProjectPath = projectPath;
|
||||
|
||||
// 设置初始路径和展开状态
|
||||
// Set initial path and expanded state
|
||||
set({
|
||||
currentPath: projectPath,
|
||||
expandedFolders: new Set([projectPath]),
|
||||
});
|
||||
|
||||
// 加载资产和文件夹树
|
||||
// Load assets and folder tree
|
||||
await get().loadAssets(projectPath);
|
||||
await get().buildFolderTree(projectPath);
|
||||
|
||||
// 订阅消息事件(替代 useEffect)
|
||||
// Subscribe to message events (replacing useEffect)
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 资产变化事件
|
||||
// Asset changed event
|
||||
_unsubscribeAssetChanged = messageHub.subscribe(
|
||||
'assets:changed',
|
||||
(data: { type: string; path: string; relativePath: string; guid: string }) => {
|
||||
const { currentPath } = get();
|
||||
if (!currentPath || !data.path) return;
|
||||
|
||||
const normalizedPath = data.path.replace(/\\/g, '/');
|
||||
const normalizedCurrentPath = currentPath.replace(/\\/g, '/');
|
||||
const parentDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
|
||||
|
||||
if (parentDir === normalizedCurrentPath) {
|
||||
// 刷新当前目录 | Refresh current directory
|
||||
get().loadAssets(currentPath);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 通用刷新事件
|
||||
// Generic refresh event
|
||||
_unsubscribeAssetRefresh = messageHub.subscribe(
|
||||
'assets:refresh',
|
||||
() => {
|
||||
const { currentPath } = get();
|
||||
if (currentPath) {
|
||||
get().loadAssets(currentPath);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
cleanup: () => {
|
||||
// 清理订阅 | Cleanup subscriptions
|
||||
if (_unsubscribeAssetChanged) {
|
||||
_unsubscribeAssetChanged();
|
||||
_unsubscribeAssetChanged = null;
|
||||
}
|
||||
if (_unsubscribeAssetRefresh) {
|
||||
_unsubscribeAssetRefresh();
|
||||
_unsubscribeAssetRefresh = null;
|
||||
}
|
||||
_currentProjectPath = null;
|
||||
|
||||
// 重置状态 | Reset state
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// ===== 导航操作 | Navigation actions =====
|
||||
setCurrentPath: (path: string) => {
|
||||
set({ currentPath: path });
|
||||
},
|
||||
|
||||
navigateToFolder: async (path: string) => {
|
||||
set({ currentPath: path });
|
||||
await get().loadAssets(path);
|
||||
},
|
||||
|
||||
// ===== 资产操作 | Asset actions =====
|
||||
loadAssets: async (path: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension: entry.is_dir ? undefined : entry.name.split('.').pop(),
|
||||
size: entry.size,
|
||||
modified: entry.modified
|
||||
}));
|
||||
|
||||
// 排序:文件夹优先,然后按名称
|
||||
// Sort: folders first, then by name
|
||||
const sorted = assetItems.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
|
||||
set({ assets: sorted, loading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
set({ assets: [], loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshCurrentFolder: async () => {
|
||||
const { currentPath } = get();
|
||||
if (currentPath) {
|
||||
await get().loadAssets(currentPath);
|
||||
}
|
||||
},
|
||||
|
||||
buildFolderTree: async (rootPath: string) => {
|
||||
const { expandedFolders } = get();
|
||||
|
||||
const buildNode = async (path: string, name: string): Promise<FolderNode> => {
|
||||
const node: FolderNode = {
|
||||
name,
|
||||
path,
|
||||
children: [],
|
||||
isExpanded: expandedFolders.has(path)
|
||||
};
|
||||
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const folders = entries
|
||||
.filter((e: DirectoryEntry) => e.is_dir && !e.name.startsWith('.'))
|
||||
.sort((a: DirectoryEntry, b: DirectoryEntry) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const folder of folders) {
|
||||
if (expandedFolders.has(path)) {
|
||||
node.children.push(await buildNode(folder.path, folder.name));
|
||||
} else {
|
||||
node.children.push({
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
children: [],
|
||||
isExpanded: false
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build folder tree:', error);
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const tree = await buildNode(rootPath, 'All');
|
||||
set({ folderTree: tree });
|
||||
},
|
||||
|
||||
// ===== 文件夹展开 | Folder expansion =====
|
||||
toggleFolderExpand: (path: string) => {
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedFolders);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return { expandedFolders: next };
|
||||
});
|
||||
|
||||
// 重建文件夹树
|
||||
// Rebuild folder tree
|
||||
if (_currentProjectPath) {
|
||||
get().buildFolderTree(_currentProjectPath);
|
||||
}
|
||||
},
|
||||
|
||||
setExpandedFolders: (foldersOrUpdater) => {
|
||||
set((state) => ({
|
||||
expandedFolders: typeof foldersOrUpdater === 'function'
|
||||
? foldersOrUpdater(state.expandedFolders)
|
||||
: foldersOrUpdater
|
||||
}));
|
||||
},
|
||||
|
||||
// ===== 选择操作 | Selection actions =====
|
||||
setSelectedPaths: (paths: Set<string>) => {
|
||||
set({ selectedPaths: paths });
|
||||
},
|
||||
|
||||
selectPath: (path: string, multiSelect = false, rangeSelect = false) => {
|
||||
const { selectedPaths, lastSelectedPath, assets } = get();
|
||||
|
||||
if (rangeSelect && lastSelectedPath) {
|
||||
// 范围选择 | Range selection
|
||||
const startIndex = assets.findIndex(a => a.path === lastSelectedPath);
|
||||
const endIndex = assets.findIndex(a => a.path === path);
|
||||
if (startIndex >= 0 && endIndex >= 0) {
|
||||
const [from, to] = [Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)];
|
||||
const rangePaths = new Set(assets.slice(from, to + 1).map(a => a.path));
|
||||
set({
|
||||
selectedPaths: rangePaths,
|
||||
lastSelectedPath: path
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (multiSelect) {
|
||||
// 多选切换 | Multi-select toggle
|
||||
const next = new Set(selectedPaths);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
set({
|
||||
selectedPaths: next,
|
||||
lastSelectedPath: path
|
||||
});
|
||||
} else {
|
||||
// 单选 | Single select
|
||||
set({
|
||||
selectedPaths: new Set([path]),
|
||||
lastSelectedPath: path
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({
|
||||
selectedPaths: new Set(),
|
||||
lastSelectedPath: null
|
||||
});
|
||||
},
|
||||
|
||||
setLastSelectedPath: (path: string | null) => {
|
||||
set({ lastSelectedPath: path });
|
||||
},
|
||||
|
||||
// ===== 视图操作 | View actions =====
|
||||
setViewMode: (mode: 'grid' | 'list') => {
|
||||
set({ viewMode: mode });
|
||||
},
|
||||
|
||||
setSortBy: (by: 'name' | 'type' | 'size' | 'modified') => {
|
||||
set({ sortBy: by });
|
||||
},
|
||||
|
||||
toggleSortOrder: () => {
|
||||
set((state) => ({
|
||||
sortOrder: state.sortOrder === 'asc' ? 'desc' : 'asc'
|
||||
}));
|
||||
},
|
||||
|
||||
setSearchQuery: (query: string) => {
|
||||
set({ searchQuery: query });
|
||||
},
|
||||
|
||||
// ===== 强制刷新 | Force refresh =====
|
||||
triggerRefresh: () => {
|
||||
set((state) => ({ refreshVersion: state.refreshVersion + 1 }));
|
||||
get().refreshCurrentFolder();
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
// ============= Selectors =============
|
||||
|
||||
/** 获取当前过滤后的资产列表 | Get current filtered assets */
|
||||
export const selectFilteredAssets = (state: ContentBrowserStore): AssetItem[] => {
|
||||
const { assets, searchQuery } = state;
|
||||
if (!searchQuery.trim()) return assets;
|
||||
return assets.filter(a =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
/** 获取选中的资产 | Get selected assets */
|
||||
export const selectSelectedAssets = (state: ContentBrowserStore): AssetItem[] => {
|
||||
const { assets, selectedPaths } = state;
|
||||
return assets.filter(a => selectedPaths.has(a.path));
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 编辑器全局状态管理
|
||||
* Editor global state management
|
||||
*
|
||||
* 使用 Zustand 替代 App.tsx 中的大量 useState,解决:
|
||||
* Using Zustand to replace numerous useState in App.tsx, solving:
|
||||
* 1. 状态变化导致全局重渲染 | State changes causing global re-renders
|
||||
* 2. useEffect 依赖过多导致面板频繁重建 | Too many useEffect deps causing panel rebuilds
|
||||
* 3. 服务状态与 UI 状态混杂 | Service state mixed with UI state
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { FlexDockPanel } from '../components/FlexLayoutDockContainer';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
/**
|
||||
* 项目状态
|
||||
* Project state
|
||||
*/
|
||||
export interface ProjectState {
|
||||
/** 是否已加载项目 | Is project loaded */
|
||||
projectLoaded: boolean;
|
||||
/** 当前项目路径 | Current project path */
|
||||
currentProjectPath: string | null;
|
||||
/** 可用场景列表 | Available scenes list */
|
||||
availableScenes: string[];
|
||||
/** 是否正在加载 | Is loading */
|
||||
isLoading: boolean;
|
||||
/** 加载消息 | Loading message */
|
||||
loadingMessage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板状态
|
||||
* Panel state
|
||||
*/
|
||||
export interface PanelState {
|
||||
/** 面板列表 | Panel list */
|
||||
panels: FlexDockPanel[];
|
||||
/** 活动的动态面板 ID 列表 | Active dynamic panel IDs */
|
||||
activeDynamicPanels: string[];
|
||||
/** 动态面板标题映射 | Dynamic panel title map */
|
||||
dynamicPanelTitles: Map<string, string>;
|
||||
/** 当前活动面板 ID | Current active panel ID */
|
||||
activePanelId: string | undefined;
|
||||
/** 插件更新触发器 | Plugin update trigger */
|
||||
pluginUpdateTrigger: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 状态
|
||||
* UI state
|
||||
*/
|
||||
export interface UIState {
|
||||
/** 是否连接到远程设备 | Is connected to remote device */
|
||||
isRemoteConnected: boolean;
|
||||
/** ContentBrowser 是否停靠 | Is ContentBrowser docked */
|
||||
isContentBrowserDocked: boolean;
|
||||
/** 编辑器是否全屏 | Is editor fullscreen */
|
||||
isEditorFullscreen: boolean;
|
||||
/** 状态栏文本 | Status bar text */
|
||||
status: string;
|
||||
/** 是否显示项目向导 | Show project wizard */
|
||||
showProjectWizard: boolean;
|
||||
/** 设置窗口初始分类 | Settings window initial category */
|
||||
settingsInitialCategory: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译器对话框状态
|
||||
* Compiler dialog state
|
||||
*/
|
||||
export interface CompilerDialogState {
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
currentFileName?: string;
|
||||
}
|
||||
|
||||
// ============= Actions =============
|
||||
|
||||
export interface EditorActions {
|
||||
// 项目操作 | Project actions
|
||||
setProjectLoaded: (loaded: boolean) => void;
|
||||
setCurrentProjectPath: (path: string | null) => void;
|
||||
setAvailableScenes: (scenes: string[]) => void;
|
||||
setIsLoading: (loading: boolean, message?: string) => void;
|
||||
resetProject: () => void;
|
||||
|
||||
// 面板操作 | Panel actions
|
||||
setPanels: (panels: FlexDockPanel[] | ((prev: FlexDockPanel[]) => FlexDockPanel[])) => void;
|
||||
addDynamicPanel: (panelId: string, title?: string) => void;
|
||||
removeDynamicPanel: (panelId: string) => void;
|
||||
clearDynamicPanels: () => void;
|
||||
setDynamicPanelTitle: (panelId: string, title: string) => void;
|
||||
setActivePanelId: (id: string | undefined) => void;
|
||||
triggerPluginUpdate: () => void;
|
||||
|
||||
// UI 操作 | UI actions
|
||||
setIsRemoteConnected: (connected: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setIsContentBrowserDocked: (docked: boolean) => void;
|
||||
setIsEditorFullscreen: (fullscreen: boolean) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setShowProjectWizard: (show: boolean) => void;
|
||||
setSettingsInitialCategory: (category: string | undefined) => void;
|
||||
|
||||
// 编译器对话框 | Compiler dialog
|
||||
openCompilerDialog: (compilerId: string, currentFileName?: string) => void;
|
||||
closeCompilerDialog: () => void;
|
||||
}
|
||||
|
||||
// ============= Store Type =============
|
||||
|
||||
export type EditorState = ProjectState & PanelState & UIState & {
|
||||
compilerDialog: CompilerDialogState;
|
||||
} & EditorActions;
|
||||
|
||||
// ============= Initial State =============
|
||||
|
||||
const initialProjectState: ProjectState = {
|
||||
projectLoaded: false,
|
||||
currentProjectPath: null,
|
||||
availableScenes: [],
|
||||
isLoading: false,
|
||||
loadingMessage: '',
|
||||
};
|
||||
|
||||
const initialPanelState: PanelState = {
|
||||
panels: [],
|
||||
activeDynamicPanels: [],
|
||||
dynamicPanelTitles: new Map(),
|
||||
activePanelId: undefined,
|
||||
pluginUpdateTrigger: 0,
|
||||
};
|
||||
|
||||
const initialUIState: UIState = {
|
||||
isRemoteConnected: false,
|
||||
isContentBrowserDocked: false,
|
||||
isEditorFullscreen: false,
|
||||
status: '',
|
||||
showProjectWizard: false,
|
||||
settingsInitialCategory: undefined,
|
||||
};
|
||||
|
||||
const initialCompilerDialog: CompilerDialogState = {
|
||||
isOpen: false,
|
||||
compilerId: '',
|
||||
currentFileName: undefined,
|
||||
};
|
||||
|
||||
// ============= Store =============
|
||||
|
||||
/**
|
||||
* 编辑器全局 Store
|
||||
* Editor global store
|
||||
*/
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// 初始状态 | Initial state
|
||||
...initialProjectState,
|
||||
...initialPanelState,
|
||||
...initialUIState,
|
||||
compilerDialog: initialCompilerDialog,
|
||||
|
||||
// ===== 项目操作 | Project actions =====
|
||||
setProjectLoaded: (loaded) => set({ projectLoaded: loaded }),
|
||||
|
||||
setCurrentProjectPath: (path) => set({ currentProjectPath: path }),
|
||||
|
||||
setAvailableScenes: (scenes) => set({ availableScenes: scenes }),
|
||||
|
||||
setIsLoading: (loading, message) => set({
|
||||
isLoading: loading,
|
||||
loadingMessage: message ?? (loading ? 'Loading...' : ''),
|
||||
}),
|
||||
|
||||
resetProject: () => set({
|
||||
...initialProjectState,
|
||||
...initialPanelState,
|
||||
}),
|
||||
|
||||
// ===== 面板操作 | Panel actions =====
|
||||
setPanels: (panelsOrUpdater) => set((state) => ({
|
||||
panels: typeof panelsOrUpdater === 'function'
|
||||
? panelsOrUpdater(state.panels)
|
||||
: panelsOrUpdater
|
||||
})),
|
||||
|
||||
addDynamicPanel: (panelId, title) => set((state) => {
|
||||
if (state.activeDynamicPanels.includes(panelId)) {
|
||||
return state;
|
||||
}
|
||||
const newTitles = new Map(state.dynamicPanelTitles);
|
||||
if (title) {
|
||||
newTitles.set(panelId, title);
|
||||
}
|
||||
return {
|
||||
activeDynamicPanels: [...state.activeDynamicPanels, panelId],
|
||||
dynamicPanelTitles: newTitles,
|
||||
};
|
||||
}),
|
||||
|
||||
removeDynamicPanel: (panelId) => set((state) => {
|
||||
const newTitles = new Map(state.dynamicPanelTitles);
|
||||
newTitles.delete(panelId);
|
||||
return {
|
||||
activeDynamicPanels: state.activeDynamicPanels.filter(id => id !== panelId),
|
||||
dynamicPanelTitles: newTitles,
|
||||
};
|
||||
}),
|
||||
|
||||
clearDynamicPanels: () => set({
|
||||
activeDynamicPanels: [],
|
||||
dynamicPanelTitles: new Map(),
|
||||
}),
|
||||
|
||||
setDynamicPanelTitle: (panelId, title) => set((state) => {
|
||||
const newTitles = new Map(state.dynamicPanelTitles);
|
||||
newTitles.set(panelId, title);
|
||||
return { dynamicPanelTitles: newTitles };
|
||||
}),
|
||||
|
||||
setActivePanelId: (id) => set({ activePanelId: id }),
|
||||
|
||||
triggerPluginUpdate: () => set((state) => ({
|
||||
pluginUpdateTrigger: state.pluginUpdateTrigger + 1,
|
||||
})),
|
||||
|
||||
// ===== UI 操作 | UI actions =====
|
||||
setIsRemoteConnected: (connectedOrUpdater) => set((state) => ({
|
||||
isRemoteConnected: typeof connectedOrUpdater === 'function'
|
||||
? connectedOrUpdater(state.isRemoteConnected)
|
||||
: connectedOrUpdater
|
||||
})),
|
||||
|
||||
setIsContentBrowserDocked: (docked) => set({ isContentBrowserDocked: docked }),
|
||||
|
||||
setIsEditorFullscreen: (fullscreen) => set({ isEditorFullscreen: fullscreen }),
|
||||
|
||||
setStatus: (status) => set({ status }),
|
||||
|
||||
setShowProjectWizard: (show) => set({ showProjectWizard: show }),
|
||||
|
||||
setSettingsInitialCategory: (category) => set({ settingsInitialCategory: category }),
|
||||
|
||||
// ===== 编译器对话框 | Compiler dialog =====
|
||||
openCompilerDialog: (compilerId, currentFileName) => set({
|
||||
compilerDialog: { isOpen: true, compilerId, currentFileName },
|
||||
}),
|
||||
|
||||
closeCompilerDialog: () => set({
|
||||
compilerDialog: initialCompilerDialog,
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
// ============= Selectors =============
|
||||
// 使用 selector 避免不必要的重渲染 | Use selectors to avoid unnecessary re-renders
|
||||
|
||||
/** 选择项目状态 | Select project state */
|
||||
export const selectProjectState = (state: EditorState): ProjectState => ({
|
||||
projectLoaded: state.projectLoaded,
|
||||
currentProjectPath: state.currentProjectPath,
|
||||
availableScenes: state.availableScenes,
|
||||
isLoading: state.isLoading,
|
||||
loadingMessage: state.loadingMessage,
|
||||
});
|
||||
|
||||
/** 选择面板状态 | Select panel state */
|
||||
export const selectPanelState = (state: EditorState): PanelState => ({
|
||||
panels: state.panels,
|
||||
activeDynamicPanels: state.activeDynamicPanels,
|
||||
dynamicPanelTitles: state.dynamicPanelTitles,
|
||||
activePanelId: state.activePanelId,
|
||||
pluginUpdateTrigger: state.pluginUpdateTrigger,
|
||||
});
|
||||
|
||||
/** 选择 UI 状态 | Select UI state */
|
||||
export const selectUIState = (state: EditorState): UIState => ({
|
||||
isRemoteConnected: state.isRemoteConnected,
|
||||
isContentBrowserDocked: state.isContentBrowserDocked,
|
||||
isEditorFullscreen: state.isEditorFullscreen,
|
||||
status: state.status,
|
||||
showProjectWizard: state.showProjectWizard,
|
||||
settingsInitialCategory: state.settingsInitialCategory,
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 场景层级状态管理
|
||||
* Scene hierarchy state management
|
||||
*
|
||||
* 管理场景层级面板的状态,减少 useEffect 数量
|
||||
* Manages scene hierarchy panel state to reduce useEffect count
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { RemoteEntity } from '../services/tokens';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
/**
|
||||
* 场景状态
|
||||
* Scene state
|
||||
*/
|
||||
export interface SceneInfo {
|
||||
/** 场景名称 | Scene name */
|
||||
sceneName: string;
|
||||
/** 场景文件路径 | Scene file path */
|
||||
sceneFilePath: string | null;
|
||||
/** 是否已修改 | Is modified */
|
||||
isModified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体编辑模式状态
|
||||
* Prefab edit mode state
|
||||
*/
|
||||
export interface PrefabEditModeState {
|
||||
isActive: boolean;
|
||||
prefabName: string;
|
||||
prefabPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级 Store 状态
|
||||
* Hierarchy store state
|
||||
*/
|
||||
export interface HierarchyState {
|
||||
// ===== 场景状态 | Scene State =====
|
||||
/** 场景信息 | Scene info */
|
||||
sceneInfo: SceneInfo;
|
||||
/** 预制体编辑模式 | Prefab edit mode */
|
||||
prefabEditMode: PrefabEditModeState | null;
|
||||
|
||||
// ===== 实体状态 | Entity State =====
|
||||
/** 根实体列表 | Root entities */
|
||||
entities: Entity[];
|
||||
/** 选中的实体 ID 集合 | Selected entity IDs */
|
||||
selectedIds: Set<number>;
|
||||
/** 展开的实体 ID 集合 | Expanded entity IDs */
|
||||
expandedIds: Set<number>;
|
||||
|
||||
// ===== 远程状态 | Remote State =====
|
||||
/** 是否连接到远程 | Is connected to remote */
|
||||
isRemoteConnected: boolean;
|
||||
/** 远程实体列表 | Remote entities */
|
||||
remoteEntities: RemoteEntity[];
|
||||
/** 远程场景名称 | Remote scene name */
|
||||
remoteSceneName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级 Store Actions
|
||||
* Hierarchy store actions
|
||||
*/
|
||||
export interface HierarchyActions {
|
||||
// ===== 场景操作 | Scene Actions =====
|
||||
setSceneInfo: (info: Partial<SceneInfo>) => void;
|
||||
setPrefabEditMode: (mode: PrefabEditModeState | null) => void;
|
||||
|
||||
// ===== 实体操作 | Entity Actions =====
|
||||
setEntities: (entities: Entity[]) => void;
|
||||
setSelectedIds: (ids: Set<number> | ((prev: Set<number>) => Set<number>)) => void;
|
||||
selectEntity: (entityId: number) => void;
|
||||
deselectEntity: (entityId: number) => void;
|
||||
clearSelection: () => void;
|
||||
toggleExpanded: (entityId: number) => void;
|
||||
setExpandedIds: (ids: Set<number> | ((prev: Set<number>) => Set<number>)) => void;
|
||||
|
||||
// ===== 远程操作 | Remote Actions =====
|
||||
setIsRemoteConnected: (connected: boolean) => void;
|
||||
setRemoteEntities: (entities: RemoteEntity[] | ((prev: RemoteEntity[]) => RemoteEntity[])) => void;
|
||||
setRemoteSceneName: (name: string | null) => void;
|
||||
|
||||
// ===== 重置 | Reset =====
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type HierarchyStore = HierarchyState & HierarchyActions;
|
||||
|
||||
// ============= Initial State =============
|
||||
|
||||
const initialSceneInfo: SceneInfo = {
|
||||
sceneName: 'Untitled',
|
||||
sceneFilePath: null,
|
||||
isModified: false,
|
||||
};
|
||||
|
||||
const initialState: HierarchyState = {
|
||||
sceneInfo: initialSceneInfo,
|
||||
prefabEditMode: null,
|
||||
entities: [],
|
||||
selectedIds: new Set(),
|
||||
expandedIds: new Set([-1]), // -1 is scene root, expanded by default
|
||||
isRemoteConnected: false,
|
||||
remoteEntities: [],
|
||||
remoteSceneName: null,
|
||||
};
|
||||
|
||||
// ============= Store =============
|
||||
|
||||
/**
|
||||
* 层级状态 Store
|
||||
* Hierarchy state store
|
||||
*/
|
||||
export const useHierarchyStore = create<HierarchyStore>()(
|
||||
subscribeWithSelector((set) => ({
|
||||
...initialState,
|
||||
|
||||
// ===== 场景操作 | Scene Actions =====
|
||||
setSceneInfo: (info) => set((state) => ({
|
||||
sceneInfo: { ...state.sceneInfo, ...info }
|
||||
})),
|
||||
|
||||
setPrefabEditMode: (mode) => set({ prefabEditMode: mode }),
|
||||
|
||||
// ===== 实体操作 | Entity Actions =====
|
||||
setEntities: (entities) => set({ entities }),
|
||||
|
||||
setSelectedIds: (idsOrUpdater) => set((state) => ({
|
||||
selectedIds: typeof idsOrUpdater === 'function'
|
||||
? idsOrUpdater(state.selectedIds)
|
||||
: idsOrUpdater
|
||||
})),
|
||||
|
||||
selectEntity: (entityId) => set((state) => {
|
||||
const next = new Set(state.selectedIds);
|
||||
next.add(entityId);
|
||||
return { selectedIds: next };
|
||||
}),
|
||||
|
||||
deselectEntity: (entityId) => set((state) => {
|
||||
const next = new Set(state.selectedIds);
|
||||
next.delete(entityId);
|
||||
return { selectedIds: next };
|
||||
}),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
|
||||
toggleExpanded: (entityId) => set((state) => {
|
||||
const next = new Set(state.expandedIds);
|
||||
if (next.has(entityId)) {
|
||||
next.delete(entityId);
|
||||
} else {
|
||||
next.add(entityId);
|
||||
}
|
||||
return { expandedIds: next };
|
||||
}),
|
||||
|
||||
setExpandedIds: (idsOrUpdater) => set((state) => ({
|
||||
expandedIds: typeof idsOrUpdater === 'function'
|
||||
? idsOrUpdater(state.expandedIds)
|
||||
: idsOrUpdater
|
||||
})),
|
||||
|
||||
// ===== 远程操作 | Remote Actions =====
|
||||
setIsRemoteConnected: (connected) => set({ isRemoteConnected: connected }),
|
||||
|
||||
setRemoteEntities: (entitiesOrUpdater) => set((state) => ({
|
||||
remoteEntities: typeof entitiesOrUpdater === 'function'
|
||||
? entitiesOrUpdater(state.remoteEntities)
|
||||
: entitiesOrUpdater
|
||||
})),
|
||||
|
||||
setRemoteSceneName: (name) => set({ remoteSceneName: name }),
|
||||
|
||||
// ===== 重置 | Reset =====
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
);
|
||||
|
||||
// ============= Selectors =============
|
||||
|
||||
/** 选择场景信息 | Select scene info */
|
||||
export const selectSceneInfo = (state: HierarchyStore) => state.sceneInfo;
|
||||
|
||||
/** 选择预制体编辑模式 | Select prefab edit mode */
|
||||
export const selectPrefabEditMode = (state: HierarchyStore) => state.prefabEditMode;
|
||||
|
||||
/** 选择选中的实体 ID | Select selected entity IDs */
|
||||
export const selectSelectedIds = (state: HierarchyStore) => state.selectedIds;
|
||||
|
||||
/** 选择第一个选中的 ID | Select first selected ID */
|
||||
export const selectFirstSelectedId = (state: HierarchyStore) =>
|
||||
state.selectedIds.size > 0 ? Array.from(state.selectedIds)[0] : null;
|
||||
|
||||
/** 选择展开的实体 ID | Select expanded entity IDs */
|
||||
export const selectExpandedIds = (state: HierarchyStore) => state.expandedIds;
|
||||
|
||||
/** 选择远程连接状态 | Select remote connection state */
|
||||
export const selectIsRemoteConnected = (state: HierarchyStore) => state.isRemoteConnected;
|
||||
|
||||
/** 选择远程实体 | Select remote entities */
|
||||
export const selectRemoteEntities = (state: HierarchyStore) => state.remoteEntities;
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 检查器状态管理
|
||||
* Inspector state management
|
||||
*
|
||||
* 管理检查器面板的状态,减少 useEffect 数量
|
||||
* Manages inspector panel state to reduce useEffect count
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails } from '../components/inspectors/types';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
/**
|
||||
* 检查器 Store 状态
|
||||
* Inspector store state
|
||||
*/
|
||||
export interface InspectorState {
|
||||
/** 当前检查目标 | Current inspection target */
|
||||
target: InspectorTarget;
|
||||
/** 组件版本(触发刷新)| Component version (triggers refresh) */
|
||||
componentVersion: number;
|
||||
/** 自动刷新 | Auto refresh */
|
||||
autoRefresh: boolean;
|
||||
/** 是否锁定 | Is locked */
|
||||
isLocked: boolean;
|
||||
/** 小数位数设置 | Decimal places setting */
|
||||
decimalPlaces: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查器 Store Actions
|
||||
* Inspector store actions
|
||||
*/
|
||||
export interface InspectorActions {
|
||||
/** 设置目标 | Set target */
|
||||
setTarget: (target: InspectorTarget) => void;
|
||||
/** 设置实体目标 | Set entity target */
|
||||
setEntityTarget: (entity: Entity) => void;
|
||||
/** 设置远程实体目标 | Set remote entity target */
|
||||
setRemoteEntityTarget: (entity: RemoteEntity, details?: unknown) => void;
|
||||
/** 设置资产文件目标 | Set asset file target */
|
||||
setAssetFileTarget: (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => void;
|
||||
/** 设置扩展目标 | Set extension target */
|
||||
setExtensionTarget: (data: Record<string, unknown>) => void;
|
||||
/** 清除目标 | Clear target */
|
||||
clearTarget: () => void;
|
||||
/** 更新远程实体详情 | Update remote entity details */
|
||||
updateRemoteEntityDetails: (entityId: number, details: unknown) => void;
|
||||
/** 增加组件版本 | Increment component version */
|
||||
incrementComponentVersion: () => void;
|
||||
/** 设置自动刷新 | Set auto refresh */
|
||||
setAutoRefresh: (autoRefresh: boolean) => void;
|
||||
/** 设置锁定状态 | Set locked state */
|
||||
setIsLocked: (locked: boolean) => void;
|
||||
/** 设置小数位数 | Set decimal places */
|
||||
setDecimalPlaces: (places: number) => void;
|
||||
/** 重置 | Reset */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type InspectorStore = InspectorState & InspectorActions;
|
||||
|
||||
// ============= Initial State =============
|
||||
|
||||
const initialState: InspectorState = {
|
||||
target: null,
|
||||
componentVersion: 0,
|
||||
autoRefresh: true,
|
||||
isLocked: false,
|
||||
decimalPlaces: 4,
|
||||
};
|
||||
|
||||
// ============= Store =============
|
||||
|
||||
/**
|
||||
* 检查器状态 Store
|
||||
* Inspector state store
|
||||
*/
|
||||
export const useInspectorStore = create<InspectorStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setTarget: (target) => set({ target }),
|
||||
|
||||
setEntityTarget: (entity) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: { type: 'entity', data: entity },
|
||||
componentVersion: 0
|
||||
});
|
||||
},
|
||||
|
||||
setRemoteEntityTarget: (entity, details) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: { type: 'remote-entity', data: entity, details: details as EntityDetails | undefined }
|
||||
});
|
||||
},
|
||||
|
||||
setAssetFileTarget: (fileInfo, content, isImage) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: { type: 'asset-file', data: fileInfo, content, isImage }
|
||||
});
|
||||
},
|
||||
|
||||
setExtensionTarget: (data) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: { type: 'extension', data }
|
||||
});
|
||||
},
|
||||
|
||||
clearTarget: () => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({ target: null, componentVersion: 0 });
|
||||
},
|
||||
|
||||
updateRemoteEntityDetails: (entityId, details) => {
|
||||
const state = get();
|
||||
if (state.target?.type === 'remote-entity' && state.target.data.id === entityId) {
|
||||
set({
|
||||
target: { ...state.target, details: details as EntityDetails | undefined }
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
incrementComponentVersion: () => set((state) => ({
|
||||
componentVersion: state.componentVersion + 1
|
||||
})),
|
||||
|
||||
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
|
||||
|
||||
setIsLocked: (locked) => set({ isLocked: locked }),
|
||||
|
||||
setDecimalPlaces: (places) => set({ decimalPlaces: places }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
);
|
||||
|
||||
// ============= Selectors =============
|
||||
|
||||
/** 选择目标 | Select target */
|
||||
export const selectTarget = (state: InspectorStore) => state.target;
|
||||
|
||||
/** 选择目标类型 | Select target type */
|
||||
export const selectTargetType = (state: InspectorStore) => state.target?.type ?? null;
|
||||
|
||||
/** 选择是否锁定 | Select is locked */
|
||||
export const selectIsLocked = (state: InspectorStore) => state.isLocked;
|
||||
|
||||
/** 选择组件版本 | Select component version */
|
||||
export const selectComponentVersion = (state: InspectorStore) => state.componentVersion;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 状态管理模块导出
|
||||
* State management module exports
|
||||
*/
|
||||
|
||||
export { useEditorStore, selectProjectState, selectPanelState, selectUIState } from './EditorStore';
|
||||
export type { EditorState, ProjectState, PanelState, UIState, CompilerDialogState, EditorActions } from './EditorStore';
|
||||
|
||||
export {
|
||||
useHierarchyStore,
|
||||
selectSceneInfo,
|
||||
selectPrefabEditMode,
|
||||
selectSelectedIds,
|
||||
selectFirstSelectedId,
|
||||
selectExpandedIds,
|
||||
selectIsRemoteConnected,
|
||||
selectRemoteEntities
|
||||
} from './HierarchyStore';
|
||||
export type { HierarchyStore, HierarchyState, HierarchyActions, SceneInfo, PrefabEditModeState } from './HierarchyStore';
|
||||
|
||||
export {
|
||||
useInspectorStore,
|
||||
selectTarget,
|
||||
selectTargetType,
|
||||
selectIsLocked,
|
||||
selectComponentVersion
|
||||
} from './InspectorStore';
|
||||
export type { InspectorStore, InspectorState, InspectorActions } from './InspectorStore';
|
||||
|
||||
// Re-export dialog store from managers for convenience
|
||||
export { useDialogStore } from '../app/managers/DialogManager';
|
||||
@@ -701,11 +701,13 @@
|
||||
.build-progress-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.build-progress-header {
|
||||
@@ -747,14 +749,31 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
gap: 12px;
|
||||
padding: 28px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.build-progress-status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Success state background */
|
||||
/* 成功状态背景 */
|
||||
.build-progress-status-icon:has(.build-progress-success) {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
/* Error state background */
|
||||
/* 错误状态背景 */
|
||||
.build-progress-status-icon:has(.build-progress-error) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.build-progress-spinner {
|
||||
@@ -769,18 +788,33 @@
|
||||
|
||||
.build-progress-success {
|
||||
color: #4ade80;
|
||||
filter: drop-shadow(0 0 8px rgba(74, 222, 128, 0.3));
|
||||
}
|
||||
|
||||
.build-progress-error {
|
||||
color: #ef4444;
|
||||
filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.3));
|
||||
}
|
||||
|
||||
.build-progress-message {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Success message styling */
|
||||
/* 成功消息样式 */
|
||||
.build-progress-dialog:has(.build-progress-success) .build-progress-message {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Error message styling */
|
||||
/* 错误消息样式 */
|
||||
.build-progress-dialog:has(.build-progress-error) .build-progress-message {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.build-progress-bar-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -812,25 +846,35 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.build-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.build-result-label {
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.build-result-value {
|
||||
color: #ccc;
|
||||
color: #e0e0e0;
|
||||
word-break: break-all;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.build-result-error {
|
||||
@@ -977,16 +1021,29 @@
|
||||
.build-progress-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.build-progress-actions .build-settings-btn {
|
||||
min-width: 80px;
|
||||
min-width: 100px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Success state actions - show open folder prominently */
|
||||
/* 成功状态操作 - 突出显示打开文件夹按钮 */
|
||||
.build-progress-actions .build-settings-btn.secondary {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.build-progress-actions .build-settings-btn.secondary:hover {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
/* ==================== Toggle Group | 开关组 ==================== */
|
||||
|
||||
.build-settings-toggle-group {
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.cb-filter-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cb-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -320,6 +324,99 @@
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.cb-filter-btn.has-filter {
|
||||
background: #0e639c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cb-filter-badge {
|
||||
font-size: 10px;
|
||||
background: #fff;
|
||||
color: #0e639c;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.cb-filter-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 200px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cb-filter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cb-filter-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0e639c;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cb-filter-clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cb-filter-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.cb-filter-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cb-filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.cb-filter-item:hover {
|
||||
background: #2a2d2e;
|
||||
}
|
||||
|
||||
.cb-filter-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cb-filter-ext {
|
||||
flex: 1;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.cb-filter-count {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cb-search-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -336,7 +433,7 @@
|
||||
|
||||
.cb-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 32px;
|
||||
padding: 6px 28px 6px 32px;
|
||||
background: #3c3c3c;
|
||||
border: 1px solid #4c4c4c;
|
||||
border-radius: 3px;
|
||||
@@ -353,6 +450,26 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cb-search-clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cb-search-clear:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cb-view-options {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -401,16 +518,89 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cb-loading,
|
||||
.cb-empty {
|
||||
/* Loading Spinner | 加载动画 */
|
||||
.cb-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cb-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #3c3c3c;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: cbSpinner 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cbSpinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State | 空状态 */
|
||||
.cb-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cb-empty-icon {
|
||||
color: #4a4a4a;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cb-empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cb-empty-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
max-width: 200px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cb-empty-action {
|
||||
margin-top: 8px;
|
||||
padding: 6px 16px;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.cb-empty-action:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* Search Highlight | 搜索高亮 */
|
||||
.search-highlight {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Asset Item - Grid View */
|
||||
.cb-asset-grid.grid .cb-asset-item {
|
||||
display: flex;
|
||||
@@ -550,6 +740,7 @@
|
||||
.cb-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 12px;
|
||||
background: #252526;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
@@ -557,6 +748,11 @@
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cb-status-selected {
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== Dialogs ==================== */
|
||||
.cb-dialog-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
overflow-y: auto;
|
||||
z-index: 10001;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.context-menu.submenu {
|
||||
@@ -29,7 +30,8 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
.context-menu-item:hover:not(.disabled),
|
||||
.context-menu-item.active:not(.disabled) {
|
||||
background-color: #094771;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,31 @@
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.inspector-search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.inspector-search-clear:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inspector-search-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Category Tabs */
|
||||
.inspector-category-tabs {
|
||||
display: flex;
|
||||
@@ -430,7 +455,7 @@
|
||||
.property-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
gap: 8px;
|
||||
@@ -440,6 +465,11 @@
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* 简单字段(文本、数字等)垂直居中 | Simple fields (text, number, etc.) center vertically */
|
||||
.property-field.simple-field {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.property-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -454,6 +484,12 @@
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
font-size: 11px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
/* 简单字段的 label 不需要额外的 padding | Simple field labels don't need extra padding */
|
||||
.property-field.simple-field .property-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.property-value-text {
|
||||
@@ -778,6 +814,15 @@
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.component-dropdown-item.selected {
|
||||
background: var(--color-bg-active, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.component-dropdown-item.selected svg {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.component-dropdown-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -880,15 +925,30 @@
|
||||
/* Property rows inside component */
|
||||
.component-item-content .property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
min-height: 22px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* 简单字段垂直居中 | Simple fields center vertically */
|
||||
.component-item-content .property-row.simple-field {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.component-item-content .property-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* 资源字段行需要更多高度 | Asset field rows need more height */
|
||||
.component-item-content .property-field-asset {
|
||||
min-height: 52px !important;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.component-item-content .property-field-asset > .property-label {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
/* Code Preview */
|
||||
.code-preview-section {
|
||||
flex: 1;
|
||||
@@ -1202,3 +1262,182 @@
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ========== Prefab Inspector Styles ========== */
|
||||
|
||||
.prefab-inspector .inspector-header {
|
||||
border-left: 3px solid #4ade80;
|
||||
}
|
||||
|
||||
.prefab-hierarchy {
|
||||
background: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
padding: 4px 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prefab-entity-node {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prefab-entity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prefab-entity-row:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.prefab-entity-expand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.prefab-entity-icon {
|
||||
color: #4ade80;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prefab-entity-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.prefab-entity-components {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.prefab-component-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.prefab-component-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.prefab-instantiate-btn:hover {
|
||||
background: #22c55e !important;
|
||||
}
|
||||
|
||||
/* ========== Prefab Property Override Styles ========== */
|
||||
/* 预制体属性覆盖样式 */
|
||||
|
||||
/* Property row with override indicator | 带覆盖指示的属性行 */
|
||||
.property-row.overridden {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.property-row.overridden::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #3b82f6;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.property-row.overridden .property-label {
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Override indicator dot for compact views | 紧凑视图的覆盖指示点 */
|
||||
.property-override-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Transform input override state | Transform 输入框覆盖状态 */
|
||||
.transform-axis-input.overridden {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.transform-axis-input.overridden .transform-axis-bar {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Context menu for reverting property | 还原属性的上下文菜单 */
|
||||
.property-context-menu {
|
||||
position: fixed;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 9999;
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.property-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.property-context-menu-item:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.property-context-menu-item.revert {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.property-context-menu-item.revert:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Component header prefab instance indicator | 组件头部预制体实例指示 */
|
||||
.component-item-header.prefab-instance {
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.component-item-header.prefab-instance .component-item-name::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 预制体实例信息样式
|
||||
* Prefab instance info styles
|
||||
*/
|
||||
|
||||
.prefab-instance-info {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prefab-instance-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prefab-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.prefab-instance-info .prefab-label {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefab-instance-info .prefab-name {
|
||||
color: var(--text-primary, #1f2937);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prefab-modified-badge {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prefab-instance-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prefab-action-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-primary, #374151);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.prefab-action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
border-color: var(--border-hover, #d1d5db);
|
||||
}
|
||||
|
||||
.prefab-action-btn:active:not(:disabled) {
|
||||
background: var(--bg-active, #e5e7eb);
|
||||
}
|
||||
|
||||
.prefab-action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Apply button - highlighted when there are modifications */
|
||||
.prefab-action-apply:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.prefab-action-apply:not(:disabled):hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Revert button - subtle warning style when modifications exist */
|
||||
.prefab-action-revert:not(:disabled) {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
border-color: #d97706;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.prefab-action-revert:not(:disabled):hover {
|
||||
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
/* Unpack button - danger style */
|
||||
.prefab-action-unpack {
|
||||
min-width: 28px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.prefab-action-unpack:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.prefab-instance-info {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.prefab-instance-info .prefab-label {
|
||||
color: var(--text-secondary-dark, #9ca3af);
|
||||
}
|
||||
|
||||
.prefab-instance-info .prefab-name {
|
||||
color: var(--text-primary-dark, #f3f4f6);
|
||||
}
|
||||
|
||||
.prefab-action-btn {
|
||||
background: var(--bg-secondary-dark, #374151);
|
||||
border-color: var(--border-color-dark, #4b5563);
|
||||
color: var(--text-primary-dark, #e5e7eb);
|
||||
}
|
||||
|
||||
.prefab-action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover-dark, #4b5563);
|
||||
border-color: var(--border-hover-dark, #6b7280);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@
|
||||
}
|
||||
|
||||
/* ==================== Property Row ==================== */
|
||||
.property-row {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.property-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -401,6 +407,43 @@ input[type="number"].property-input {
|
||||
}
|
||||
|
||||
/* ==================== Asset Field ==================== */
|
||||
/* Property field containing asset field - use flex-start alignment | 包含资源字段的属性行使用顶部对齐 */
|
||||
.property-field-asset {
|
||||
align-items: flex-start !important;
|
||||
min-height: 52px !important;
|
||||
}
|
||||
|
||||
.property-field-asset > .property-label {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
/* Ensure property-row expands for asset fields | 确保属性行能为资源字段扩展 */
|
||||
.property-row:has(.property-field-asset),
|
||||
.property-row:has(.asset-field) {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* Ensure property-row expands for array fields | 确保属性行能为数组字段扩展 */
|
||||
.property-row:has(.property-field-array) {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fallback for array fields without :has() support */
|
||||
.property-inspector .property-field-array {
|
||||
min-height: auto !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Fallback for browsers without :has() support | 不支持 :has() 的浏览器的备用方案 */
|
||||
.property-inspector .property-field-asset {
|
||||
min-height: 52px !important;
|
||||
}
|
||||
|
||||
.property-asset-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1157,6 +1200,149 @@ input[type="number"].property-input {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Array Field ==================== */
|
||||
.property-field-array {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 4px 8px;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.array-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.array-field-header .property-label {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.array-field-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.array-field-add {
|
||||
margin-left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 3px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.array-field-add:hover {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.array-field-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
margin-left: 8px;
|
||||
border-left: 1px solid #3a3a3a;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.array-field-empty {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.array-field-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
background: #252525;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.array-field-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.array-field-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.array-field-drag-handle {
|
||||
cursor: grab;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
letter-spacing: -2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.array-field-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.array-field-index {
|
||||
font-size: 9px;
|
||||
color: #555;
|
||||
font-family: monospace;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.array-field-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.array-field-value .property-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.array-field-value .asset-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.array-field-remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.array-field-remove:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Reduced Motion ==================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.property-input,
|
||||
@@ -1164,7 +1350,10 @@ input[type="number"].property-input {
|
||||
.property-toggle-thumb,
|
||||
.property-expand-btn,
|
||||
.property-color-preview,
|
||||
.property-section-expand {
|
||||
.property-section-expand,
|
||||
.array-field-item,
|
||||
.array-field-add,
|
||||
.array-field-remove {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,24 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.outliner-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -120,10 +138,11 @@
|
||||
.outliner-header-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
gap: 6px;
|
||||
padding-right: 6px;
|
||||
border-right: 1px solid #444;
|
||||
margin-right: 8px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@@ -157,7 +176,9 @@
|
||||
}
|
||||
|
||||
.outliner-header-type {
|
||||
width: 140px;
|
||||
width: 80px;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -187,23 +208,16 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
/* Hide scrollbar but keep scroll functionality */
|
||||
.outliner-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.outliner-content::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.outliner-content::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.outliner-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
.outliner-content {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.outliner-list {
|
||||
@@ -241,20 +255,53 @@
|
||||
|
||||
.outliner-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Drop Indicators */
|
||||
/* Drop Indicators | 拖放指示器 */
|
||||
.outliner-item.drop-before {
|
||||
border-top: 2px solid #4a9eff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.outliner-item.drop-before::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #4a9eff 0%, #4a9eff 50%, transparent 50%);
|
||||
background-size: 8px 2px;
|
||||
border-radius: 1px;
|
||||
animation: dropIndicatorPulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.outliner-item.drop-after {
|
||||
border-bottom: 2px solid #4a9eff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.outliner-item.drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #4a9eff 0%, #4a9eff 50%, transparent 50%);
|
||||
background-size: 8px 2px;
|
||||
border-radius: 1px;
|
||||
animation: dropIndicatorPulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.outliner-item.drop-inside {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
box-shadow: inset 0 0 0 2px rgba(74, 158, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes dropIndicatorPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.outliner-item.disabled {
|
||||
@@ -264,9 +311,10 @@
|
||||
.outliner-item-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
gap: 6px;
|
||||
padding-right: 6px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
@@ -283,6 +331,20 @@
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.item-icon.visibility:hover {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.item-icon.visibility.hidden {
|
||||
color: #555;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.item-icon.visibility.hidden:hover {
|
||||
color: #4a9eff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.outliner-item-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -328,8 +390,29 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 内联重命名输入框 | Inline rename input */
|
||||
.outliner-item-name-input {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
padding: 1px 4px;
|
||||
margin: -2px 0;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 2px;
|
||||
color: #fff;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.outliner-item-name-input:focus {
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.outliner-item-type {
|
||||
width: 140px;
|
||||
width: 80px;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
@@ -340,6 +423,7 @@
|
||||
/* Entity Type Icons */
|
||||
.entity-type-icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.entity-type-icon.world {
|
||||
@@ -546,3 +630,160 @@
|
||||
.context-menu-danger:hover {
|
||||
background: rgba(248, 113, 113, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 快捷键提示 | Shortcut hints */
|
||||
.context-menu-shortcut {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-family: var(--font-family-mono, 'Consolas', monospace);
|
||||
}
|
||||
|
||||
/* Prefab Instance Context Menu Styles | 预制体实例右键菜单样式 */
|
||||
.context-menu-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #3b82f6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.context-menu-section-header svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.context-menu-highlight {
|
||||
color: #60a5fa !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.context-menu-highlight:hover {
|
||||
background: rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
.context-menu-warning {
|
||||
color: #fbbf24 !important;
|
||||
}
|
||||
|
||||
.context-menu-warning:hover {
|
||||
background: rgba(251, 191, 36, 0.15) !important;
|
||||
}
|
||||
|
||||
.context-menu button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.context-menu button:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ==================== Prefab Edit Mode | 预制体编辑模式 ==================== */
|
||||
.scene-hierarchy.prefab-edit-mode {
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.prefab-edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #1e293b 100%);
|
||||
border-bottom: 1px solid #3b82f6;
|
||||
color: #93c5fd;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefab-edit-header svg {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.outliner-item.prefab-root {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.outliner-item.prefab-root:hover {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.entity-type-icon.prefab {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* ==================== Prefab Instance Indicator | 预制体实例指示器 ==================== */
|
||||
.outliner-item.prefab-instance {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.outliner-item.prefab-instance:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.outliner-item.prefab-instance.selected {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.entity-type-icon.prefab-instance {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.prefab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outliner-item.prefab-instance .outliner-item-name {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.outliner-item.prefab-instance .outliner-item-type {
|
||||
color: #60a5fa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==================== Responsive Layout | 响应式布局 ==================== */
|
||||
|
||||
/* 使用 container query 在窄面板时隐藏 Type 列 */
|
||||
/* Use container query to hide Type column in narrow panels */
|
||||
.scene-hierarchy {
|
||||
container-type: inline-size;
|
||||
container-name: scene-hierarchy;
|
||||
}
|
||||
|
||||
/* 当面板宽度小于 200px 时隐藏 Type 列和图标列 */
|
||||
/* Hide Type column and icons column when panel width < 200px */
|
||||
@container scene-hierarchy (max-width: 200px) {
|
||||
.outliner-header-type,
|
||||
.outliner-item-type,
|
||||
.outliner-header-icons,
|
||||
.outliner-item-icons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 当面板宽度小于 280px 时只隐藏 Type 列 */
|
||||
/* Hide only Type column when panel width < 280px */
|
||||
@container scene-hierarchy (max-width: 280px) {
|
||||
.outliner-header-type,
|
||||
.outliner-item-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,64 @@
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.startup-dialog-btn.primary {
|
||||
background: #0e639c;
|
||||
border-color: #0e639c;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.startup-dialog-btn.primary:hover:not(:disabled) {
|
||||
background: #1177bb;
|
||||
border-color: #1177bb;
|
||||
}
|
||||
|
||||
.startup-dialog-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* esbuild 安装对话框样式 | esbuild Installation Dialog Styles */
|
||||
.startup-dialog-info {
|
||||
padding: 10px 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #cccccc;
|
||||
border-left: 3px solid #0e639c;
|
||||
}
|
||||
|
||||
.startup-dialog-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
color: #0e639c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.startup-dialog-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid #dc2626;
|
||||
}
|
||||
|
||||
.dialog-icon-info {
|
||||
color: #0e639c;
|
||||
}
|
||||
|
||||
/* 环境状态指示器样式 | Environment Status Indicator Styles */
|
||||
.startup-env-status {
|
||||
position: relative;
|
||||
|
||||
@@ -380,6 +380,50 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Live Transform Display | 实时变换显示 */
|
||||
.viewport-live-transform {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-above);
|
||||
animation: slideUp 0.15s ease-out;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.live-transform-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.live-transform-value {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.viewport:fullscreen {
|
||||
background: #000;
|
||||
}
|
||||
@@ -397,3 +441,83 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Prefab Edit Mode Toolbar | 预制体编辑模式工具栏 ==================== */
|
||||
.viewport.prefab-edit-mode {
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.viewport-prefab-toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, rgba(30, 58, 95, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.viewport-prefab-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e0f2fe;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.viewport-prefab-toolbar-left svg {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.viewport-prefab-toolbar-left .prefab-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewport-prefab-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.viewport-prefab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.viewport-prefab-btn.save {
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.viewport-prefab-btn.save:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.viewport-prefab-btn.exit {
|
||||
background: #475569;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.viewport-prefab-btn.exit:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
@@ -75,16 +75,6 @@ select {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 滚动条悬停时显示更宽 */
|
||||
*:hover::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user