Feature/editor optimization (#251)
* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
@@ -34,9 +34,7 @@ import { ProjectCreationWizard } from './components/ProjectCreationWizard';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { ConsolePanel } from './components/ConsolePanel';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { ProfilerWindow } from './components/ProfilerWindow';
|
||||
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
|
||||
import { PortManager } from './components/PortManager';
|
||||
import { SettingsWindow } from './components/SettingsWindow';
|
||||
@@ -110,7 +108,6 @@ function App() {
|
||||
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||
const [showProjectWizard, setShowProjectWizard] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -372,6 +369,9 @@ function App() {
|
||||
|
||||
await projectService.openProject(projectPath);
|
||||
|
||||
// 注意:插件配置会在引擎初始化后加载和激活
|
||||
// Note: Plugin config will be loaded and activated after engine initialization
|
||||
|
||||
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
|
||||
await TauriAPI.setProjectBasePath(projectPath);
|
||||
|
||||
@@ -392,6 +392,19 @@ function App() {
|
||||
throw new Error(locale === 'zh' ? '引擎初始化超时' : 'Engine initialization timeout');
|
||||
}
|
||||
|
||||
// 加载项目插件配置并激活插件(在引擎初始化后、模块系统初始化前)
|
||||
// Load project plugin config and activate plugins (after engine init, before module system init)
|
||||
if (pluginManager) {
|
||||
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 });
|
||||
} else {
|
||||
console.log('[App] No plugin settings found in project config');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化模块系统(所有插件的 runtimeModule 会在 PluginManager 安装时自动注册)
|
||||
await engineService.initializeModuleSystems();
|
||||
|
||||
@@ -520,13 +533,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfilerMode = async () => {
|
||||
setIsProfilerMode(true);
|
||||
setIsRemoteConnected(true);
|
||||
setProjectLoaded(true);
|
||||
setStatus(t('header.status.profilerMode') || 'Profiler Mode - Waiting for connection...');
|
||||
};
|
||||
|
||||
const handleNewScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
@@ -631,7 +637,6 @@ function App() {
|
||||
|
||||
setProjectLoaded(false);
|
||||
setCurrentProjectPath(null);
|
||||
setIsProfilerMode(false);
|
||||
setStatus(t('header.status.ready'));
|
||||
};
|
||||
|
||||
@@ -705,45 +710,26 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
let corePanels: FlexDockPanel[];
|
||||
|
||||
if (isProfilerMode) {
|
||||
corePanels = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} isProfilerMode={true} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
} else {
|
||||
corePanels = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
content: <Viewport locale={locale} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
}
|
||||
const corePanels: FlexDockPanel[] = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
content: <Viewport locale={locale} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
|
||||
// 获取启用的插件面板
|
||||
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
|
||||
@@ -797,7 +783,7 @@ function App() {
|
||||
|
||||
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
|
||||
|
||||
|
||||
if (!initialized) {
|
||||
@@ -819,7 +805,6 @@ function App() {
|
||||
onOpenProject={handleOpenProject}
|
||||
onCreateProject={handleCreateProject}
|
||||
onOpenRecentProject={handleOpenRecentProject}
|
||||
onProfilerMode={handleProfilerMode}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
recentProjects={recentProjects}
|
||||
locale={locale}
|
||||
@@ -947,12 +932,11 @@ function App() {
|
||||
/>
|
||||
|
||||
|
||||
{showProfiler && (
|
||||
<ProfilerWindow onClose={() => setShowProfiler(false)} />
|
||||
)}
|
||||
|
||||
{showAdvancedProfiler && (
|
||||
<AdvancedProfilerWindow onClose={() => setShowAdvancedProfiler(false)} />
|
||||
{(showProfiler || showAdvancedProfiler) && (
|
||||
<AdvancedProfilerWindow onClose={() => {
|
||||
setShowProfiler(false);
|
||||
setShowAdvancedProfiler(false);
|
||||
}} />
|
||||
)}
|
||||
|
||||
{showPortManager && (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* 插件安装器
|
||||
* Plugin Installer
|
||||
*
|
||||
* 现在所有插件都使用统一的 IPlugin 接口,无需适配器。
|
||||
* Now all plugins use the unified IPlugin interface, no adapter needed.
|
||||
*/
|
||||
|
||||
import type { PluginManager } from '@esengine/editor-core';
|
||||
@@ -13,12 +16,12 @@ import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePl
|
||||
import { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
|
||||
// 统一模块插件(CSS 已内联到 JS 中,导入时自动注入)
|
||||
import { TilemapPlugin } from '@esengine/tilemap';
|
||||
import { UIPlugin } from '@esengine/ui';
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint/editor';
|
||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIPlugin } from '@esengine/ui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
@@ -61,13 +64,13 @@ export class PluginInstaller {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
continue;
|
||||
}
|
||||
// 详细日志,检查 editorModule 是否存在
|
||||
console.log(`[PluginInstaller] ${name}: descriptor.id=${plugin.descriptor.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// All builtin plugins registered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SceneManagerService,
|
||||
SceneTemplateRegistry,
|
||||
FileActionRegistry,
|
||||
IFileActionRegistry,
|
||||
EntityCreationRegistry,
|
||||
PluginManager,
|
||||
IPluginManager,
|
||||
@@ -28,14 +29,11 @@ import {
|
||||
CompilerRegistry,
|
||||
ICompilerRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
TransformComponent,
|
||||
SpriteComponent,
|
||||
SpriteAnimatorComponent,
|
||||
TextComponent,
|
||||
CameraComponent,
|
||||
AudioSourceComponent
|
||||
} from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { AudioSourceComponent } from '@esengine/audio';
|
||||
import { UITextComponent } from '@esengine/ui';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
@@ -110,7 +108,7 @@ export class ServiceRegistry {
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description', icon: 'Type' },
|
||||
{ name: 'UITextComponent', type: UITextComponent, editorName: 'UIText', category: 'components.category.ui', description: 'components.text.description', icon: 'Type' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
|
||||
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
|
||||
@@ -154,6 +152,7 @@ export class ServiceRegistry {
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManager);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
|
||||
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
|
||||
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
|
||||
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -28,13 +29,15 @@ export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform、Sprite和Animator组件
|
||||
// 添加 Transform、Sprite、Animator 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new SpriteAnimatorComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -28,12 +29,14 @@ export class CreateCameraEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Camera组件
|
||||
// 添加 Transform、Camera 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new CameraComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -28,11 +28,15 @@ export class CreateEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 自动添加Transform组件
|
||||
// 自动添加 Transform 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
|
||||
// 添加 HierarchyComponent 支持层级结构
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -28,12 +29,14 @@ export class CreateSpriteEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Sprite组件
|
||||
// 添加 Transform、Sprite 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
@@ -48,8 +48,9 @@ export class CreateTilemapEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform组件
|
||||
// 添加 Transform 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
// 创建并配置Tilemap组件
|
||||
const tilemapComponent = new TilemapComponent();
|
||||
@@ -79,7 +80,8 @@ export class CreateTilemapEntityCommand extends BaseCommand {
|
||||
this.entity.addComponent(tilemapComponent);
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Core, Entity, Component } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, Component, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
@@ -8,9 +8,9 @@ import { BaseCommand } from '../BaseCommand';
|
||||
export class DeleteEntityCommand extends BaseCommand {
|
||||
private entityId: number;
|
||||
private entityName: string;
|
||||
private parentEntity: Entity | null;
|
||||
private parentEntityId: number | null;
|
||||
private components: Component[] = [];
|
||||
private childEntities: Entity[] = [];
|
||||
private childEntityIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
@@ -20,18 +20,28 @@ export class DeleteEntityCommand extends BaseCommand {
|
||||
super();
|
||||
this.entityId = entity.id;
|
||||
this.entityName = entity.name;
|
||||
this.parentEntity = entity.parent;
|
||||
|
||||
// 通过 HierarchyComponent 获取父实体 ID
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
this.parentEntityId = hierarchy?.parentId ?? null;
|
||||
|
||||
// 保存组件状态用于撤销
|
||||
this.components = [...entity.components];
|
||||
// 保存子实体
|
||||
this.childEntities = [...entity.children];
|
||||
|
||||
// 保存子实体 ID
|
||||
this.childEntityIds = hierarchy?.childIds ? [...hierarchy.childIds] : [];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 先移除子实体
|
||||
for (const child of this.childEntities) {
|
||||
this.entityStore.removeEntity(child);
|
||||
for (const childId of this.childEntityIds) {
|
||||
const child = scene.findEntityById(childId);
|
||||
if (child) {
|
||||
this.entityStore.removeEntity(child);
|
||||
}
|
||||
}
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
@@ -46,12 +56,17 @@ export class DeleteEntityCommand extends BaseCommand {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 重新创建实体
|
||||
const newEntity = scene.createEntity(this.entityName);
|
||||
|
||||
// 设置父实体
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(newEntity);
|
||||
if (this.parentEntityId !== null && hierarchySystem) {
|
||||
const parentEntity = scene.findEntityById(this.parentEntityId);
|
||||
if (parentEntity) {
|
||||
hierarchySystem.setParent(newEntity, parentEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复组件
|
||||
@@ -71,12 +86,20 @@ export class DeleteEntityCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
// 恢复子实体
|
||||
for (const child of this.childEntities) {
|
||||
newEntity.addChild(child);
|
||||
this.entityStore.addEntity(child, newEntity);
|
||||
for (const childId of this.childEntityIds) {
|
||||
const child = scene.findEntityById(childId);
|
||||
if (child && hierarchySystem) {
|
||||
hierarchySystem.setParent(child, newEntity);
|
||||
this.entityStore.addEntity(child, newEntity);
|
||||
}
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(newEntity, this.parentEntity ?? undefined);
|
||||
// 获取父实体
|
||||
const parentEntity = this.parentEntityId !== null
|
||||
? scene.findEntityById(this.parentEntityId) ?? undefined
|
||||
: undefined;
|
||||
|
||||
this.entityStore.addEntity(newEntity, parentEntity);
|
||||
this.entityStore.selectEntity(newEntity);
|
||||
|
||||
// 更新引用
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 拖放位置类型
|
||||
*/
|
||||
export enum DropPosition {
|
||||
/** 在目标之前 */
|
||||
BEFORE = 'before',
|
||||
/** 在目标内部(作为子级) */
|
||||
INSIDE = 'inside',
|
||||
/** 在目标之后 */
|
||||
AFTER = 'after'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新设置实体父级命令
|
||||
*
|
||||
* 支持拖拽重排功能,可以将实体移动到:
|
||||
* - 另一个实体之前 (BEFORE)
|
||||
* - 另一个实体内部作为子级 (INSIDE)
|
||||
* - 另一个实体之后 (AFTER)
|
||||
*/
|
||||
export class ReparentEntityCommand extends BaseCommand {
|
||||
private oldParentId: number | null;
|
||||
private oldSiblingIndex: number;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private targetEntity: Entity,
|
||||
private dropPosition: DropPosition
|
||||
) {
|
||||
super();
|
||||
|
||||
// 保存原始状态用于撤销
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
this.oldParentId = hierarchy?.parentId ?? null;
|
||||
|
||||
// 获取在兄弟列表中的原始索引
|
||||
this.oldSiblingIndex = this.getSiblingIndex(entity);
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
console.warn('[ReparentEntityCommand] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (!hierarchySystem) {
|
||||
console.warn('[ReparentEntityCommand] No HierarchySystem found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保目标实体有 HierarchyComponent
|
||||
if (!this.targetEntity.getComponent(HierarchyComponent)) {
|
||||
this.targetEntity.addComponent(new HierarchyComponent());
|
||||
}
|
||||
|
||||
console.log(`[ReparentEntityCommand] Moving ${this.entity.name} to ${this.targetEntity.name} (${this.dropPosition})`);
|
||||
|
||||
switch (this.dropPosition) {
|
||||
case DropPosition.INSIDE:
|
||||
// 移动到目标实体内部作为最后一个子级
|
||||
hierarchySystem.setParent(this.entity, this.targetEntity);
|
||||
break;
|
||||
|
||||
case DropPosition.BEFORE:
|
||||
case DropPosition.AFTER:
|
||||
// 移动到目标实体的同级
|
||||
this.moveToSibling(hierarchySystem);
|
||||
break;
|
||||
}
|
||||
|
||||
this.entityStore.syncFromScene();
|
||||
this.messageHub.publish('entity:reparented', {
|
||||
entityId: this.entity.id,
|
||||
targetId: this.targetEntity.id,
|
||||
position: this.dropPosition
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (!hierarchySystem) return;
|
||||
|
||||
// 恢复到原始父级
|
||||
const oldParent = this.oldParentId !== null
|
||||
? scene.findEntityById(this.oldParentId)
|
||||
: null;
|
||||
|
||||
if (oldParent) {
|
||||
// 恢复到原始父级的指定位置
|
||||
hierarchySystem.insertChildAt(oldParent, this.entity, this.oldSiblingIndex);
|
||||
} else {
|
||||
// 恢复到根级
|
||||
hierarchySystem.setParent(this.entity, null);
|
||||
}
|
||||
|
||||
this.entityStore.syncFromScene();
|
||||
this.messageHub.publish('entity:reparented', {
|
||||
entityId: this.entity.id,
|
||||
targetId: null,
|
||||
position: 'undo'
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const positionText = this.dropPosition === DropPosition.INSIDE
|
||||
? '移入'
|
||||
: this.dropPosition === DropPosition.BEFORE ? '移动到前面' : '移动到后面';
|
||||
return `${positionText}: ${this.entity.name} -> ${this.targetEntity.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动到目标的同级位置
|
||||
*/
|
||||
private moveToSibling(hierarchySystem: HierarchySystem): void {
|
||||
const targetHierarchy = this.targetEntity.getComponent(HierarchyComponent);
|
||||
const targetParentId = targetHierarchy?.parentId ?? null;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 获取目标的父实体
|
||||
const targetParent = targetParentId !== null
|
||||
? scene.findEntityById(targetParentId)
|
||||
: null;
|
||||
|
||||
// 获取目标在兄弟列表中的索引
|
||||
let targetIndex = this.getSiblingIndex(this.targetEntity);
|
||||
|
||||
// 根据放置位置调整索引
|
||||
if (this.dropPosition === DropPosition.AFTER) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// 如果移动到同一父级下,需要考虑原位置对索引的影响
|
||||
const entityHierarchy = this.entity.getComponent(HierarchyComponent);
|
||||
const entityParentId = entityHierarchy?.parentId ?? null;
|
||||
|
||||
const bSameParent = entityParentId === targetParentId;
|
||||
if (bSameParent) {
|
||||
const currentIndex = this.getSiblingIndex(this.entity);
|
||||
if (currentIndex < targetIndex) {
|
||||
targetIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ReparentEntityCommand] moveToSibling: targetParent=${targetParent?.name ?? 'ROOT'}, targetIndex=${targetIndex}`);
|
||||
|
||||
if (targetParent) {
|
||||
// 有父级,插入到父级的指定位置
|
||||
hierarchySystem.insertChildAt(targetParent, this.entity, targetIndex);
|
||||
} else {
|
||||
// 目标在根级
|
||||
// 先确保实体移动到根级
|
||||
if (entityParentId !== null) {
|
||||
hierarchySystem.setParent(this.entity, null);
|
||||
}
|
||||
// 然后调整根级顺序
|
||||
this.entityStore.reorderEntity(this.entity.id, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体在兄弟列表中的索引
|
||||
*/
|
||||
private getSiblingIndex(entity: Entity): number {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return 0;
|
||||
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
const parentId = hierarchy?.parentId;
|
||||
|
||||
if (parentId === null || parentId === undefined) {
|
||||
// 根级实体,从 EntityStoreService 获取
|
||||
return this.entityStore.getRootEntityIds().indexOf(entity.id);
|
||||
}
|
||||
|
||||
const parent = scene.findEntityById(parentId);
|
||||
if (!parent) return 0;
|
||||
|
||||
const parentHierarchy = parent.getComponent(HierarchyComponent);
|
||||
return parentHierarchy?.childIds.indexOf(entity.id) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityC
|
||||
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
|
||||
export { CreateTilemapEntityCommand } from './CreateTilemapEntityCommand';
|
||||
export { DeleteEntityCommand } from './DeleteEntityCommand';
|
||||
export { ReparentEntityCommand, DropPosition } from './ReparentEntityCommand';
|
||||
|
||||
|
||||
@@ -8,6 +8,19 @@ import '../styles/AdvancedProfiler.css';
|
||||
/**
|
||||
* 高级性能数据接口(与 Core 的 IAdvancedProfilerData 对应)
|
||||
*/
|
||||
interface HotspotItem {
|
||||
name: string;
|
||||
category: string;
|
||||
inclusiveTime: number;
|
||||
inclusiveTimePercent: number;
|
||||
exclusiveTime: number;
|
||||
exclusiveTimePercent: number;
|
||||
callCount: number;
|
||||
avgCallTime: number;
|
||||
depth: number;
|
||||
children?: HotspotItem[];
|
||||
}
|
||||
|
||||
interface AdvancedProfilerData {
|
||||
currentFrame: {
|
||||
frameNumber: number;
|
||||
@@ -41,16 +54,7 @@ interface AdvancedProfilerData {
|
||||
percentOfFrame: number;
|
||||
}>;
|
||||
}>;
|
||||
hotspots: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
inclusiveTime: number;
|
||||
inclusiveTimePercent: number;
|
||||
exclusiveTime: number;
|
||||
exclusiveTimePercent: number;
|
||||
callCount: number;
|
||||
avgCallTime: number;
|
||||
}>;
|
||||
hotspots: HotspotItem[];
|
||||
callGraph: {
|
||||
currentFunction: string | null;
|
||||
callers: Array<{
|
||||
@@ -120,18 +124,72 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
'Custom': '#64748b'
|
||||
};
|
||||
|
||||
type DataMode = 'oneframe' | 'average' | 'maximum';
|
||||
|
||||
export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
const [data, setData] = useState<AdvancedProfilerData | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['ECS']));
|
||||
const [expandedHotspots, setExpandedHotspots] = useState<Set<string>>(new Set());
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('incTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
|
||||
const [dataMode, setDataMode] = useState<DataMode>('average');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const frameHistoryRef = useRef<Array<{ time: number; duration: number }>>([]);
|
||||
const lastDataRef = useRef<AdvancedProfilerData | null>(null);
|
||||
// 用于计算平均值和最大值的历史数据
|
||||
const hotspotHistoryRef = useRef<Map<string, { times: number[]; maxTime: number }>>(new Map());
|
||||
|
||||
// 更新历史数据
|
||||
const updateHotspotHistory = useCallback((hotspots: HotspotItem[]) => {
|
||||
const updateItem = (item: HotspotItem) => {
|
||||
const history = hotspotHistoryRef.current.get(item.name) || { times: [], maxTime: 0 };
|
||||
history.times.push(item.inclusiveTime);
|
||||
// 保留最近 60 帧的数据
|
||||
if (history.times.length > 60) {
|
||||
history.times.shift();
|
||||
}
|
||||
history.maxTime = Math.max(history.maxTime, item.inclusiveTime);
|
||||
hotspotHistoryRef.current.set(item.name, history);
|
||||
|
||||
if (item.children) {
|
||||
item.children.forEach(updateItem);
|
||||
}
|
||||
};
|
||||
hotspots.forEach(updateItem);
|
||||
}, []);
|
||||
|
||||
// 根据数据模式处理 hotspots
|
||||
const processHotspotsWithDataMode = useCallback((hotspots: HotspotItem[], mode: DataMode): HotspotItem[] => {
|
||||
if (mode === 'oneframe') {
|
||||
return hotspots;
|
||||
}
|
||||
|
||||
const processItem = (item: HotspotItem): HotspotItem => {
|
||||
const history = hotspotHistoryRef.current.get(item.name);
|
||||
let processedTime = item.inclusiveTime;
|
||||
|
||||
if (history && history.times.length > 0) {
|
||||
if (mode === 'average') {
|
||||
processedTime = history.times.reduce((a, b) => a + b, 0) / history.times.length;
|
||||
} else if (mode === 'maximum') {
|
||||
processedTime = history.maxTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
inclusiveTime: processedTime,
|
||||
avgCallTime: item.callCount > 0 ? processedTime / item.callCount : 0,
|
||||
children: item.children ? item.children.map(processItem) : undefined
|
||||
};
|
||||
};
|
||||
|
||||
return hotspots.map(processItem);
|
||||
}, []);
|
||||
|
||||
// 订阅数据更新
|
||||
useEffect(() => {
|
||||
@@ -142,18 +200,21 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
|
||||
// 解析高级性能数据
|
||||
if (rawData.advancedProfiler) {
|
||||
// 更新历史数据
|
||||
updateHotspotHistory(rawData.advancedProfiler.hotspots);
|
||||
setData(rawData.advancedProfiler);
|
||||
lastDataRef.current = rawData.advancedProfiler;
|
||||
} else if (rawData.performance) {
|
||||
// 从传统数据构建
|
||||
const advancedData = buildFromLegacyData(rawData);
|
||||
updateHotspotHistory(advancedData.hotspots);
|
||||
setData(advancedData);
|
||||
lastDataRef.current = advancedData;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [profilerService, isPaused]);
|
||||
}, [profilerService, isPaused, updateHotspotHistory]);
|
||||
|
||||
// 当选中函数变化时,通知服务端
|
||||
useEffect(() => {
|
||||
@@ -317,44 +378,90 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
return percent.toFixed(1) + '%';
|
||||
};
|
||||
|
||||
// 展平层级数据用于显示
|
||||
const flattenHotspots = (items: HotspotItem[], result: HotspotItem[] = []): HotspotItem[] => {
|
||||
for (const item of items) {
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchTerm === '' || item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (viewMode === 'flat') {
|
||||
// 扁平模式:显示所有层级的项目
|
||||
if (matchesSearch) {
|
||||
result.push({ ...item, depth: 0 }); // 扁平模式下深度都是0
|
||||
}
|
||||
if (item.children) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
} else {
|
||||
// 层级模式:根据展开状态显示
|
||||
if (matchesSearch || (item.children && item.children.some(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())))) {
|
||||
result.push(item);
|
||||
}
|
||||
if (item.children && expandedHotspots.has(item.name)) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 切换展开状态
|
||||
const toggleHotspotExpand = (name: string) => {
|
||||
setExpandedHotspots(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 排序数据
|
||||
const getSortedHotspots = () => {
|
||||
const getSortedHotspots = (): HotspotItem[] => {
|
||||
if (!data) return [];
|
||||
|
||||
const filtered = data.hotspots.filter(h =>
|
||||
searchTerm === '' || h.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
// 先根据数据模式处理 hotspots
|
||||
const processedHotspots = processHotspotsWithDataMode(data.hotspots, dataMode);
|
||||
const flattened = flattenHotspots(processedHotspots);
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'incTime':
|
||||
comparison = a.inclusiveTime - b.inclusiveTime;
|
||||
break;
|
||||
case 'incPercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
case 'excTime':
|
||||
comparison = a.exclusiveTime - b.exclusiveTime;
|
||||
break;
|
||||
case 'excPercent':
|
||||
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
|
||||
break;
|
||||
case 'calls':
|
||||
comparison = a.callCount - b.callCount;
|
||||
break;
|
||||
case 'avgTime':
|
||||
comparison = a.avgCallTime - b.avgCallTime;
|
||||
break;
|
||||
case 'framePercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
// 扁平模式下排序
|
||||
if (viewMode === 'flat') {
|
||||
return [...flattened].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'incTime':
|
||||
comparison = a.inclusiveTime - b.inclusiveTime;
|
||||
break;
|
||||
case 'incPercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
case 'excTime':
|
||||
comparison = a.exclusiveTime - b.exclusiveTime;
|
||||
break;
|
||||
case 'excPercent':
|
||||
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
|
||||
break;
|
||||
case 'calls':
|
||||
comparison = a.callCount - b.callCount;
|
||||
break;
|
||||
case 'avgTime':
|
||||
comparison = a.avgCallTime - b.avgCallTime;
|
||||
break;
|
||||
case 'framePercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// 层级模式下保持原有层级顺序
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const renderSortIcon = (column: SortColumn) => {
|
||||
@@ -512,7 +619,11 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
<Activity size={14} />
|
||||
<span className="profiler-graph-title">Call Graph</span>
|
||||
<div className="profiler-callgraph-controls">
|
||||
<select className="profiler-callgraph-type-select">
|
||||
<select
|
||||
className="profiler-callgraph-type-select"
|
||||
value={dataMode}
|
||||
onChange={(e) => setDataMode(e.target.value as DataMode)}
|
||||
>
|
||||
<option value="oneframe">One Frame</option>
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
@@ -654,49 +765,67 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-body">
|
||||
{getSortedHotspots().map((item, index) => (
|
||||
<div
|
||||
key={item.name + index}
|
||||
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedFunction(item.name)}
|
||||
>
|
||||
<div className="profiler-table-cell col-name name">
|
||||
<ChevronRight size={12} className="expand-icon" />
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-time numeric">
|
||||
{formatTime(item.inclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-percent percent">
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
|
||||
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
|
||||
{getSortedHotspots().map((item, index) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedHotspots.has(item.name);
|
||||
const indentPadding = viewMode === 'hierarchical' ? item.depth * 16 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.name + index + item.depth}
|
||||
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''} depth-${item.depth}`}
|
||||
onClick={() => setSelectedFunction(item.name)}
|
||||
>
|
||||
<div className="profiler-table-cell col-name name" style={{ paddingLeft: indentPadding }}>
|
||||
{hasChildren && viewMode === 'hierarchical' ? (
|
||||
<span
|
||||
className={`expand-icon clickable ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHotspotExpand(item.name);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="expand-icon placeholder" style={{ width: 12 }} />
|
||||
)}
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
|
||||
/>
|
||||
<span>{formatPercent(item.inclusiveTimePercent)}</span>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-time numeric">
|
||||
{formatTime(item.inclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-percent percent">
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
|
||||
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
|
||||
/>
|
||||
<span>{formatPercent(item.inclusiveTimePercent)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-time numeric">
|
||||
{formatTime(item.exclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-percent percent">
|
||||
{formatPercent(item.exclusiveTimePercent)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-calls numeric">
|
||||
{item.callCount}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-avg-calls numeric">
|
||||
{formatTime(item.avgCallTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-frame-percent percent">
|
||||
{formatPercent(item.inclusiveTimePercent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-time numeric">
|
||||
{formatTime(item.exclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-percent percent">
|
||||
{formatPercent(item.exclusiveTimePercent)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-calls numeric">
|
||||
{item.callCount}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-avg-calls numeric">
|
||||
{formatTime(item.avgCallTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-frame-percent percent">
|
||||
{formatPercent(item.inclusiveTimePercent)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,7 +845,7 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
|
||||
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
|
||||
|
||||
// 构建 hotspots
|
||||
const hotspots = systems.map((sys: any) => ({
|
||||
const hotspots: HotspotItem[] = systems.map((sys: any) => ({
|
||||
name: sys.name || sys.type || 'Unknown',
|
||||
category: 'ECS',
|
||||
inclusiveTime: sys.executionTime || 0,
|
||||
@@ -724,7 +853,8 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
|
||||
exclusiveTime: sys.executionTime || 0,
|
||||
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
|
||||
callCount: 1,
|
||||
avgCallTime: sys.executionTime || 0
|
||||
avgCallTime: sys.executionTime || 0,
|
||||
depth: 0
|
||||
}));
|
||||
|
||||
// 构建 categoryStats
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, BarChart3 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, BarChart3, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { ProfilerService } from '../services/ProfilerService';
|
||||
import { AdvancedProfiler } from './AdvancedProfiler';
|
||||
import '../styles/ProfilerWindow.css';
|
||||
@@ -15,6 +15,7 @@ interface WindowWithProfiler extends Window {
|
||||
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
|
||||
const [profilerService, setProfilerService] = useState<ProfilerService | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const service = (window as WindowWithProfiler).__PROFILER_SERVICE__;
|
||||
@@ -36,12 +37,35 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
|
||||
return () => clearInterval(interval);
|
||||
}, [profilerService]);
|
||||
|
||||
// 处理 ESC 键退出全屏
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const windowStyle = isFullscreen
|
||||
? { width: '100vw', height: '100vh', maxWidth: 'none', borderRadius: 0 }
|
||||
: { width: '90vw', height: '85vh', maxWidth: '1600px' };
|
||||
|
||||
return (
|
||||
<div className="profiler-window-overlay" onClick={onClose}>
|
||||
<div
|
||||
className={`profiler-window-overlay ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={isFullscreen ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className="profiler-window advanced-profiler-window"
|
||||
className={`profiler-window advanced-profiler-window ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: '90vw', height: '85vh', maxWidth: '1600px' }}
|
||||
style={windowStyle}
|
||||
>
|
||||
<div className="profiler-window-header">
|
||||
<div className="profiler-window-title">
|
||||
@@ -53,9 +77,18 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="profiler-window-controls">
|
||||
<button
|
||||
className="profiler-window-btn"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-content" style={{ padding: 0 }}>
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { useState, useEffect, useRef, useMemo, memo } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Wifi } from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/ConsolePanel.css';
|
||||
|
||||
interface ConsolePanelProps {
|
||||
logService: LogService;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
// 提取JSON检测和格式化逻辑
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
// 日志等级图标
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 日志等级样式类
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 单个日志条目组件
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
log: LogEntry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
}) => {
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-message">
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => onOpenJsonViewer(parsed as any)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogEntryItem.displayName = 'LogEntryItem';
|
||||
|
||||
export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
// 状态管理
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
LogLevel.Warn,
|
||||
LogLevel.Error,
|
||||
LogLevel.Fatal
|
||||
]));
|
||||
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 订阅日志更新
|
||||
useEffect(() => {
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, entry];
|
||||
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [logService]);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// 处理滚动
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
};
|
||||
|
||||
// 清空日志
|
||||
const handleClear = () => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// 切换等级过滤
|
||||
const toggleLevelFilter = (level: LogLevel) => {
|
||||
const newFilter = new Set(levelFilter);
|
||||
if (newFilter.has(level)) {
|
||||
newFilter.delete(level);
|
||||
} else {
|
||||
newFilter.add(level);
|
||||
}
|
||||
setLevelFilter(newFilter);
|
||||
};
|
||||
|
||||
// 过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
if (showRemoteOnly && log.source !== 'remote') return false;
|
||||
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [logs, levelFilter, showRemoteOnly, filter]);
|
||||
|
||||
// 统计各等级日志数量
|
||||
const levelCounts = useMemo(() => ({
|
||||
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
|
||||
[LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length,
|
||||
[LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
|
||||
}), [logs]);
|
||||
|
||||
const remoteLogCount = useMemo(() =>
|
||||
logs.filter((l) => l.source === 'remote').length
|
||||
, [logs]);
|
||||
|
||||
return (
|
||||
<div className="console-panel">
|
||||
<div className="console-toolbar">
|
||||
<div className="console-toolbar-left">
|
||||
<button
|
||||
className="console-btn"
|
||||
onClick={handleClear}
|
||||
title="Clear console"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="console-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-toolbar-right">
|
||||
<button
|
||||
className={`console-filter-btn ${showRemoteOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowRemoteOnly(!showRemoteOnly)}
|
||||
title="Show Remote Logs Only"
|
||||
>
|
||||
<Wifi size={14} />
|
||||
{remoteLogCount > 0 && <span>{remoteLogCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Debug) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Debug)}
|
||||
title="Debug"
|
||||
>
|
||||
<Bug size={14} />
|
||||
{levelCounts[LogLevel.Debug] > 0 && <span>{levelCounts[LogLevel.Debug]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Info) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Info)}
|
||||
title="Info"
|
||||
>
|
||||
<Info size={14} />
|
||||
{levelCounts[LogLevel.Info] > 0 && <span>{levelCounts[LogLevel.Info]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Warn) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Warn)}
|
||||
title="Warnings"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
{levelCounts[LogLevel.Warn] > 0 && <span>{levelCounts[LogLevel.Warn]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Error) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Error)}
|
||||
title="Errors"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
{levelCounts[LogLevel.Error] > 0 && <span>{levelCounts[LogLevel.Error]}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="console-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="console-empty">
|
||||
<AlertCircle size={32} />
|
||||
<p>No logs to display</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="console-scroll-to-bottom"
|
||||
onClick={() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓ Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,15 @@ import {
|
||||
Trash2,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
PanelRightClose
|
||||
PanelRightClose,
|
||||
Tag,
|
||||
Link,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Package,
|
||||
Clipboard,
|
||||
RefreshCw,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
|
||||
@@ -564,9 +572,191 @@ export function ContentBrowser({
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 保存
|
||||
items.push({
|
||||
label: locale === 'zh' ? '保存' : 'Save',
|
||||
icon: <Save size={16} />,
|
||||
shortcut: 'Ctrl+S',
|
||||
onClick: () => {
|
||||
console.log('Save file:', asset.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'F2',
|
||||
onClick: () => {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
// 批量重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '批量重命名' : 'Batch Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'Shift+F2',
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
console.log('Batch rename');
|
||||
}
|
||||
});
|
||||
|
||||
// 复制
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制' : 'Duplicate',
|
||||
icon: <Clipboard size={16} />,
|
||||
shortcut: 'Ctrl+D',
|
||||
onClick: () => {
|
||||
console.log('Duplicate:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
// 删除
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
shortcut: 'Delete',
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 资产操作子菜单
|
||||
items.push({
|
||||
label: locale === 'zh' ? '资产操作' : 'Asset Actions',
|
||||
icon: <Settings size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: locale === 'zh' ? '重新导入' : 'Reimport',
|
||||
icon: <RefreshCw size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Reimport asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导出...' : 'Export...',
|
||||
icon: <Package size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Export asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{ label: '', separator: true, onClick: () => {} },
|
||||
{
|
||||
label: locale === 'zh' ? '迁移资产' : 'Migrate Asset',
|
||||
icon: <Folder size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Migrate asset:', asset.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 资产本地化子菜单
|
||||
items.push({
|
||||
label: locale === 'zh' ? '资产本地化' : 'Asset Localization',
|
||||
icon: <Globe size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: locale === 'zh' ? '创建本地化资产' : 'Create Localized Asset',
|
||||
onClick: () => {
|
||||
console.log('Create localized asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导入翻译' : 'Import Translation',
|
||||
onClick: () => {
|
||||
console.log('Import translation:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导出翻译' : 'Export Translation',
|
||||
onClick: () => {
|
||||
console.log('Export translation:', asset.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 标签管理
|
||||
items.push({
|
||||
label: locale === 'zh' ? '管理标签' : 'Manage Tags',
|
||||
icon: <Tag size={16} />,
|
||||
shortcut: 'Ctrl+T',
|
||||
onClick: () => {
|
||||
console.log('Manage tags:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 路径复制选项
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制引用' : 'Copy Reference',
|
||||
icon: <Link size={16} />,
|
||||
shortcut: 'Ctrl+C',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '拷贝Object路径' : 'Copy Object Path',
|
||||
icon: <Copy size={16} />,
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
onClick: () => {
|
||||
const objectPath = asset.path.replace(/\\/g, '/');
|
||||
navigator.clipboard.writeText(objectPath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '拷贝包路径' : 'Copy Package Path',
|
||||
icon: <Package size={16} />,
|
||||
shortcut: 'Ctrl+Alt+C',
|
||||
onClick: () => {
|
||||
const packagePath = '/' + asset.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
|
||||
navigator.clipboard.writeText(packagePath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 引用查看器
|
||||
items.push({
|
||||
label: locale === 'zh' ? '引用查看器' : 'Reference Viewer',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+R',
|
||||
onClick: () => {
|
||||
console.log('Open reference viewer:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '尺寸信息图' : 'Size Map',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+D',
|
||||
onClick: () => {
|
||||
console.log('Show size map:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 在文件管理器中显示
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
icon: <ExternalLink size={16} />,
|
||||
@@ -579,34 +769,8 @@ export function ContentBrowser({
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => navigator.clipboard.writeText(asset.path)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]);
|
||||
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder, setRenameDialog, setDeleteConfirmDialog, setContextMenu, setCreateFileDialog]);
|
||||
|
||||
// Render folder tree node
|
||||
const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => {
|
||||
@@ -818,7 +982,10 @@ export function ContentBrowser({
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, asset);
|
||||
}}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import '../styles/ContextMenu.css';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -7,6 +8,10 @@ export interface ContextMenuItem {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
/** 快捷键提示文本 */
|
||||
shortcut?: string;
|
||||
/** 子菜单项 */
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -15,9 +20,113 @@ interface ContextMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SubMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
parentRect: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子菜单组件
|
||||
*/
|
||||
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setPosition({ x, y });
|
||||
}
|
||||
}, [parentRect]);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu submenu"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
@@ -65,6 +174,17 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -79,19 +199,36 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus } from 'lucide-react';
|
||||
import {
|
||||
Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus,
|
||||
Save, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings
|
||||
} from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
@@ -614,25 +617,187 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 文件操作菜单项
|
||||
items.push({
|
||||
label: '保存',
|
||||
icon: <Save size={16} />,
|
||||
shortcut: 'Ctrl+S',
|
||||
onClick: () => {
|
||||
// TODO: 实现保存功能
|
||||
console.log('Save file:', node.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: '重命名',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'F2',
|
||||
onClick: () => {
|
||||
setRenamingNode(node.path);
|
||||
setNewName(node.name);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '批量重命名',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'Shift+F2',
|
||||
disabled: true, // TODO: 实现批量重命名
|
||||
onClick: () => {
|
||||
console.log('Batch rename');
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '复制',
|
||||
icon: <Clipboard size={16} />,
|
||||
shortcut: 'Ctrl+D',
|
||||
onClick: () => {
|
||||
// TODO: 实现复制功能
|
||||
console.log('Duplicate:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '删除',
|
||||
icon: <Trash2 size={16} />,
|
||||
shortcut: 'Delete',
|
||||
onClick: () => handleDeleteClick(node)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 资产操作子菜单
|
||||
items.push({
|
||||
label: '资产操作',
|
||||
icon: <Settings size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: '重新导入',
|
||||
icon: <RefreshCw size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Reimport asset:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导出...',
|
||||
icon: <Package size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Export asset:', node.path);
|
||||
}
|
||||
},
|
||||
{ label: '', separator: true, onClick: () => {} },
|
||||
{
|
||||
label: '迁移资产',
|
||||
icon: <Folder size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Migrate asset:', node.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 资产本地化子菜单
|
||||
items.push({
|
||||
label: '资产本地化',
|
||||
icon: <Globe size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: '创建本地化资产',
|
||||
onClick: () => {
|
||||
console.log('Create localized asset:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导入翻译',
|
||||
onClick: () => {
|
||||
console.log('Import translation:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导出翻译',
|
||||
onClick: () => {
|
||||
console.log('Export translation:', node.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 标签和引用
|
||||
items.push({
|
||||
label: '管理标签',
|
||||
icon: <Tag size={16} />,
|
||||
shortcut: 'Ctrl+T',
|
||||
onClick: () => {
|
||||
console.log('Manage tags:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 路径复制选项
|
||||
items.push({
|
||||
label: '复制引用',
|
||||
icon: <Link size={16} />,
|
||||
shortcut: 'Ctrl+C',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '拷贝Object路径',
|
||||
icon: <Copy size={16} />,
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
onClick: () => {
|
||||
// 生成对象路径格式
|
||||
const objectPath = node.path.replace(/\\/g, '/');
|
||||
navigator.clipboard.writeText(objectPath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '拷贝包路径',
|
||||
icon: <Package size={16} />,
|
||||
shortcut: 'Ctrl+Alt+C',
|
||||
onClick: () => {
|
||||
// 生成包路径格式
|
||||
const packagePath = '/' + node.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
|
||||
navigator.clipboard.writeText(packagePath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 引用查看器
|
||||
items.push({
|
||||
label: '引用查看器',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+R',
|
||||
onClick: () => {
|
||||
console.log('Open reference viewer:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '尺寸信息图',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+D',
|
||||
onClick: () => {
|
||||
console.log('Show size map:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
if (node.type === 'folder') {
|
||||
items.push({
|
||||
label: '新建文件',
|
||||
@@ -675,14 +840,6 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '复制路径',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(node.path);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Search, Filter, Settings, X, Trash2, ChevronDown,
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play, Copy
|
||||
} from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/OutputLogPanel.css';
|
||||
|
||||
interface OutputLogPanelProps {
|
||||
@@ -16,15 +15,6 @@ interface OutputLogPanelProps {
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
@@ -33,103 +23,121 @@ function formatTime(date: Date): string {
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
function getLevelIcon(level: LogLevel, size: number = 14) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
return <Bug size={size} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
return <Info size={size} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
return <AlertTriangle size={size} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
return <XCircle size={size} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
return <AlertCircle size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
return 'output-log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
return 'output-log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
return 'output-log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
return 'output-log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
/**
|
||||
* 尝试从消息中提取堆栈信息
|
||||
*/
|
||||
function extractStackTrace(message: string): { message: string; stack: string | null } {
|
||||
const stackPattern = /\n\s*at\s+/;
|
||||
if (stackPattern.test(message)) {
|
||||
const lines = message.split('\n');
|
||||
const messageLines: string[] = [];
|
||||
const stackLines: string[] = [];
|
||||
let inStack = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('at ') || inStack) {
|
||||
inStack = true;
|
||||
stackLines.push(line);
|
||||
} else {
|
||||
messageLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: messageLines.join('\n').trim(),
|
||||
stack: stackLines.length > 0 ? stackLines.join('\n') : null
|
||||
};
|
||||
}
|
||||
|
||||
return { message, stack: null };
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({ log, isExpanded, onToggle, onCopy }: {
|
||||
log: LogEntry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCopy: () => void;
|
||||
}) => {
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// 优先使用 log.stack,否则尝试从 message 中提取
|
||||
const { message, stack } = useMemo(() => {
|
||||
if (log.stack) {
|
||||
return { message: log.message, stack: log.stack };
|
||||
}
|
||||
return extractStackTrace(log.message);
|
||||
}, [log.message, log.stack]);
|
||||
|
||||
const hasStack = !!stack;
|
||||
|
||||
return (
|
||||
<div className={`output-log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
<div
|
||||
className={`output-log-entry ${getLevelClass(log.level)} ${isExpanded ? 'expanded' : ''} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${hasStack ? 'has-stack' : ''}`}
|
||||
>
|
||||
<div className="output-log-entry-main" onClick={hasStack ? onToggle : undefined} style={{ cursor: hasStack ? 'pointer' : 'default' }}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? 'Remote' : log.source}]
|
||||
</div>
|
||||
<div className="output-log-entry-message">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
className="output-log-entry-copy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="output-log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
{isExpanded && stack && (
|
||||
<div className="output-log-entry-stack">
|
||||
<div className="output-log-stack-header">调用堆栈:</div>
|
||||
{stack.split('\n').filter(line => line.trim()).map((line, index) => (
|
||||
<div key={index} className="output-log-stack-line">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="output-log-entry-message">
|
||||
<div className="output-log-message-container">
|
||||
<div className="output-log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="output-log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="output-log-json-btn"
|
||||
onClick={() => onOpenJsonViewer(parsed)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -150,10 +158,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const [showTimestamp, setShowTimestamp] = useState(true);
|
||||
const [showSource, setShowSource] = useState(true);
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -174,7 +179,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Close menus on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
||||
@@ -199,6 +203,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
const handleClear = useCallback(() => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
setExpandedLogIds(new Set());
|
||||
}, [logService]);
|
||||
|
||||
const toggleLevelFilter = useCallback((level: LogLevel) => {
|
||||
@@ -213,6 +218,22 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleLogExpanded = useCallback((logId: string) => {
|
||||
setExpandedLogIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId);
|
||||
} else {
|
||||
newSet.add(logId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCopyLog = useCallback((log: LogEntry) => {
|
||||
navigator.clipboard.writeText(log.message);
|
||||
}, []);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
@@ -376,26 +397,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
</button>
|
||||
{showSettingsMenu && (
|
||||
<div className="output-log-menu settings-menu">
|
||||
<div className="output-log-menu-header">
|
||||
{locale === 'zh' ? '显示选项' : 'Display Options'}
|
||||
</div>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTimestamp}
|
||||
onChange={() => setShowTimestamp(!showTimestamp)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示时间戳' : 'Show Timestamp'}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSource}
|
||||
onChange={() => setShowSource(!showSource)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示来源' : 'Show Source'}</span>
|
||||
</label>
|
||||
<div className="output-log-menu-divider" />
|
||||
<button
|
||||
className="output-log-menu-action"
|
||||
onClick={handleClear}
|
||||
@@ -421,7 +422,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
className={`output-log-content ${!showTimestamp ? 'hide-timestamp' : ''} ${!showSource ? 'hide-source' : ''}`}
|
||||
className="output-log-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
@@ -438,7 +439,9 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
isExpanded={expandedLogIds.has(String(log.id))}
|
||||
onToggle={() => toggleLogExpanded(String(log.id))}
|
||||
onCopy={() => handleCopyLog(log)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -461,14 +464,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Viewer Modal */}
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory } from '@esengine/editor-core';
|
||||
import { Check, Lock, RefreshCw, Package } from 'lucide-react';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
|
||||
@@ -30,14 +30,14 @@ const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' }
|
||||
content: { zh: '内容', en: 'Content' },
|
||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content'];
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
@@ -55,11 +55,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.loader.descriptor.id === pluginId);
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
@@ -69,11 +69,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
const newEnabled = !plugin.enabled;
|
||||
|
||||
// 检查依赖
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.loader.descriptor.id === dep.id);
|
||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
@@ -81,44 +81,76 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 检查是否有其他插件依赖此插件
|
||||
const dependents = plugins.filter(p => {
|
||||
if (!p.enabled || p.loader.descriptor.id === pluginId) return false;
|
||||
const deps = p.loader.descriptor.dependencies || [];
|
||||
return deps.some(d => d.id === pluginId);
|
||||
});
|
||||
|
||||
if (dependents.length > 0) {
|
||||
showWarning(`以下插件依赖此插件: ${dependents.map(p => p.loader.descriptor.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录待处理的更改
|
||||
const newPendingChanges = new Map(pendingChanges);
|
||||
newPendingChanges.set(pluginId, newEnabled);
|
||||
setPendingChanges(newPendingChanges);
|
||||
// 调用 PluginManager 的动态启用/禁用方法
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enabling' : 'Disabling'} plugin: ${pluginId}`);
|
||||
let success: boolean;
|
||||
if (newEnabled) {
|
||||
success = await pluginManager.enable(pluginId);
|
||||
} else {
|
||||
success = await pluginManager.disable(pluginId);
|
||||
}
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enable' : 'Disable'} result: ${success}`);
|
||||
|
||||
if (!success) {
|
||||
showWarning(newEnabled ? '启用插件失败' : '禁用插件失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.loader.descriptor.id === pluginId) {
|
||||
if (p.plugin.descriptor.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
|
||||
// 调用 PluginManager 的启用/禁用方法
|
||||
if (newEnabled) {
|
||||
pluginManager.enable(pluginId);
|
||||
} else {
|
||||
pluginManager.disable(pluginId);
|
||||
// 保存到项目配置
|
||||
savePluginConfigToProject();
|
||||
|
||||
// 通知用户(如果有编辑器模块变更)
|
||||
const hasEditorModule = !!plugin.plugin.editorModule;
|
||||
if (hasEditorModule) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存插件配置到项目文件
|
||||
*/
|
||||
const savePluginConfigToProject = async () => {
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
if (!projectService || !projectService.isProjectOpen()) {
|
||||
console.warn('[PluginListSetting] Cannot save: project not open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.descriptor.isCore)
|
||||
.map(p => p.plugin.descriptor.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
try {
|
||||
await projectService.setEnabledPlugins(enabledPlugins);
|
||||
console.log('[PluginListSetting] Plugin config saved successfully');
|
||||
} catch (error) {
|
||||
console.error('[PluginListSetting] Failed to save plugin config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.loader.descriptor.category;
|
||||
const category = plugin.plugin.descriptor.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
@@ -131,13 +163,6 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
return (
|
||||
<div className="plugin-list-setting">
|
||||
{pendingChanges.size > 0 && (
|
||||
<div className="plugin-list-notice">
|
||||
<RefreshCw size={14} />
|
||||
<span>部分更改需要重启编辑器后生效</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedCategories.map(category => (
|
||||
<div key={category} className="plugin-category">
|
||||
<div className="plugin-category-header">
|
||||
@@ -145,9 +170,9 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
const hasRuntime = !!plugin.loader.runtimeModule;
|
||||
const hasEditor = !!plugin.loader.editorModule;
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -18,12 +18,16 @@ export function PortManager({ onClose }: PortManagerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setServerPort(settings.get('profiler.port', '8080'));
|
||||
const savedPort = settings.get('profiler.port', 8080);
|
||||
console.log('[PortManager] Initial port from settings:', savedPort);
|
||||
setServerPort(String(savedPort));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
console.log('[PortManager] settings:changed event received:', event.detail);
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setServerPort(newPort);
|
||||
if (newPort !== undefined) {
|
||||
console.log('[PortManager] Updating port to:', newPort);
|
||||
setServerPort(String(newPort));
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown,
|
||||
Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag,
|
||||
SquareStack
|
||||
SquareStack, FolderPlus
|
||||
} from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import { CreateEntityCommand, DeleteEntityCommand, ReparentEntityCommand, DropPosition } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode {
|
||||
@@ -61,8 +61,19 @@ interface SceneHierarchyProps {
|
||||
interface EntityNode {
|
||||
entity: Entity;
|
||||
children: EntityNode[];
|
||||
isExpanded: boolean;
|
||||
depth: number;
|
||||
bIsFolder: boolean;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖放指示器位置
|
||||
*/
|
||||
enum DropIndicator {
|
||||
NONE = 'none',
|
||||
BEFORE = 'before',
|
||||
INSIDE = 'inside',
|
||||
AFTER = 'after'
|
||||
}
|
||||
|
||||
export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) {
|
||||
@@ -78,9 +89,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
|
||||
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ entityId: number; indicator: DropIndicator } | null>(null);
|
||||
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set([-1])); // -1 is scene root
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
@@ -89,6 +100,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
|
||||
const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null;
|
||||
|
||||
/**
|
||||
* 构建层级树结构
|
||||
*/
|
||||
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const buildNode = (entity: Entity, depth: number): EntityNode => {
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
const childIds = hierarchy?.childIds ?? [];
|
||||
const bIsEntityFolder = isFolder(entity.tag);
|
||||
|
||||
const children: EntityNode[] = [];
|
||||
for (const childId of childIds) {
|
||||
const childEntity = scene.findEntityById(childId);
|
||||
if (childEntity) {
|
||||
children.push(buildNode(childEntity, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entity,
|
||||
children,
|
||||
depth,
|
||||
bIsFolder: bIsEntityFolder,
|
||||
hasChildren: children.length > 0
|
||||
};
|
||||
};
|
||||
|
||||
return rootEntities.map((entity) => buildNode(entity, 1));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 扁平化树为带深度信息的列表(用于渲染)
|
||||
*/
|
||||
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
|
||||
const result: EntityNode[] = [];
|
||||
|
||||
const traverse = (nodeList: EntityNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
result.push(node);
|
||||
|
||||
const bIsExpanded = expandedSet.has(node.entity.id);
|
||||
if (bIsExpanded && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 层级树和扁平化列表
|
||||
*/
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
|
||||
const flattenedEntities = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
|
||||
[entityTree, expandedIds, flattenTree]
|
||||
);
|
||||
|
||||
// Get entity creation templates from plugins
|
||||
useEffect(() => {
|
||||
const updateTemplates = () => {
|
||||
@@ -171,7 +244,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
|
||||
const unsubSceneLoaded = messageHub.subscribe('scene:loaded', updateEntities);
|
||||
const unsubSceneNew = messageHub.subscribe('scene:new', updateEntities);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', updateEntities);
|
||||
const unsubReordered = messageHub.subscribe('entity:reordered', updateEntities);
|
||||
const unsubReparented = messageHub.subscribe('entity:reparented', updateEntities);
|
||||
|
||||
return () => {
|
||||
unsubAdd();
|
||||
@@ -180,7 +255,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
unsubSelect();
|
||||
unsubSceneLoaded();
|
||||
unsubSceneNew();
|
||||
unsubSceneRestored();
|
||||
unsubReordered();
|
||||
unsubReparented();
|
||||
};
|
||||
}, [entityStore, messageHub]);
|
||||
|
||||
@@ -258,35 +335,110 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, entityId: number) => {
|
||||
const handleDragStart = useCallback((e: React.DragEvent, entityId: number) => {
|
||||
setDraggedEntityId(entityId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', entityId.toString());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
/**
|
||||
* 根据鼠标位置计算拖放指示器位置
|
||||
* 上20%区域 = BEFORE, 中间60% = INSIDE, 下20% = AFTER
|
||||
* 所有实体都支持作为父节点接收子节点
|
||||
*/
|
||||
const calculateDropIndicator = useCallback((e: React.DragEvent, _targetNode: EntityNode): DropIndicator => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height * 0.2) {
|
||||
return DropIndicator.BEFORE;
|
||||
} else if (y > height * 0.8) {
|
||||
return DropIndicator.AFTER;
|
||||
} else {
|
||||
return DropIndicator.INSIDE;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDropTargetIndex(index);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedEntityId !== null) {
|
||||
entityStore.reorderEntity(draggedEntityId, targetIndex);
|
||||
// 不能拖放到自己
|
||||
if (draggedEntityId === targetNode.entity.id) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
setDraggedEntityId(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
// 检查是否拖到自己的子节点
|
||||
const scene = Core.scene;
|
||||
if (scene && draggedEntityId !== null) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
const draggedEntity = scene.findEntityById(draggedEntityId);
|
||||
if (draggedEntity && hierarchySystem?.isAncestorOf(draggedEntity, targetNode.entity)) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const indicator = calculateDropIndicator(e, targetNode);
|
||||
setDropTarget({ entityId: targetNode.entity.id, indicator });
|
||||
}, [draggedEntityId, calculateDropIndicator]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedEntityId === null || !dropTarget) {
|
||||
setDraggedEntityId(null);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const draggedEntity = scene.findEntityById(draggedEntityId);
|
||||
if (!draggedEntity) return;
|
||||
|
||||
// 转换 DropIndicator 到 DropPosition
|
||||
let dropPosition: DropPosition;
|
||||
switch (dropTarget.indicator) {
|
||||
case DropIndicator.BEFORE:
|
||||
dropPosition = DropPosition.BEFORE;
|
||||
break;
|
||||
case DropIndicator.INSIDE:
|
||||
dropPosition = DropPosition.INSIDE;
|
||||
// 自动展开目标节点
|
||||
setExpandedIds(prev => new Set([...prev, targetNode.entity.id]));
|
||||
break;
|
||||
case DropIndicator.AFTER:
|
||||
dropPosition = DropPosition.AFTER;
|
||||
break;
|
||||
default:
|
||||
dropPosition = DropPosition.AFTER;
|
||||
}
|
||||
|
||||
const command = new ReparentEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
draggedEntity,
|
||||
targetNode.entity,
|
||||
dropPosition
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
setDraggedEntityId(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
setDropTarget(null);
|
||||
}, [draggedEntityId, dropTarget, entityStore, messageHub, commandManager]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedEntityId(null);
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleRemoteEntityClick = (entity: RemoteEntity) => {
|
||||
setSelectedIds(new Set([entity.id]));
|
||||
@@ -373,8 +525,8 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isShowingRemote]);
|
||||
|
||||
const toggleFolderExpand = (entityId: number) => {
|
||||
setExpandedFolders(prev => {
|
||||
const toggleExpand = useCallback((entityId: number) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entityId)) {
|
||||
next.delete(entityId);
|
||||
@@ -383,7 +535,29 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 创建文件夹实体
|
||||
*/
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const folderName = locale === 'zh' ? `文件夹 ${entityCount + 1}` : `Folder ${entityCount + 1}`;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entity = scene.createEntity(folderName);
|
||||
entity.tag = EntityTags.FOLDER;
|
||||
|
||||
// 添加 HierarchyComponent 支持层级结构
|
||||
entity.addComponent(new HierarchyComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
entityStore.selectEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
}, [entityStore, messageHub, locale]);
|
||||
|
||||
const handleSortClick = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
@@ -394,20 +568,33 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
};
|
||||
|
||||
// Get entity type for display
|
||||
const getEntityType = (entity: Entity): string => {
|
||||
/**
|
||||
* 获取实体类型显示名称
|
||||
*/
|
||||
const getEntityType = useCallback((entity: Entity): string => {
|
||||
if (isFolder(entity.tag)) {
|
||||
return 'Folder';
|
||||
}
|
||||
|
||||
const components = entity.components || [];
|
||||
if (components.length > 0) {
|
||||
const firstComponent = components[0];
|
||||
return firstComponent?.constructor?.name || 'Entity';
|
||||
}
|
||||
return 'Entity';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get icon for entity type
|
||||
const getEntityIcon = (entityType: string): React.ReactNode => {
|
||||
/**
|
||||
* 获取实体类型图标
|
||||
*/
|
||||
const getEntityIcon = useCallback((entity: Entity): React.ReactNode => {
|
||||
if (isFolder(entity.tag)) {
|
||||
return <Folder size={14} className="entity-type-icon folder" />;
|
||||
}
|
||||
|
||||
const entityType = getEntityType(entity);
|
||||
return entityTypeIcons[entityType] || <Box size={14} className="entity-type-icon default" />;
|
||||
};
|
||||
}, [getEntityType]);
|
||||
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
@@ -443,13 +630,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
});
|
||||
};
|
||||
|
||||
const displayEntities = isShowingRemote
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
|
||||
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
|
||||
|
||||
const totalCount = displayEntities.length;
|
||||
const totalCount = isShowingRemote ? remoteEntities.length : entityStore.getAllEntities().length;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
return (
|
||||
@@ -479,13 +663,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
<div className="outliner-toolbar-right">
|
||||
{!isShowingRemote && (
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '添加' : 'Add'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateFolder}
|
||||
title={locale === 'zh' ? '创建文件夹' : 'Create Folder'}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
@@ -550,94 +743,129 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
{/* Entity List */}
|
||||
<div className="outliner-content" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{isShowingRemote
|
||||
? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game')
|
||||
: (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
|
||||
{isShowingRemote ? (
|
||||
// Remote entities view (flat list)
|
||||
remoteEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isShowingRemote ? (
|
||||
<div className="outliner-list">
|
||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleRemoteEntityClick(entity)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')}
|
||||
<span className="outliner-item-name">{entity.name}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">
|
||||
{entity.componentTypes?.[0] || 'Entity'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{/* World/Scene Root */}
|
||||
<div
|
||||
className={`outliner-item world-item ${expandedFolders.has(-1) ? 'expanded' : ''}`}
|
||||
onClick={() => toggleFolderExpand(-1)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span
|
||||
className="outliner-item-expand"
|
||||
onClick={(e) => { e.stopPropagation(); toggleFolderExpand(-1); }}
|
||||
>
|
||||
{expandedFolders.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<Mountain size={14} className="entity-type-icon world" />
|
||||
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">World</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Items */}
|
||||
{expandedFolders.has(-1) && entities.map((entity, index) => {
|
||||
const entityType = getEntityType(entity);
|
||||
return (
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{filterRemoteEntities(remoteEntities).map((entity) => (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleRemoteEntityClick(entity)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entityType)}
|
||||
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
{entityTypeIcons[entity.componentTypes?.[0] || 'Entity'] || <Box size={14} className="entity-type-icon default" />}
|
||||
<span className="outliner-item-name">{entity.name}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">
|
||||
{entity.componentTypes?.[0] || 'Entity'}
|
||||
</div>
|
||||
<div className="outliner-item-type">{entityType}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// Local entities view (hierarchical tree)
|
||||
entities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{/* World/Scene Root */}
|
||||
<div
|
||||
className={`outliner-item world-item ${expandedIds.has(-1) ? 'expanded' : ''}`}
|
||||
onClick={() => toggleExpand(-1)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span
|
||||
className="outliner-item-expand"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(-1); }}
|
||||
>
|
||||
{expandedIds.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<Mountain size={14} className="entity-type-icon world" />
|
||||
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">World</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Entity Items */}
|
||||
{flattenedEntities.map((node) => {
|
||||
const { entity, depth, hasChildren, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + depth * 16;
|
||||
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -657,6 +885,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
entityId={contextMenu.entityId}
|
||||
pluginTemplates={pluginTemplates}
|
||||
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
|
||||
onCreateFolder={() => { handleCreateFolder(); closeContextMenu(); }}
|
||||
onCreateFromTemplate={async (template) => {
|
||||
await template.create(contextMenu.entityId ?? undefined);
|
||||
closeContextMenu();
|
||||
@@ -676,6 +905,7 @@ interface ContextMenuWithSubmenuProps {
|
||||
entityId: number | null;
|
||||
pluginTemplates: EntityCreationTemplate[];
|
||||
onCreateEmpty: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
@@ -683,7 +913,7 @@ interface ContextMenuWithSubmenuProps {
|
||||
|
||||
function ContextMenuWithSubmenu({
|
||||
x, y, locale, entityId, pluginTemplates,
|
||||
onCreateEmpty, onCreateFromTemplate, onDelete
|
||||
onCreateEmpty, onCreateFolder, onCreateFromTemplate, onDelete
|
||||
}: ContextMenuWithSubmenuProps) {
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
@@ -738,6 +968,11 @@ function ContextMenuWithSubmenu({
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
|
||||
<button onClick={onCreateFolder}>
|
||||
<Folder size={12} />
|
||||
<span>{locale === 'zh' ? '创建文件夹' : 'Create Folder'}</span>
|
||||
</button>
|
||||
|
||||
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
|
||||
|
||||
{sortedCategories.map(([category, templates]) => (
|
||||
|
||||
@@ -148,9 +148,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
} else {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
if (key.startsWith('profiler.')) {
|
||||
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Initial values for profiler:',
|
||||
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
@@ -162,10 +167,24 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || '无效值');
|
||||
setErrors(newErrors);
|
||||
return; // 验证失败,不保存
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
setErrors(newErrors);
|
||||
|
||||
// 实时保存设置
|
||||
const settings = SettingsService.getInstance();
|
||||
if (!key.startsWith('project.')) {
|
||||
settings.set(key, value);
|
||||
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
||||
|
||||
// 触发设置变更事件
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: { [key]: value }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -208,6 +227,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,6 @@ interface StartupPageProps {
|
||||
onOpenProject: () => void;
|
||||
onCreateProject: () => void;
|
||||
onOpenRecentProject?: (projectPath: string) => void;
|
||||
onProfilerMode?: () => void;
|
||||
onLocaleChange?: (locale: Locale) => void;
|
||||
recentProjects?: string[];
|
||||
locale: string;
|
||||
@@ -22,7 +21,7 @@ const LANGUAGES = [
|
||||
{ code: 'zh', name: '中文' }
|
||||
];
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
@@ -62,10 +61,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create Project',
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
comingSoon: 'Coming Soon',
|
||||
updateAvailable: 'New version available',
|
||||
updateNow: 'Update Now',
|
||||
installing: 'Installing...',
|
||||
@@ -76,10 +73,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
comingSoon: '即将推出',
|
||||
updateAvailable: '发现新版本',
|
||||
updateNow: '立即更新',
|
||||
installing: '正在安装...',
|
||||
@@ -126,13 +121,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
</svg>
|
||||
<span>{t.createProject}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onProfilerMode}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.profilerMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="startup-recent">
|
||||
|
||||
@@ -8,8 +8,9 @@ import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
@@ -59,7 +60,8 @@ function generateRuntimeHtml(): string {
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'runtime-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
height: window.innerHeight,
|
||||
projectConfigUrl: '/ecs-editor.config.json'
|
||||
});
|
||||
|
||||
await runtime.initialize(esEngine);
|
||||
@@ -354,11 +356,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
|
||||
if (messageHubRef.current) {
|
||||
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
|
||||
const value = propertyName === 'position' ? transform.position :
|
||||
propertyName === 'rotation' ? transform.rotation : transform.scale;
|
||||
messageHubRef.current.publish('component:property:changed', {
|
||||
entity,
|
||||
component: transform,
|
||||
propertyName,
|
||||
value: transform[propertyName]
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -373,16 +377,29 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const rotationSpeed = 0.01;
|
||||
uiTransform.rotation += deltaX * rotationSpeed;
|
||||
} else if (mode === 'scale') {
|
||||
const width = uiTransform.width * uiTransform.scaleX;
|
||||
const height = uiTransform.height * uiTransform.scaleY;
|
||||
const centerX = uiTransform.x + width * uiTransform.pivotX;
|
||||
const centerY = uiTransform.y + height * uiTransform.pivotY;
|
||||
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
|
||||
const oldWidth = uiTransform.width * uiTransform.scaleX;
|
||||
const oldHeight = uiTransform.height * uiTransform.scaleY;
|
||||
|
||||
// pivot点的世界坐标(缩放前)
|
||||
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
|
||||
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
|
||||
|
||||
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
|
||||
|
||||
if (startDist > 0) {
|
||||
const scaleFactor = endDist / startDist;
|
||||
uiTransform.scaleX *= scaleFactor;
|
||||
uiTransform.scaleY *= scaleFactor;
|
||||
const newScaleX = uiTransform.scaleX * scaleFactor;
|
||||
const newScaleY = uiTransform.scaleY * scaleFactor;
|
||||
|
||||
const newWidth = uiTransform.width * newScaleX;
|
||||
const newHeight = uiTransform.height * newScaleY;
|
||||
|
||||
// 调整位置使pivot点保持不动
|
||||
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
|
||||
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
|
||||
uiTransform.scaleX = newScaleX;
|
||||
uiTransform.scaleY = newScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,46 +706,118 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
// Write scene data and HTML (always update)
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||
|
||||
// Copy texture assets referenced in the scene
|
||||
// 复制场景中引用的纹理资产
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const texturePathSet = new Set<string>();
|
||||
|
||||
// Find all texture paths in sprite components
|
||||
if (sceneObj.entities) {
|
||||
for (const entity of sceneObj.entities) {
|
||||
if (entity.components) {
|
||||
for (const comp of entity.components) {
|
||||
if (comp.type === 'Sprite' && comp.data?.texture) {
|
||||
texturePathSet.add(comp.data.texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy project config file (for plugin settings)
|
||||
// 复制项目配置文件(用于插件设置)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
if (projectPath) {
|
||||
const configPath = `${projectPath}\\ecs-editor.config.json`;
|
||||
const configExists = await TauriAPI.pathExists(configPath);
|
||||
if (configExists) {
|
||||
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
|
||||
console.log('[Viewport] Copied project config to runtime dir');
|
||||
}
|
||||
}
|
||||
|
||||
// Create assets directory and copy textures
|
||||
// Create assets directory
|
||||
// 创建资产目录
|
||||
const assetsDir = `${runtimeDir}\\assets`;
|
||||
const assetsDirExists = await TauriAPI.pathExists(assetsDir);
|
||||
if (!assetsDirExists) {
|
||||
await TauriAPI.createDirectory(assetsDir);
|
||||
}
|
||||
|
||||
for (const texturePath of texturePathSet) {
|
||||
if (texturePath && (texturePath.includes(':\\') || texturePath.startsWith('/'))) {
|
||||
try {
|
||||
const filename = texturePath.split(/[/\\]/).pop() || '';
|
||||
const destPath = `${assetsDir}\\${filename}`;
|
||||
const exists = await TauriAPI.pathExists(texturePath);
|
||||
if (exists) {
|
||||
await TauriAPI.copyFile(texturePath, destPath);
|
||||
// Collect all asset paths from scene
|
||||
// 从场景中收集所有资产路径
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const assetPaths = new Set<string>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy texture ${texturePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build asset catalog and copy files
|
||||
// 构建资产目录并复制文件
|
||||
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
|
||||
|
||||
try {
|
||||
const exists = await TauriAPI.pathExists(assetPath);
|
||||
if (!exists) {
|
||||
console.warn(`[Viewport] Asset not found: ${assetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get filename and determine relative path
|
||||
const filename = assetPath.split(/[/\\]/).pop() || '';
|
||||
const destPath = `${assetsDir}\\${filename}`;
|
||||
const relativePath = `assets/${filename}`;
|
||||
|
||||
// Copy file
|
||||
await TauriAPI.copyFile(assetPath, destPath);
|
||||
|
||||
// Determine asset type from extension
|
||||
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
const typeMap: Record<string, string> = {
|
||||
'.png': 'texture', '.jpg': 'texture', '.jpeg': 'texture', '.webp': 'texture',
|
||||
'.btree': 'btree',
|
||||
'.tmx': 'tilemap', '.tsx': 'tileset',
|
||||
'.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio',
|
||||
'.json': 'json'
|
||||
};
|
||||
const assetType = typeMap[ext] || 'binary';
|
||||
|
||||
// Generate simple GUID based on path
|
||||
const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
|
||||
|
||||
catalogEntries[guid] = {
|
||||
guid,
|
||||
path: relativePath,
|
||||
type: assetType,
|
||||
size: 0,
|
||||
hash: ''
|
||||
};
|
||||
|
||||
console.log(`[Viewport] Copied asset: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Write asset catalog
|
||||
// 写入资产目录
|
||||
const assetCatalog = {
|
||||
version: '1.0.0',
|
||||
createdAt: Date.now(),
|
||||
entries: catalogEntries
|
||||
};
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
||||
|
||||
const runtimeHtml = generateRuntimeHtml();
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||
|
||||
@@ -781,6 +870,19 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
if (projectService) {
|
||||
const currentProject = projectService.getCurrentProject();
|
||||
if (currentProject?.path) {
|
||||
const configPath = `${currentProject.path}\\ecs-editor.config.json`;
|
||||
const configExists = await TauriAPI.pathExists(configPath);
|
||||
if (configExists) {
|
||||
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write scene data and HTML
|
||||
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
||||
|
||||
@@ -124,7 +124,15 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
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);
|
||||
@@ -136,6 +144,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
|
||||
return () => {
|
||||
unsubEntitySelect();
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Component } from '@esengine/ecs-framework';
|
||||
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { ChevronDown, Lock, Unlock } from 'lucide-react';
|
||||
import '../../../styles/TransformInspector.css';
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { IGizmoRenderData, IRectGizmoData, GizmoColor } from '@esengine/editor-core';
|
||||
import { GizmoColors, GizmoRegistry } from '@esengine/editor-core';
|
||||
import { SpriteComponent, TransformComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
|
||||
/**
|
||||
* Gizmo provider function for SpriteComponent.
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
|
||||
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
import { EngineService } from '../../services/EngineService';
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ export const en: Translations = {
|
||||
ready: 'Editor Ready',
|
||||
failed: 'Initialization Failed',
|
||||
projectOpened: 'Project Opened',
|
||||
remoteConnected: 'Remote Game Connected',
|
||||
profilerMode: 'Profiler Mode - Waiting for connection...'
|
||||
remoteConnected: 'Remote Game Connected'
|
||||
}
|
||||
},
|
||||
hierarchy: {
|
||||
|
||||
@@ -15,8 +15,7 @@ export const zh: Translations = {
|
||||
ready: '编辑器就绪',
|
||||
failed: '初始化失败',
|
||||
projectOpened: '项目已打开',
|
||||
remoteConnected: '远程游戏已连接',
|
||||
profilerMode: '性能分析模式 - 等待连接...'
|
||||
remoteConnected: '远程游戏已连接'
|
||||
}
|
||||
},
|
||||
hierarchy: {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
|
||||
@@ -122,7 +122,7 @@ const descriptor: PluginDescriptor = {
|
||||
]
|
||||
};
|
||||
|
||||
export const EditorAppearancePlugin: IPluginLoader = {
|
||||
export const EditorAppearancePlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new EditorAppearanceEditorModule()
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import { registerSpriteGizmo } from '../../gizmos';
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ const descriptor: PluginDescriptor = {
|
||||
]
|
||||
};
|
||||
|
||||
export const GizmoPlugin: IPluginLoader = {
|
||||
export const GizmoPlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new GizmoEditorModule()
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
|
||||
const logger = createLogger('PluginConfigPlugin');
|
||||
@@ -71,7 +71,7 @@ const descriptor: PluginDescriptor = {
|
||||
]
|
||||
};
|
||||
|
||||
export const PluginConfigPlugin: IPluginLoader = {
|
||||
export const PluginConfigPlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new PluginConfigEditorModule()
|
||||
};
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
PanelDescriptor,
|
||||
MenuItemDescriptor
|
||||
} from '@esengine/editor-core';
|
||||
import { MessageHub, SettingsRegistry, PanelPosition } from '@esengine/editor-core';
|
||||
import { ProfilerDockPanel } from '../../components/ProfilerDockPanel';
|
||||
import { MessageHub, SettingsRegistry } from '@esengine/editor-core';
|
||||
import { ProfilerService } from '../../services/ProfilerService';
|
||||
|
||||
/**
|
||||
@@ -100,19 +98,6 @@ class ProfilerEditorModule implements IEditorModuleLoader {
|
||||
delete (window as any).__PROFILER_SERVICE__;
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'profiler-monitor',
|
||||
title: 'Performance Monitor',
|
||||
position: PanelPosition.Center,
|
||||
closable: false,
|
||||
component: ProfilerDockPanel,
|
||||
order: 200
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getMenuItems(): MenuItemDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -122,14 +107,6 @@ class ProfilerEditorModule implements IEditorModuleLoader {
|
||||
execute: () => {
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'window.advancedProfiler',
|
||||
label: 'Advanced Profiler',
|
||||
parentId: 'window',
|
||||
execute: () => {
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'advancedProfiler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -151,13 +128,12 @@ const descriptor: PluginDescriptor = {
|
||||
{
|
||||
name: 'ProfilerEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault',
|
||||
panels: ['profiler-monitor']
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ProfilerPlugin: IPluginLoader = {
|
||||
export const ProfilerPlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new ProfilerEditorModule()
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
|
||||
@@ -167,7 +167,7 @@ const descriptor: PluginDescriptor = {
|
||||
]
|
||||
};
|
||||
|
||||
export const ProjectSettingsPlugin: IPluginLoader = {
|
||||
export const ProjectSettingsPlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new ProjectSettingsEditorModule()
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
PanelDescriptor,
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
EntityCreationTemplate
|
||||
} from '@esengine/editor-core';
|
||||
import { PanelPosition, EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
|
||||
/**
|
||||
* Scene Inspector 编辑器模块
|
||||
@@ -186,14 +188,12 @@ const descriptor: PluginDescriptor = {
|
||||
{
|
||||
name: 'SceneInspectorEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default',
|
||||
panels: ['panel-scene-hierarchy', 'panel-entity-inspector'],
|
||||
inspectors: ['EntityInspector']
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const SceneInspectorPlugin: IPluginLoader = {
|
||||
export const SceneInspectorPlugin: IPlugin = {
|
||||
descriptor,
|
||||
editorModule: new SceneInspectorEditorModule()
|
||||
};
|
||||
|
||||
@@ -9,4 +9,5 @@ export { ProfilerPlugin } from './ProfilerPlugin';
|
||||
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
|
||||
export { PluginConfigPlugin } from './PluginConfigPlugin';
|
||||
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
|
||||
export { BlueprintPlugin } from '@esengine/blueprint/editor';
|
||||
// TODO: Re-enable when blueprint-editor package is fixed
|
||||
// export { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { EngineService } from './EngineService';
|
||||
|
||||
export class EditorEngineSync {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,10 @@ import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import * as editorRuntime from '@esengine/editor-runtime';
|
||||
import * as ecsFramework from '@esengine/ecs-framework';
|
||||
import * as behaviorTree from '@esengine/behavior-tree';
|
||||
import * as ecsComponents from '@esengine/ecs-components';
|
||||
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';
|
||||
|
||||
// 存储服务实例引用(在初始化时设置)
|
||||
let entityStoreInstance: EntityStoreService | null = null;
|
||||
@@ -29,7 +32,10 @@ const SDK_MODULES = {
|
||||
'@esengine/editor-runtime': editorRuntime,
|
||||
'@esengine/ecs-framework': ecsFramework,
|
||||
'@esengine/behavior-tree': behaviorTree,
|
||||
'@esengine/ecs-components': ecsComponents,
|
||||
'@esengine/engine-core': engineCore,
|
||||
'@esengine/sprite': sprite,
|
||||
'@esengine/camera': camera,
|
||||
'@esengine/audio': audio,
|
||||
} as const;
|
||||
|
||||
// 全局变量名称映射(用于插件构建配置)
|
||||
@@ -37,7 +43,10 @@ export const SDK_GLOBALS = {
|
||||
'@esengine/editor-runtime': '__ESENGINE__.editorRuntime',
|
||||
'@esengine/ecs-framework': '__ESENGINE__.ecsFramework',
|
||||
'@esengine/behavior-tree': '__ESENGINE__.behaviorTree',
|
||||
'@esengine/ecs-components': '__ESENGINE__.ecsComponents',
|
||||
'@esengine/engine-core': '__ESENGINE__.engineCore',
|
||||
'@esengine/sprite': '__ESENGINE__.sprite',
|
||||
'@esengine/camera': '__ESENGINE__.camera',
|
||||
'@esengine/audio': '__ESENGINE__.audio',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -62,7 +71,10 @@ interface ESEngineGlobal {
|
||||
editorRuntime: typeof editorRuntime;
|
||||
ecsFramework: typeof ecsFramework;
|
||||
behaviorTree: typeof behaviorTree;
|
||||
ecsComponents: typeof ecsComponents;
|
||||
engineCore: typeof engineCore;
|
||||
sprite: typeof sprite;
|
||||
camera: typeof camera;
|
||||
audio: typeof audio;
|
||||
require: (moduleName: string) => any;
|
||||
api: IPluginAPI;
|
||||
}
|
||||
@@ -117,7 +129,10 @@ export class PluginSDKRegistry {
|
||||
editorRuntime,
|
||||
ecsFramework,
|
||||
behaviorTree,
|
||||
ecsComponents,
|
||||
engineCore,
|
||||
sprite,
|
||||
camera,
|
||||
audio,
|
||||
require: this.requireModule.bind(this),
|
||||
api: pluginAPI,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ type AdvancedProfilerDataListener = (data: AdvancedProfilerDataPayload) => void;
|
||||
export class ProfilerService {
|
||||
private ws: WebSocket | null = null;
|
||||
private isServerRunning = false;
|
||||
private wsPort: string;
|
||||
private wsPort: number;
|
||||
private listeners: Set<ProfilerDataListener> = new Set();
|
||||
private advancedListeners: Set<AdvancedProfilerDataListener> = new Set();
|
||||
private currentData: ProfilerData | null = null;
|
||||
@@ -82,7 +82,7 @@ export class ProfilerService {
|
||||
|
||||
constructor() {
|
||||
const settings = SettingsService.getInstance();
|
||||
this.wsPort = settings.get('profiler.port', '8080');
|
||||
this.wsPort = settings.get('profiler.port', 8080);
|
||||
this.autoStart = settings.get('profiler.autoStart', true);
|
||||
|
||||
this.startServerCheck();
|
||||
@@ -97,8 +97,9 @@ export class ProfilerService {
|
||||
private listenToSettingsChanges(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort && newPort !== this.wsPort) {
|
||||
this.wsPort = newPort;
|
||||
if (newPort !== undefined && Number(newPort) !== this.wsPort) {
|
||||
console.log(`[ProfilerService] Port changed from ${this.wsPort} to ${newPort}`);
|
||||
this.wsPort = Number(newPort);
|
||||
this.reconnectWithNewPort();
|
||||
}
|
||||
}) as EventListener);
|
||||
@@ -247,8 +248,8 @@ export class ProfilerService {
|
||||
|
||||
private async startServer(): Promise<void> {
|
||||
try {
|
||||
const port = parseInt(this.wsPort);
|
||||
await invoke<string>('start_profiler_server', { port });
|
||||
console.log(`[ProfilerService] Starting server on port ${this.wsPort}`);
|
||||
await invoke<string>('start_profiler_server', { port: this.wsPort });
|
||||
this.isServerRunning = true;
|
||||
} catch (error) {
|
||||
// Ignore "already running" error - it's expected in some cases
|
||||
@@ -300,7 +301,7 @@ export class ProfilerService {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'debug_data' && message.data) {
|
||||
this.handleDebugData(message.data);
|
||||
this.handleDebugData(message.data, message.advancedProfiler);
|
||||
} else if (message.type === 'get_raw_entity_list_response' && message.data) {
|
||||
this.handleRawEntityListResponse(message.data);
|
||||
} else if (message.type === 'get_entity_details_response' && message.data) {
|
||||
@@ -338,7 +339,7 @@ export class ProfilerService {
|
||||
}
|
||||
}
|
||||
|
||||
private handleDebugData(debugData: any): void {
|
||||
private handleDebugData(debugData: any, advancedProfiler?: any): void {
|
||||
const performance = debugData.performance;
|
||||
if (!performance) return;
|
||||
|
||||
@@ -380,18 +381,25 @@ export class ProfilerService {
|
||||
|
||||
this.notifyListeners(this.currentData);
|
||||
|
||||
// 通知高级监听器原始数据
|
||||
this.lastRawData = {
|
||||
performance: debugData.performance,
|
||||
systems: {
|
||||
systemsInfo: systems.map(sys => ({
|
||||
name: sys.name,
|
||||
executionTime: sys.executionTime,
|
||||
entityCount: sys.entityCount,
|
||||
averageTime: sys.averageTime
|
||||
}))
|
||||
}
|
||||
};
|
||||
// 如果有高级性能数据,优先使用它
|
||||
if (advancedProfiler) {
|
||||
this.lastRawData = {
|
||||
advancedProfiler
|
||||
};
|
||||
} else {
|
||||
// 否则使用传统数据
|
||||
this.lastRawData = {
|
||||
performance: debugData.performance,
|
||||
systems: {
|
||||
systemsInfo: systems.map(sys => ({
|
||||
name: sys.name,
|
||||
executionTime: sys.executionTime,
|
||||
entityCount: sys.entityCount,
|
||||
averageTime: sys.averageTime
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
this.notifyAdvancedListeners(this.lastRawData);
|
||||
|
||||
// 请求完整的实体列表
|
||||
|
||||
@@ -586,9 +586,44 @@
|
||||
.profiler-table-cell.name .expand-icon {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.profiler-table-cell.name .expand-icon.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profiler-table-cell.name .expand-icon.clickable:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.profiler-table-cell.name .expand-icon.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 层级行的背景色变化 */
|
||||
.profiler-table-row.depth-1 {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.profiler-table-row.depth-2 {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.profiler-table-row.depth-3 {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.profiler-table-row.depth-4 {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.profiler-table-cell.name .category-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
.console-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.console-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.console-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.console-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
background: var(--color-bg-inset);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.console-search:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.console-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.console-search input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.console-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.console-filter-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.console-filter-btn.active {
|
||||
opacity: 1;
|
||||
border-color: currentColor;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(1) {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(1).active {
|
||||
color: #34d399;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(2) {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(2).active {
|
||||
color: #a0a0a0;
|
||||
border-color: #858585;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(3) {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(3).active {
|
||||
color: #6eb3ff;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(4) {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(4).active {
|
||||
color: #ffd54f;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(5) {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.console-filter-btn:nth-child(5).active {
|
||||
color: #ef5350;
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.console-filter-btn span {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.console-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.console-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-tertiary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.log-entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-entry-time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-entry-source {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.log-entry-source.source-remote {
|
||||
color: #4a9eff;
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-entry-client {
|
||||
color: #10b981;
|
||||
font-size: calc(var(--font-size-xs) - 2px);
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.log-entry-remote {
|
||||
border-left: 2px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
.log-entry-expander {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.log-entry-expander:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.log-entry-expanded {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.log-entry-expanded .log-entry-message {
|
||||
padding-left: 22px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-entry-message {
|
||||
flex: 1;
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.log-message-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-message-preview {
|
||||
opacity: 0.9;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-open-json-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-open-json-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.log-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.log-expand-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.log-message-json {
|
||||
margin: 4px 0 0 0;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-default);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
color: #a0e7a0;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.log-message-json::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.log-message-json::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-message-json::-webkit-scrollbar-thumb {
|
||||
background: rgba(160, 231, 160, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-message-json::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(160, 231, 160, 0.5);
|
||||
}
|
||||
|
||||
.log-entry-debug {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.log-entry-debug .log-entry-icon {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.log-entry-info .log-entry-icon {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.log-entry-warn {
|
||||
background: rgba(255, 193, 7, 0.05);
|
||||
}
|
||||
|
||||
.log-entry-warn .log-entry-icon {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.log-entry-error {
|
||||
background: rgba(244, 67, 54, 0.05);
|
||||
}
|
||||
|
||||
.log-entry-error .log-entry-icon {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.console-scroll-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 12px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: var(--z-index-above);
|
||||
}
|
||||
|
||||
.console-scroll-to-bottom:hover {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.console-scroll-to-bottom:active {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.console-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.console-btn,
|
||||
.console-filter-btn,
|
||||
.log-entry,
|
||||
.console-scroll-to-bottom {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,30 @@
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
min-width: 200px;
|
||||
z-index: var(--z-index-popover);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-menu.submenu {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
padding-right: 24px;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
background-color: #383838;
|
||||
background-color: #094771;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -32,6 +38,10 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.context-menu-item.has-submenu {
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -51,8 +61,41 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
margin-left: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) .context-menu-shortcut {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.context-menu-arrow {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) .context-menu-arrow {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background-color: #3e3e42;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Section header in submenu */
|
||||
.context-menu-section-header {
|
||||
padding: 4px 12px;
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -318,15 +318,6 @@
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* Hide timestamp/source based on settings */
|
||||
.output-log-content.hide-timestamp .output-log-entry-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.output-log-content.hide-source .output-log-entry-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.output-log-empty {
|
||||
display: flex;
|
||||
@@ -345,12 +336,7 @@
|
||||
|
||||
/* ==================== Log Entry ==================== */
|
||||
.output-log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
line-height: 1.4;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
@@ -358,6 +344,19 @@
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.output-log-entry.expanded {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.output-log-entry-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.output-log-entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -389,78 +388,69 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.output-log-entry-client {
|
||||
color: #10b981;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.output-log-entry-message {
|
||||
flex: 1;
|
||||
color: #e0e0e0;
|
||||
word-break: break-word;
|
||||
padding-top: 2px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.output-log-message-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.output-log-message-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.output-log-message-preview {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.output-log-expand-btn {
|
||||
display: inline;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
background: #3c3c3c;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #3b82f6;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.output-log-expand-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.output-log-json-btn {
|
||||
.output-log-entry-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: all 0.1s ease;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.output-log-json-btn:hover {
|
||||
.output-log-entry:hover .output-log-entry-copy {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.output-log-entry-copy:hover {
|
||||
background: #3c3c3c;
|
||||
border-color: #5a5a5a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Stack Trace (expanded) */
|
||||
.output-log-entry-stack {
|
||||
padding: 8px 12px 12px 42px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.output-log-stack-header {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.output-log-stack-line {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.output-log-stack-line:hover {
|
||||
color: #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Log Level Styles */
|
||||
.output-log-entry-debug {
|
||||
color: #858585;
|
||||
@@ -470,10 +460,18 @@
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.output-log-entry-debug .output-log-entry-message {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.output-log-entry-info .output-log-entry-icon {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.output-log-entry-info .output-log-entry-message {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.output-log-entry-warn {
|
||||
background: rgba(255, 193, 7, 0.05);
|
||||
}
|
||||
@@ -482,6 +480,10 @@
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.output-log-entry-warn .output-log-entry-message {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.output-log-entry-error {
|
||||
background: rgba(244, 67, 54, 0.08);
|
||||
}
|
||||
@@ -490,6 +492,10 @@
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.output-log-entry-error .output-log-entry-message {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.log-entry-remote {
|
||||
border-left: 2px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
|
||||
@@ -88,6 +88,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.profiler-window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profiler-window-btn {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profiler-window-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profiler-window-close {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
@@ -106,6 +130,24 @@
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Fullscreen styles */
|
||||
.profiler-window-overlay.fullscreen {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.profiler-window.fullscreen {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
max-height: none;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.profiler-window.fullscreen .profiler-window-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.profiler-window-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -243,10 +243,20 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.outliner-item.drop-target {
|
||||
/* Drop Indicators */
|
||||
.outliner-item.drop-before {
|
||||
border-top: 2px solid #4a9eff;
|
||||
}
|
||||
|
||||
.outliner-item.drop-after {
|
||||
border-bottom: 2px solid #4a9eff;
|
||||
}
|
||||
|
||||
.outliner-item.drop-inside {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px #4a9eff;
|
||||
}
|
||||
|
||||
.outliner-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -291,6 +301,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outliner-item-expand.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.outliner-item-expand.clickable:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.outliner-item-expand:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user