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:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -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 && (

View File

@@ -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
}
}

View File

@@ -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);

View File

@@ -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、SpriteAnimator组件
// 添加 Transform、SpriteAnimator 和 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);

View File

@@ -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;
// 添加TransformCamera组件
// 添加 TransformCamera 和 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);

View File

@@ -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);

View File

@@ -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;
// 添加TransformSprite组件
// 添加 TransformSprite 和 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);

View File

@@ -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);

View File

@@ -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);
// 更新引用

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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 }}>

View File

@@ -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>
);
}

View File

@@ -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') {

View 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>
);
})}

View File

@@ -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;
};

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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]) => (

View File

@@ -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
}));

View File

@@ -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">

View File

@@ -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);

View File

@@ -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();

View File

@@ -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';

View File

@@ -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.

View File

@@ -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';

View File

@@ -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: {

View File

@@ -15,8 +15,7 @@ export const zh: Translations = {
ready: '编辑器就绪',
failed: '初始化失败',
projectOpened: '项目已打开',
remoteConnected: '远程游戏已连接',
profilerMode: '性能分析模式 - 等待连接...'
remoteConnected: '远程游戏已连接'
}
},
hierarchy: {

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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';

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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);
// 请求完整的实体列表

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}