refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
import type { IFileAPI } from '@esengine/editor-core';
import { TauriAPI } from '../api/tauri';
/**
* Tauri 文件 API 适配器
*
* 实现 IFileAPI 接口,连接 editor-core 和 Tauri 后端
*/
export class TauriFileAPI implements IFileAPI {
public async openSceneDialog(): Promise<string | null> {
return await TauriAPI.openSceneDialog();
}
public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
return await TauriAPI.saveSceneDialog(defaultName, scenesDir);
}
public async readFileContent(path: string): Promise<string> {
return await TauriAPI.readFileContent(path);
}
public async saveProject(path: string, data: string): Promise<void> {
return await TauriAPI.saveProject(path, data);
}
public async exportBinary(data: Uint8Array, path: string): Promise<void> {
return await TauriAPI.exportBinary(data, path);
}
public async createDirectory(path: string): Promise<void> {
return await TauriAPI.createDirectory(path);
}
public async writeFileContent(path: string, content: string): Promise<void> {
return await TauriAPI.writeFileContent(path, content);
}
public async pathExists(path: string): Promise<boolean> {
return await TauriAPI.pathExists(path);
}
public async getFileMtime(path: string): Promise<number> {
return await TauriAPI.getFileMtime(path);
}
}
+475
View File
@@ -0,0 +1,475 @@
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
/**
* 文件过滤器定义
*/
interface FileFilter {
name: string;
extensions: string[];
}
/**
* Tauri IPC 通信层
*/
export class TauriAPI {
static async openFolderDialog(title?: string): Promise<string | null> {
return await invoke<string | null>('open_folder_dialog', { title });
}
static async openFileDialog(
title?: string,
filters?: FileFilter[],
multiple?: boolean
): Promise<string[] | null> {
return await invoke<string[] | null>('open_file_dialog', {
title,
filters,
multiple
});
}
static async saveFileDialog(
title?: string,
defaultName?: string,
filters?: FileFilter[],
defaultPath?: string
): Promise<string | null> {
return await invoke<string | null>('save_file_dialog', {
title,
defaultName,
defaultPath,
filters
});
}
static async openProjectDialog(): Promise<string | null> {
return await this.openFolderDialog('Select Project Directory');
}
static async openProject(path: string): Promise<string> {
return await invoke<string>('open_project', { path });
}
/**
* 保存项目
*/
static async saveProject(path: string, data: string): Promise<void> {
return await invoke<void>('save_project', { path, data });
}
/**
* 导出二进制数据
*/
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
return await invoke<void>('export_binary', {
data: Array.from(data),
outputPath
});
}
/**
* 扫描目录查找匹配模式的文件
*/
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
return await invoke<string[]>('scan_directory', { path, pattern });
}
/**
* 读取文件内容(文本)
*/
static async readFileContent(path: string): Promise<string> {
return await invoke<string>('read_file_content', { path });
}
/**
* 读取文件内容(二进制)
* Read file content as binary ArrayBuffer
*/
static async readFileBinary(path: string): Promise<ArrayBuffer> {
// Use Tauri read_file_as_base64 command which returns base64 encoded data
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
// Decode base64 to ArrayBuffer
// 将 base64 解码为 ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
/**
* 列出目录内容
*/
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
return await invoke<DirectoryEntry[]>('list_directory', { path });
}
/**
* 设置项目基础路径,用于 Custom Protocol
*/
static async setProjectBasePath(path: string): Promise<void> {
return await invoke<void>('set_project_base_path', { path });
}
/**
* 切换开发者工具(仅在debug模式下可用)
*/
static async toggleDevtools(): Promise<void> {
return await invoke<void>('toggle_devtools');
}
/**
* 打开保存场景对话框
* Open save scene dialog
*
* @param defaultName 默认文件名(可选)| Default file name (optional)
* @param scenesDir 场景目录路径(可选)| Scenes directory path (optional)
* @returns 用户选择的文件路径,取消则返回 null | Selected file path or null
*/
static async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
return await this.saveFileDialog(
'Save ECS Scene',
defaultName,
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
scenesDir
);
}
/**
* 打开场景文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null
*/
static async openSceneDialog(): Promise<string | null> {
const result = await this.openFileDialog(
'Open ECS Scene',
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
false
);
return result && result[0] ? result[0] : null;
}
/**
* 创建目录
* @param path 目录路径
*/
static async createDirectory(path: string): Promise<void> {
return await invoke<void>('create_directory', { path });
}
/**
* 写入文件内容
* @param path 文件路径
* @param content 文件内容
*/
static async writeFileContent(path: string, content: string): Promise<void> {
return await invoke<void>('write_file_content', { path, content });
}
/**
* 检查路径是否存在
* @param path 文件或目录路径
* @returns 路径是否存在
*/
static async pathExists(path: string): Promise<boolean> {
return await invoke<boolean>('path_exists', { path });
}
/**
* 使用系统默认程序打开文件
* @param path 文件路径
*/
static async openFileWithSystemApp(path: string): Promise<void> {
await invoke('open_file_with_default_app', { filePath: path });
}
/**
* 在文件管理器中显示文件
* @param path 文件路径
*/
static async showInFolder(path: string): Promise<void> {
await invoke('show_in_folder', { filePath: path });
}
/**
* 使用指定编辑器打开项目
* Open project with specified editor
*
* @param projectPath 项目文件夹路径 | Project folder path
* @param editorCommand 编辑器命令(如 "code", "cursor"| Editor command
* @param filePath 可选的要打开的文件路径 | Optional file path to open
*/
static async openWithEditor(
projectPath: string,
editorCommand: string,
filePath?: string
): Promise<void> {
await invoke('open_with_editor', {
projectPath,
editorCommand,
filePath: filePath || null
});
}
/**
* 打开行为树文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null
*/
static async openBehaviorTreeDialog(): Promise<string | null> {
const result = await this.openFileDialog(
'Select Behavior Tree',
[{ name: 'Behavior Tree Files', extensions: ['btree'] }],
false
);
return result && result[0] ? result[0] : null;
}
/**
* 扫描项目中的所有行为树文件
* @param projectPath 项目路径
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路 径,不含扩展名)
*/
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
return await invoke<string[]>('scan_behavior_trees', { projectPath });
}
/**
* 重命名文件或文件夹
* @param oldPath 原路径
* @param newPath 新路径
*/
static async renameFileOrFolder(oldPath: string, newPath: string): Promise<void> {
return await invoke<void>('rename_file_or_folder', { oldPath, newPath });
}
/**
* 删除文件
* @param path 文件路径
*/
static async deleteFile(path: string): Promise<void> {
return await invoke<void>('delete_file', { path });
}
/**
* 删除文件夹
* @param path 文件夹路径
*/
static async deleteFolder(path: string): Promise<void> {
return await invoke<void>('delete_folder', { path });
}
/**
* 创建文件
* @param path 文件路径
*/
static async createFile(path: string): Promise<void> {
return await invoke<void>('create_file', { path });
}
/**
* 读取文件并转换为base64
* @param path 文件路径
* @returns base64编码的文件内容
*/
static async readFileAsBase64(path: string): Promise<string> {
return await invoke<string>('read_file_as_base64', { filePath: path });
}
/**
* 复制文件
* @param src 源文件路径
* @param dst 目标文件路径
*/
static async copyFile(src: string, dst: string): Promise<void> {
return await invoke<void>('copy_file', { src, dst });
}
/**
* 获取文件修改时间
* Get file modification time
*
* @param path 文件路径 | File path
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
*/
static async getFileMtime(path: string): Promise<number> {
return await invoke<number>('get_file_mtime', { path });
}
/**
* 写入二进制文件
* @param filePath 文件路径
* @param content 二进制数据
*/
static async writeBinaryFile(filePath: string, content: Uint8Array): Promise<void> {
return await invoke<void>('write_binary_file', {
filePath,
content: Array.from(content)
});
}
/**
* 获取临时目录路径
* @returns 临时目录路径
*/
static async getTempDir(): Promise<string> {
return await invoke<string>('get_temp_dir');
}
/**
* 获取应用资源目录
* @returns 资源目录路径
*/
static async getAppResourceDir(): Promise<string> {
return await invoke<string>('get_app_resource_dir');
}
/**
* 获取当前工作目录
* @returns 当前工作目录路径
*/
static async getCurrentDir(): Promise<string> {
return await invoke<string>('get_current_dir');
}
/**
* 启动本地HTTP服务器
* @param rootPath 服务器根目录
* @param port 端口号
* @returns 服务器URL
*/
static async startLocalServer(rootPath: string, port: number): Promise<string> {
return await invoke<string>('start_local_server', { rootPath, port });
}
/**
* 停止本地HTTP服务器
*/
static async stopLocalServer(): Promise<void> {
return await invoke<void>('stop_local_server');
}
/**
* 获取本机局域网IP地址
* @returns 局域网IP地址
*/
static async getLocalIp(): Promise<string> {
return await invoke<string>('get_local_ip');
}
/**
* 生成二维码
* @param text 要编码的文本
* @returns base64编码的PNG图片
*/
static async generateQRCode(text: string): Promise<string> {
return await invoke<string>('generate_qrcode', { text });
}
/**
* 更新项目的 tsconfig.json,添加引擎类型路径
* Update project tsconfig.json with engine type paths
*
* This updates the tsconfig to point directly to engine's .d.ts files
* instead of copying them to the project.
* 这会更新 tsconfig 直接指向引擎的 .d.ts 文件,而不是复制到项目。
*
* @param projectPath 项目路径 | Project path
*/
static async updateProjectTsconfig(projectPath: string): Promise<void> {
return await invoke<void>('update_project_tsconfig', { projectPath });
}
/**
* 将本地文件路径转换为 Tauri 可访问的 asset URL
* @param filePath 本地文件路径
* @param protocol 协议类型 (默认: 'asset')
* @returns 转换后的 URL,可用于 img src、audio src 等
* @example
* const url = TauriAPI.convertFileSrc('C:\\Users\\...\\image.png');
* // 返回: 'https://asset.localhost/C:/Users/.../image.png'
*/
static convertFileSrc(filePath: string, protocol?: string): string {
return convertFileSrc(filePath, protocol);
}
/**
* 检测开发环境
* Check development environment
*
* Checks if all required tools (esbuild, etc.) are available.
* 检查所有必需的工具是否可用。
*
* @returns 环境检测结果 | Environment check result
*/
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
return await invoke<EnvironmentCheckResult>('check_environment');
}
/**
* 安装 esbuild(全局)
* Install esbuild globally via npm
*
* This command installs esbuild globally using `npm install -g esbuild`.
* 使用 `npm install -g esbuild` 全局安装 esbuild。
*
* @returns Promise that resolves when installation completes
*/
static async installEsbuild(): Promise<void> {
return await invoke<void>('install_esbuild');
}
}
/**
* 工具可用性状态
* Tool availability status
*/
export interface ToolStatus {
/** 工具是否可用 | Whether the tool is available */
available: boolean;
/** 工具版本 | Tool version */
version?: string;
/** 工具路径 | Tool path */
path?: string;
/** 工具来源: "bundled", "local", "global" | Tool source */
source?: string;
/** 错误信息 | Error message */
error?: string;
}
/**
* 环境检测结果
* Environment check result
*/
export interface EnvironmentCheckResult {
/** 所有必需工具是否可用 | Whether all required tools are available */
ready: boolean;
/** esbuild 可用性状态 | esbuild availability status */
esbuild: ToolStatus;
}
export interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
size?: number;
modified?: number;
}
/**
* 项目信息
*/
export interface ProjectInfo {
name: string;
path: string;
version: string;
}
/**
* 编辑器配置
*/
export interface EditorConfig {
theme: string;
autoSave: boolean;
recentProjects: string[];
}
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { ConfirmDialogData } from '../../services/TauriDialogService';
interface ErrorDialogData {
title: string;
message: string;
}
/**
* 外部修改对话框数据
* External modification dialog data
*/
export interface ExternalModificationDialogData {
sceneName: string;
onReload: () => void;
onOverwrite: () => void;
}
interface DialogState {
showProfiler: boolean;
showAdvancedProfiler: boolean;
showPortManager: boolean;
showSettings: boolean;
showAbout: boolean;
showPluginGenerator: boolean;
showBuildSettings: boolean;
showRenderDebug: boolean;
errorDialog: ErrorDialogData | null;
confirmDialog: ConfirmDialogData | null;
externalModificationDialog: ExternalModificationDialogData | null;
setShowProfiler: (show: boolean) => void;
setShowAdvancedProfiler: (show: boolean) => void;
setShowPortManager: (show: boolean) => void;
setShowSettings: (show: boolean) => void;
setShowAbout: (show: boolean) => void;
setShowPluginGenerator: (show: boolean) => void;
setShowBuildSettings: (show: boolean) => void;
setShowRenderDebug: (show: boolean) => void;
setErrorDialog: (data: ErrorDialogData | null) => void;
setConfirmDialog: (data: ConfirmDialogData | null) => void;
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
closeAllDialogs: () => void;
}
export const useDialogStore = create<DialogState>((set) => ({
showProfiler: false,
showAdvancedProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,
showPluginGenerator: false,
showBuildSettings: false,
showRenderDebug: false,
errorDialog: null,
confirmDialog: null,
externalModificationDialog: null,
setShowProfiler: (show) => set({ showProfiler: show }),
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
setShowPortManager: (show) => set({ showPortManager: show }),
setShowSettings: (show) => set({ showSettings: show }),
setShowAbout: (show) => set({ showAbout: show }),
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
setErrorDialog: (data) => set({ errorDialog: data }),
setConfirmDialog: (data) => set({ confirmDialog: data }),
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
closeAllDialogs: () => set({
showProfiler: false,
showAdvancedProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,
showPluginGenerator: false,
showBuildSettings: false,
showRenderDebug: false,
errorDialog: null,
confirmDialog: null,
externalModificationDialog: null
})
}));
@@ -0,0 +1,91 @@
/**
* 插件安装器
* Plugin Installer
*
* 现在所有插件都使用统一的 IPlugin 接口,无需适配器。
* Now all plugins use the unified IPlugin interface, no adapter needed.
*/
import type { PluginManager } from '@esengine/editor-core';
// 内置插件
import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
import { AssetMetaPlugin } from '../../plugins/builtin/AssetMetaPlugin';
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
import { ParticlePlugin } from '@esengine/particle-editor';
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
import { TilemapPlugin } from '@esengine/tilemap-editor';
import { FGUIPlugin } from '@esengine/fairygui-editor';
import { BlueprintPlugin } from '@esengine/blueprint-editor';
import { MaterialPlugin } from '@esengine/material-editor';
import { SpritePlugin } from '@esengine/sprite-editor';
import { ShaderEditorPlugin } from '@esengine/shader-editor';
import { Mesh3DPlugin } from '@esengine/mesh-3d-editor';
// 纯运行时插件 | Runtime-only plugins
import { CameraPlugin } from '@esengine/camera';
export class PluginInstaller {
/**
* 安装所有内置插件
*/
async installBuiltinPlugins(pluginManager: PluginManager): Promise<void> {
// 内置编辑器插件
const builtinPlugins = [
{ name: 'GizmoPlugin', plugin: GizmoPlugin },
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
{ name: 'AssetMetaPlugin', plugin: AssetMetaPlugin },
];
for (const { name, plugin } of builtinPlugins) {
if (!plugin || !plugin.manifest) {
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
continue;
}
try {
pluginManager.register(plugin);
} catch (error) {
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
}
}
// 统一模块插件(runtime + editor
const modulePlugins = [
{ name: 'CameraPlugin', plugin: CameraPlugin },
{ name: 'SpritePlugin', plugin: SpritePlugin },
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
{ name: 'Mesh3DPlugin', plugin: Mesh3DPlugin },
];
for (const { name, plugin } of modulePlugins) {
if (!plugin || !plugin.manifest) {
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
continue;
}
// 详细日志,检查 editorModule 是否存在
console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
try {
pluginManager.register(plugin);
} catch (error) {
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
}
}
}
}
@@ -0,0 +1,535 @@
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
import type { ComponentType } from '@esengine/ecs-framework';
import { invoke } from '@tauri-apps/api/core';
import {
UIRegistry,
MessageHub,
IMessageHub,
SerializerRegistry,
EntityStoreService,
EditorComponentRegistry,
ProjectService,
ComponentDiscoveryService,
PropertyMetadataService,
LogService,
SettingsRegistry,
SceneManagerService,
SceneTemplateRegistry,
FileActionRegistry,
IFileActionRegistry,
EntityCreationRegistry,
PluginManager,
IPluginManager,
InspectorRegistry,
IInspectorRegistry,
PropertyRendererRegistry,
FieldEditorRegistry,
ComponentActionRegistry,
ComponentInspectorRegistry,
IDialogService,
IFileSystemService,
CompilerRegistry,
ICompilerRegistry,
IViewportService_ID,
IPreviewSceneService,
IEditorViewportServiceIdentifier,
PreviewSceneService,
EditorViewportService,
BuildService,
WebBuildPipeline,
WeChatBuildPipeline,
moduleRegistry,
UserCodeService,
UserCodeTarget,
type HotReloadEvent
} from '@esengine/editor-core';
import { ViewportService } from '../../services/ViewportService';
import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
import { CameraComponent } from '@esengine/camera';
import { AudioSourceComponent } from '@esengine/audio';
import { FGUIComponent } from '@esengine/fairygui';
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
import { DIContainer } from '../../core/di/DIContainer';
import { TypedEventBus } from '../../core/events/TypedEventBus';
import { CommandRegistry } from '../../core/commands/CommandRegistry';
import { PanelRegistry } from '../../core/commands/PanelRegistry';
import type { EditorEventMap } from '../../core/events/EditorEventMap';
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
import { TauriDialogService } from '../../services/TauriDialogService';
import { NotificationService } from '../../services/NotificationService';
import {
StringRenderer,
NumberRenderer,
BooleanRenderer,
NullRenderer,
Vector2Renderer,
Vector3Renderer,
ColorRenderer,
ComponentRenderer,
ArrayRenderer,
FallbackRenderer
} from '../../infrastructure/property-renderers';
import {
AssetFieldEditor,
Vector2FieldEditor,
Vector3FieldEditor,
Vector4FieldEditor,
ColorFieldEditor,
AnimationClipsFieldEditor,
EntityRefFieldEditor
} from '../../infrastructure/field-editors';
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
import { buildFileSystem } from '../../services/BuildFileSystemService';
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
import { PluginSDKRegistry } from '../../services/PluginSDKRegistry';
export interface EditorServices {
uiRegistry: UIRegistry;
messageHub: MessageHub;
serializerRegistry: SerializerRegistry;
entityStore: EntityStoreService;
componentRegistry: EditorComponentRegistry;
projectService: ProjectService;
componentDiscovery: ComponentDiscoveryService;
propertyMetadata: PropertyMetadataService;
logService: LogService;
settingsRegistry: SettingsRegistry;
sceneManager: SceneManagerService;
fileActionRegistry: FileActionRegistry;
pluginManager: PluginManager;
diContainer: DIContainer;
eventBus: TypedEventBus<EditorEventMap>;
commandRegistry: CommandRegistry;
panelRegistry: PanelRegistry;
fileSystem: TauriFileSystemService;
dialog: TauriDialogService;
notification: NotificationService;
inspectorRegistry: InspectorRegistry;
propertyRendererRegistry: PropertyRendererRegistry;
fieldEditorRegistry: FieldEditorRegistry;
buildService: BuildService;
userCodeService: UserCodeService;
}
export class ServiceRegistry {
registerAllServices(coreInstance: Core): EditorServices {
const fileAPI = new TauriFileAPI();
const uiRegistry = new UIRegistry();
const messageHub = new MessageHub();
const serializerRegistry = new SerializerRegistry();
const entityStore = new EntityStoreService(messageHub);
const componentRegistry = new EditorComponentRegistry();
// 注册标准组件到编辑器和核心注册表
// Register to both editor registry (for UI) and core registry (for serialization)
const standardComponents = [
{ 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: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
{ 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' }
];
for (const comp of standardComponents) {
// Register to editor registry for UI
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
componentRegistry.register({
name: comp.editorName,
type: comp.type,
category: comp.category,
description: comp.description,
icon: comp.icon
});
}
// Enable hot reload for editor environment
// 在编辑器环境中启用热更新
GlobalComponentRegistry.enableHotReload();
const projectService = new ProjectService(messageHub, fileAPI);
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const propertyMetadata = new PropertyMetadataService();
const logService = new LogService();
const settingsRegistry = new SettingsRegistry();
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore);
const fileActionRegistry = new FileActionRegistry();
const entityCreationRegistry = new EntityCreationRegistry();
const componentActionRegistry = new ComponentActionRegistry();
const componentInspectorRegistry = new ComponentInspectorRegistry();
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(IMessageHub, messageHub); // Symbol 注册用于跨包插件访问
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
Core.services.registerInstance(EntityStoreService, entityStore);
Core.services.registerInstance(EditorComponentRegistry, componentRegistry);
Core.services.registerInstance(ProjectService, projectService);
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
Core.services.registerInstance(LogService, logService);
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
Core.services.registerInstance(SceneManagerService, sceneManager);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
// 注册预制体文件处理器 | Register prefab file handler
fileActionRegistry.registerActionHandler({
extensions: ['prefab'],
onDoubleClick: (filePath: string) => {
// 发布事件,由编辑器面板处理预制体选择/预览
// Publish event for editor panels to handle prefab selection/preview
messageHub.publish('prefab:selected', { path: filePath });
}
});
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
const pluginManager = new PluginManager();
Core.services.registerInstance(IPluginManager, pluginManager);
const diContainer = new DIContainer();
const eventBus = new TypedEventBus<EditorEventMap>();
const commandRegistry = new CommandRegistry();
const panelRegistry = new PanelRegistry();
const fileSystem = new TauriFileSystemService();
const dialog = new TauriDialogService();
const notification = new NotificationService();
Core.services.registerInstance(NotificationService, notification);
Core.services.registerInstance(IDialogService, dialog);
Core.services.registerInstance(IFileSystemService, fileSystem);
// Register viewport service for editor panels
// 注册视口服务供编辑器面板使用
const viewportService = ViewportService.getInstance();
Core.services.registerInstance(IViewportService_ID, viewportService);
// Register preview scene service for isolated preview scenes
// 注册预览场景服务,用于隔离的预览场景
const previewSceneService = PreviewSceneService.getInstance();
Core.services.registerInstance(IPreviewSceneService, previewSceneService);
// Register editor viewport service for coordinating viewports with overlays
// 注册编辑器视口服务,协调带有覆盖层的视口
const editorViewportService = EditorViewportService.getInstance();
editorViewportService.setViewportService(viewportService);
Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService);
const inspectorRegistry = new InspectorRegistry();
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
const propertyRendererRegistry = new PropertyRendererRegistry();
Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry);
propertyRendererRegistry.register(new StringRenderer());
propertyRendererRegistry.register(new NumberRenderer());
propertyRendererRegistry.register(new BooleanRenderer());
propertyRendererRegistry.register(new NullRenderer());
propertyRendererRegistry.register(new Vector2Renderer());
propertyRendererRegistry.register(new Vector3Renderer());
propertyRendererRegistry.register(new ColorRenderer());
propertyRendererRegistry.register(new ComponentRenderer());
propertyRendererRegistry.register(new ArrayRenderer());
propertyRendererRegistry.register(new FallbackRenderer());
const fieldEditorRegistry = new FieldEditorRegistry();
Core.services.registerInstance(FieldEditorRegistry, fieldEditorRegistry);
fieldEditorRegistry.register(new AssetFieldEditor());
fieldEditorRegistry.register(new Vector2FieldEditor());
fieldEditorRegistry.register(new Vector3FieldEditor());
fieldEditorRegistry.register(new Vector4FieldEditor());
fieldEditorRegistry.register(new ColorFieldEditor());
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
fieldEditorRegistry.register(new EntityRefFieldEditor());
// 注册组件检查器
// Register component inspectors
componentInspectorRegistry.register(new TransformComponentInspector());
// 注册构建服务
// Register build service
const buildService = new BuildService();
// Register Web build pipeline with file system service
// 注册 Web 构建管线并注入文件系统服务
const webPipeline = new WebBuildPipeline();
webPipeline.setFileSystem(buildFileSystem);
// Get engine modules path from Tauri backend
// 从 Tauri 后端获取引擎模块路径
invoke<string>('get_engine_modules_base_path').then(enginePath => {
console.log('[ServiceRegistry] Engine modules path:', enginePath);
webPipeline.setEngineModulesPath(enginePath);
}).catch(err => {
console.warn('[ServiceRegistry] Failed to get engine modules path:', err);
});
buildService.register(webPipeline);
// Register WeChat build pipeline
// 注册微信构建管线
const wechatPipeline = new WeChatBuildPipeline();
wechatPipeline.setFileSystem(buildFileSystem);
buildService.register(wechatPipeline);
Core.services.registerInstance(BuildService, buildService);
// Initialize ModuleRegistry with Tauri file system
// 使用 Tauri 文件系统初始化 ModuleRegistry
// Engine modules are read via Tauri commands from local file system
// 引擎模块通过 Tauri 命令从本地文件系统读取
const tauriModuleFs = new TauriModuleFileSystem();
moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => {
console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err);
});
// Initialize UserCodeService for user script compilation and loading
// 初始化 UserCodeService 用于用户脚本编译和加载
const userCodeService = new UserCodeService(fileSystem);
Core.services.registerInstance(UserCodeService, userCodeService);
// Helper function to compile and load user scripts
// 辅助函数:编译和加载用户脚本
let currentProjectPath: string | null = null;
const compileAndLoadUserScripts = async (projectPath: string) => {
// Ensure PluginSDKRegistry is initialized before loading user code
// 确保在加载用户代码之前 PluginSDKRegistry 已初始化
PluginSDKRegistry.initialize();
try {
// 1. 编译运行时脚本 | Compile runtime scripts
const runtimeResult = await userCodeService.compile({
projectPath: projectPath,
target: UserCodeTarget.Runtime
});
if (runtimeResult.success && runtimeResult.outputPath) {
const module = await userCodeService.load(runtimeResult.outputPath, UserCodeTarget.Runtime);
userCodeService.registerComponents(module, componentRegistry);
messageHub.publish('usercode:reloaded', {
projectPath,
exports: Object.keys(module.exports)
});
} else if (runtimeResult.errors.length > 0) {
console.warn('[UserCodeService] Runtime compilation errors:', runtimeResult.errors);
}
// 2. 编译编辑器脚本 | Compile editor scripts
const editorResult = await userCodeService.compile({
projectPath: projectPath,
target: UserCodeTarget.Editor
});
if (editorResult.success && editorResult.outputPath) {
const editorModule = await userCodeService.load(editorResult.outputPath, UserCodeTarget.Editor);
userCodeService.registerEditorExtensions(editorModule, componentInspectorRegistry);
messageHub.publish('usercode:editor-reloaded', {
projectPath,
exports: Object.keys(editorModule.exports)
});
} else if (editorResult.errors.length > 0) {
// 编辑器脚本编译错误只记录,不影响运行时
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
}
// 编译完成,发出就绪信号 | Compilation done, signal ready
userCodeService.signalReady();
} catch (error) {
console.error('[UserCodeService] Failed to compile/load:', error);
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
// Signal ready even on failure to avoid blocking scene loading
userCodeService.signalReady();
}
};
// Subscribe to project:opened to compile and load user scripts
// 订阅 project:opened 以编译和加载用户脚本
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
currentProjectPath = data.path;
await compileAndLoadUserScripts(data.path);
// Start watching for file changes (external editor support)
// 开始监视文件变更(支持外部编辑器)
userCodeService.watch(data.path, async (event) => {
console.log('[UserCodeService] Hot reload event:', event.changedFiles);
if (event.newModule) {
// 1. 注册新的/更新的组件到注册表
userCodeService.registerComponents(event.newModule, componentRegistry);
// 2. 热更新:更新现有实例的原型链
const updatedCount = userCodeService.hotReloadInstances(event.newModule);
console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`);
// 3. 如果正在预览,热更新用户系统
const scene = Core.scene;
if (scene && !scene.isEditorMode) {
userCodeService.hotReloadSystems(event.newModule, scene);
}
// 4. 通知用户代码已重新加载
messageHub.publish('usercode:reloaded', {
projectPath: data.path,
exports: Object.keys(event.newModule.exports),
updatedInstances: updatedCount
});
}
}).catch(err => {
console.warn('[UserCodeService] Failed to start file watcher:', err);
});
});
// Subscribe to project:closed to stop watching and cleanup
// 订阅 project:closed 以停止监视和清理
messageHub.subscribe('project:closed', async () => {
currentProjectPath = null;
await userCodeService.stopWatch();
userCodeService.unregisterEditorExtensions(componentInspectorRegistry);
});
// Subscribe to script file changes (create/delete) from editor operations
// 订阅编辑器操作的脚本文件变更(创建/删除)
// Note: file:modified is handled by the Rust file watcher for external editor support
// 注意:file:modified 由 Rust 文件监视器处理以支持外部编辑器
messageHub.subscribe('file:created', async (data: { path: string }) => {
if (currentProjectPath && this.isScriptFile(data.path)) {
await compileAndLoadUserScripts(currentProjectPath);
}
});
messageHub.subscribe('file:deleted', async (data: { path: string }) => {
if (currentProjectPath && this.isScriptFile(data.path)) {
await compileAndLoadUserScripts(currentProjectPath);
}
});
// 预览开始时注册用户系统
// Register user systems when preview starts
messageHub.subscribe('preview:start', () => {
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
if (runtimeModule) {
const scene = Core.scene;
if (scene) {
userCodeService.registerSystems(runtimeModule, scene);
}
}
});
// 预览停止时移除用户系统
// Unregister user systems when preview stops
messageHub.subscribe('preview:stop', () => {
const scene = Core.scene;
if (scene) {
userCodeService.unregisterSystems(scene);
}
});
// 注册默认场景模板 - 创建默认相机
// Register default scene template - creates default camera
this.registerDefaultSceneTemplate();
return {
uiRegistry,
messageHub,
serializerRegistry,
entityStore,
componentRegistry,
projectService,
componentDiscovery,
propertyMetadata,
logService,
settingsRegistry,
sceneManager,
fileActionRegistry,
pluginManager,
diContainer,
eventBus,
commandRegistry,
panelRegistry,
fileSystem,
dialog,
notification,
inspectorRegistry,
propertyRendererRegistry,
fieldEditorRegistry,
buildService,
userCodeService
};
}
setupRemoteLogListener(logService: LogService): void {
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
const { level, message, timestamp, clientId } = event.detail;
logService.addRemoteLog(level, message, timestamp, clientId);
}) as EventListener);
}
/**
* Check if a file path is a TypeScript script file (not in editor folder)
* 检查文件路径是否为 TypeScript 脚本文件(不在 editor 文件夹中)
*/
private isScriptFile(filePath: string): boolean {
// Must be .ts file | 必须是 .ts 文件
if (!filePath.endsWith('.ts')) {
return false;
}
// Normalize path separators | 规范化路径分隔符
const normalizedPath = filePath.replace(/\\/g, '/');
// Must be in scripts folder | 必须在 scripts 文件夹中
if (!normalizedPath.includes('/scripts/')) {
return false;
}
// Exclude editor scripts | 排除编辑器脚本
if (normalizedPath.includes('/scripts/editor/')) {
return false;
}
// Exclude .esengine folder | 排除 .esengine 文件夹
if (normalizedPath.includes('/.esengine/')) {
return false;
}
return true;
}
/**
* 注册默认场景模板
* Register default scene template with default entities
*/
private registerDefaultSceneTemplate(): void {
// 注册默认相机创建器
// Register default camera creator
SceneTemplateRegistry.registerDefaultEntity((scene) => {
// 检查是否已存在相机
// Check if camera already exists
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
if (existingCameras.length > 0) {
return null;
}
// 创建默认相机实体
// Create default camera entity
const cameraEntity = scene.createEntity('Main Camera');
cameraEntity.addComponent(new TransformComponent());
const camera = new CameraComponent();
camera.orthographicSize = 1;
cameraEntity.addComponent(camera);
return cameraEntity;
});
}
}
@@ -0,0 +1,3 @@
export * from './ServiceRegistry';
export * from './DialogManager';
export * from './PluginInstaller';
@@ -0,0 +1,25 @@
import { ICommand } from './ICommand';
/**
* 命令基类
* 提供默认实现,具体命令继承此类
*/
export abstract class BaseCommand implements ICommand {
abstract execute(): void;
abstract undo(): void;
abstract getDescription(): string;
/**
* 默认不支持合并
*/
canMergeWith(_other: ICommand): boolean {
return false;
}
/**
* 默认抛出错误
*/
mergeWith(_other: ICommand): ICommand {
throw new Error(`${this.constructor.name} 不支持合并操作`);
}
}
@@ -0,0 +1,230 @@
import { ICommand } from './ICommand';
/**
* 命令历史记录配置
*/
export interface CommandManagerConfig {
/**
* 最大历史记录数量
*/
maxHistorySize?: number;
/**
* 是否自动合并相似命令
*/
autoMerge?: boolean;
}
/**
* 命令管理器
* 管理命令的执行、撤销、重做以及历史记录
*/
export class CommandManager {
private undoStack: ICommand[] = [];
private redoStack: ICommand[] = [];
private readonly config: Required<CommandManagerConfig>;
private isExecuting = false;
constructor(config: CommandManagerConfig = {}) {
this.config = {
maxHistorySize: config.maxHistorySize ?? 100,
autoMerge: config.autoMerge ?? true
};
}
/**
* 执行命令
*/
execute(command: ICommand): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中执行新命令');
}
this.isExecuting = true;
try {
command.execute();
if (this.config.autoMerge && this.undoStack.length > 0) {
const lastCommand = this.undoStack[this.undoStack.length - 1];
if (lastCommand && lastCommand.canMergeWith(command)) {
const mergedCommand = lastCommand.mergeWith(command);
this.undoStack[this.undoStack.length - 1] = mergedCommand;
this.redoStack = [];
return;
}
}
this.undoStack.push(command);
this.redoStack = [];
if (this.undoStack.length > this.config.maxHistorySize) {
this.undoStack.shift();
}
} finally {
this.isExecuting = false;
}
}
/**
* 撤销上一个命令
*/
undo(): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中撤销');
}
const command = this.undoStack.pop();
if (!command) {
return;
}
this.isExecuting = true;
try {
command.undo();
this.redoStack.push(command);
} catch (error) {
this.undoStack.push(command);
throw error;
} finally {
this.isExecuting = false;
}
}
/**
* 重做上一个被撤销的命令
*/
redo(): void {
if (this.isExecuting) {
throw new Error('不能在命令执行过程中重做');
}
const command = this.redoStack.pop();
if (!command) {
return;
}
this.isExecuting = true;
try {
command.execute();
this.undoStack.push(command);
} catch (error) {
this.redoStack.push(command);
throw error;
} finally {
this.isExecuting = false;
}
}
/**
* 检查是否可以撤销
*/
canUndo(): boolean {
return this.undoStack.length > 0;
}
/**
* 检查是否可以重做
*/
canRedo(): boolean {
return this.redoStack.length > 0;
}
/**
* 获取撤销栈的描述列表
*/
getUndoHistory(): string[] {
return this.undoStack.map((cmd) => cmd.getDescription());
}
/**
* 获取重做栈的描述列表
*/
getRedoHistory(): string[] {
return this.redoStack.map((cmd) => cmd.getDescription());
}
/**
* 清空所有历史记录
*/
clear(): void {
this.undoStack = [];
this.redoStack = [];
}
/**
* 批量执行命令(作为单一操作,可以一次撤销)
*/
executeBatch(commands: ICommand[]): void {
if (commands.length === 0) {
return;
}
const batchCommand = new BatchCommand(commands);
this.execute(batchCommand);
}
/**
* 将命令推入撤销栈但不执行
* Push command to undo stack without executing
*
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
* Used for operations that have already been performed (like drag transforms),
* only need to record to history
*/
pushWithoutExecute(command: ICommand): void {
if (this.config.autoMerge && this.undoStack.length > 0) {
const lastCommand = this.undoStack[this.undoStack.length - 1];
if (lastCommand && lastCommand.canMergeWith(command)) {
const mergedCommand = lastCommand.mergeWith(command);
this.undoStack[this.undoStack.length - 1] = mergedCommand;
this.redoStack = [];
return;
}
}
this.undoStack.push(command);
this.redoStack = [];
if (this.undoStack.length > this.config.maxHistorySize) {
this.undoStack.shift();
}
}
}
/**
* 批量命令
* 将多个命令组合为一个命令
*/
class BatchCommand implements ICommand {
constructor(private readonly commands: ICommand[]) {}
execute(): void {
for (const command of this.commands) {
command.execute();
}
}
undo(): void {
for (let i = this.commands.length - 1; i >= 0; i--) {
const command = this.commands[i];
if (command) {
command.undo();
}
}
}
getDescription(): string {
return `批量操作 (${this.commands.length} 个命令)`;
}
canMergeWith(): boolean {
return false;
}
mergeWith(): ICommand {
throw new Error('批量命令不支持合并');
}
}
@@ -0,0 +1,31 @@
/**
* 命令接口
* 实现命令模式,支持撤销/重做功能
*/
export interface ICommand {
/**
* 执行命令
*/
execute(): void;
/**
* 撤销命令
*/
undo(): void;
/**
* 获取命令描述(用于显示历史记录)
*/
getDescription(): string;
/**
* 检查命令是否可以合并
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
*/
canMergeWith(other: ICommand): boolean;
/**
* 与另一个命令合并
*/
mergeWith(other: ICommand): ICommand;
}
@@ -0,0 +1,133 @@
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
import { MessageHub, EditorComponentRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { BaseCommand } from '../BaseCommand';
/**
* 添加组件命令
*
* 自动添加缺失的依赖组件(通过 @ECSComponent requires 选项声明)
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
*/
export class AddComponentCommand extends BaseCommand {
private component: Component | null = null;
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
private autoAddedDependencies: Component[] = [];
constructor(
private messageHub: MessageHub,
private entity: Entity,
private ComponentClass: new () => Component,
private initialData?: Record<string, unknown>
) {
super();
}
execute(): void {
// 先添加缺失的依赖组件 | Add missing dependencies first
this.addMissingDependencies();
this.component = new this.ComponentClass();
// 应用初始数据 | Apply initial data
if (this.initialData) {
for (const [key, value] of Object.entries(this.initialData)) {
(this.component as any)[key] = value;
}
}
this.entity.addComponent(this.component);
this.messageHub.publish('component:added', {
entity: this.entity,
component: this.component
});
}
/**
* 添加缺失的依赖组件
* Add missing dependency components
*/
private addMissingDependencies(): void {
const dependencies = getComponentDependencies(this.ComponentClass);
if (!dependencies || dependencies.length === 0) {
return;
}
const componentRegistry = Core.services.tryResolve(EditorComponentRegistry) as EditorComponentRegistry | null;
if (!componentRegistry) {
return;
}
for (const depName of dependencies) {
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
const depInfo = componentRegistry.getComponent(depName);
if (!depInfo?.type) {
console.warn(`Dependency component not found in registry: ${depName}`);
continue;
}
const DepClass = depInfo.type;
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
if (foundByName) {
// 组件已存在(通过名称匹配),跳过添加
// Component already exists (matched by name), skip adding
continue;
}
// 自动添加依赖组件 | Auto-add dependency component
const depComponent = new DepClass();
this.entity.addComponent(depComponent);
this.autoAddedDependencies.push(depComponent);
this.messageHub.publish('component:added', {
entity: this.entity,
component: depComponent,
isAutoDependency: true
});
}
}
undo(): void {
if (!this.component) return;
// 先移除主组件 | Remove main component first
this.entity.removeComponent(this.component);
this.messageHub.publish('component:removed', {
entity: this.entity,
componentType: getComponentTypeName(this.ComponentClass)
});
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
const dep = this.autoAddedDependencies[i];
if (dep) {
this.entity.removeComponent(dep);
this.messageHub.publish('component:removed', {
entity: this.entity,
componentType: dep.constructor.name,
isAutoDependency: true
});
}
}
this.component = null;
this.autoAddedDependencies = [];
}
getDescription(): string {
const mainName = getComponentTypeName(this.ComponentClass);
if (this.autoAddedDependencies.length > 0) {
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
}
return `添加组件: ${mainName}`;
}
}
@@ -0,0 +1,57 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 移除组件命令
*/
export class RemoveComponentCommand extends BaseCommand {
private componentData: Record<string, unknown> = {};
private ComponentClass: new () => Component;
constructor(
private messageHub: MessageHub,
private entity: Entity,
private component: Component
) {
super();
this.ComponentClass = component.constructor as new () => Component;
// 保存组件数据用于撤销
for (const key of Object.keys(component)) {
if (key !== 'entity' && key !== 'id') {
this.componentData[key] = (component as any)[key];
}
}
}
execute(): void {
this.entity.removeComponent(this.component);
this.messageHub.publish('component:removed', {
entity: this.entity,
componentType: this.ComponentClass.name
});
}
undo(): void {
const newComponent = new this.ComponentClass();
// 恢复数据
for (const [key, value] of Object.entries(this.componentData)) {
(newComponent as any)[key] = value;
}
this.entity.addComponent(newComponent);
this.component = newComponent;
this.messageHub.publish('component:added', {
entity: this.entity,
component: newComponent
});
}
getDescription(): string {
return `移除组件: ${this.ComponentClass.name}`;
}
}
@@ -0,0 +1,76 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
import { ICommand } from '../ICommand';
/**
* 更新组件属性命令
*/
export class UpdateComponentCommand extends BaseCommand {
private oldValue: unknown;
constructor(
private messageHub: MessageHub,
private entity: Entity,
private component: Component,
private propertyName: string,
private newValue: unknown
) {
super();
this.oldValue = (component as any)[propertyName];
}
execute(): void {
(this.component as any)[this.propertyName] = this.newValue;
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName: this.propertyName,
value: this.newValue
});
}
undo(): void {
(this.component as any)[this.propertyName] = this.oldValue;
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName: this.propertyName,
value: this.oldValue
});
}
getDescription(): string {
return `更新 ${this.component.constructor.name}.${this.propertyName}`;
}
canMergeWith(other: ICommand): boolean {
if (!(other instanceof UpdateComponentCommand)) return false;
return (
this.entity === other.entity &&
this.component === other.component &&
this.propertyName === other.propertyName
);
}
mergeWith(other: ICommand): ICommand {
if (!(other instanceof UpdateComponentCommand)) {
throw new Error('无法合并不同类型的命令');
}
// 保留原始值,使用新命令的新值
const merged = new UpdateComponentCommand(
this.messageHub,
this.entity,
this.component,
this.propertyName,
other.newValue
);
merged.oldValue = this.oldValue;
return merged;
}
}
@@ -0,0 +1,3 @@
export { AddComponentCommand } from './AddComponentCommand';
export { RemoveComponentCommand } from './RemoveComponentCommand';
export { UpdateComponentCommand } from './UpdateComponentCommand';
@@ -0,0 +1,67 @@
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带动画组件的Sprite实体命令
*/
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加 Transform、Sprite、Animator 和 Hierarchy 组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new SpriteComponent());
this.entity.addComponent(new SpriteAnimatorComponent());
this.entity.addComponent(new HierarchyComponent());
if (this.parentEntity) {
const hierarchySystem = scene.getSystem(HierarchySystem);
hierarchySystem?.setParent(this.entity, this.parentEntity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建动画Sprite实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}
@@ -0,0 +1,66 @@
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带Camera组件的实体命令
*/
export class CreateCameraEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加 Transform、Camera 和 Hierarchy 组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new CameraComponent());
this.entity.addComponent(new HierarchyComponent());
if (this.parentEntity) {
const hierarchySystem = scene.getSystem(HierarchySystem);
hierarchySystem?.setParent(this.entity, this.parentEntity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Camera实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}
@@ -0,0 +1,66 @@
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { BaseCommand } from '../BaseCommand';
/**
* 创建实体命令
*/
export class CreateEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 自动添加 Transform 组件
this.entity.addComponent(new TransformComponent());
// 添加 HierarchyComponent 支持层级结构
this.entity.addComponent(new HierarchyComponent());
if (this.parentEntity) {
const hierarchySystem = scene.getSystem(HierarchySystem);
hierarchySystem?.setParent(this.entity, this.parentEntity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}
@@ -0,0 +1,66 @@
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent } from '@esengine/sprite';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带Sprite组件的实体命令
*/
export class CreateSpriteEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加 Transform、Sprite 和 Hierarchy 组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new SpriteComponent());
this.entity.addComponent(new HierarchyComponent());
if (this.parentEntity) {
const hierarchySystem = scene.getSystem(HierarchySystem);
hierarchySystem?.setParent(this.entity, this.parentEntity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Sprite实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}
@@ -0,0 +1,115 @@
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { TilemapComponent } from '@esengine/tilemap';
import { BaseCommand } from '../BaseCommand';
/**
* Tilemap创建选项
*/
export interface TilemapCreationOptions {
/** 地图宽度(瓦片数),默认10 */
width?: number;
/** 地图高度(瓦片数),默认10 */
height?: number;
/** 瓦片宽度(像素),默认32 */
tileWidth?: number;
/** 瓦片高度(像素),默认32 */
tileHeight?: number;
/** 渲染层级,默认0 */
sortingOrder?: number;
/** 初始Tileset源路径 */
tilesetSource?: string;
}
/**
* 创建带Tilemap组件的实体命令
*/
export class CreateTilemapEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity,
private options: TilemapCreationOptions = {}
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加 Transform 和 Hierarchy 组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new HierarchyComponent());
// 创建并配置Tilemap组件
const tilemapComponent = new TilemapComponent();
// 应用配置选项
const {
width = 10,
height = 10,
tileWidth = 32,
tileHeight = 32,
sortingOrder = 0,
tilesetSource
} = this.options;
tilemapComponent.tileWidth = tileWidth;
tilemapComponent.tileHeight = tileHeight;
tilemapComponent.sortingOrder = sortingOrder;
// 初始化空白地图
tilemapComponent.initializeEmpty(width, height);
// 添加初始 Tileset
if (tilesetSource) {
tilemapComponent.addTileset(tilesetSource);
}
this.entity.addComponent(tilemapComponent);
if (this.parentEntity) {
const hierarchySystem = scene.getSystem(HierarchySystem);
hierarchySystem?.setParent(this.entity, this.parentEntity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
this.messageHub.publish('tilemap:created', {
entity: this.entity,
component: tilemapComponent
});
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Tilemap实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}
@@ -0,0 +1,114 @@
import { Core, Entity, Component, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 删除实体命令
*/
export class DeleteEntityCommand extends BaseCommand {
private entityId: number;
private entityName: string;
private parentEntityId: number | null;
private components: Component[] = [];
private childEntityIds: number[] = [];
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
this.entityId = entity.id;
this.entityName = entity.name;
// 通过 HierarchyComponent 获取父实体 ID
const hierarchy = entity.getComponent(HierarchyComponent);
this.parentEntityId = hierarchy?.parentId ?? null;
// 保存组件状态用于撤销
this.components = [...entity.components];
// 保存子实体 ID
this.childEntityIds = hierarchy?.childIds ? [...hierarchy.childIds] : [];
}
execute(): void {
const scene = Core.scene;
if (!scene) return;
// 先移除子实体
for (const childId of this.childEntityIds) {
const child = scene.findEntityById(childId);
if (child) {
this.entityStore.removeEntity(child);
}
}
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
}
undo(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
const hierarchySystem = scene.getSystem(HierarchySystem);
// 重新创建实体
const newEntity = scene.createEntity(this.entityName);
// 设置父实体
if (this.parentEntityId !== null && hierarchySystem) {
const parentEntity = scene.findEntityById(this.parentEntityId);
if (parentEntity) {
hierarchySystem.setParent(newEntity, parentEntity);
}
}
// 恢复组件
for (const component of this.components) {
// 创建组件副本
const ComponentClass = component.constructor as new () => Component;
const newComponent = new ComponentClass();
// 复制属性
for (const key of Object.keys(component)) {
if (key !== 'entity' && key !== 'id') {
(newComponent as any)[key] = (component as any)[key];
}
}
newEntity.addComponent(newComponent);
}
// 恢复子实体
for (const childId of this.childEntityIds) {
const child = scene.findEntityById(childId);
if (child && hierarchySystem) {
hierarchySystem.setParent(child, newEntity);
this.entityStore.addEntity(child, newEntity);
}
}
// 获取父实体
const parentEntity = this.parentEntityId !== null
? scene.findEntityById(this.parentEntityId) ?? undefined
: undefined;
this.entityStore.addEntity(newEntity, parentEntity);
this.entityStore.selectEntity(newEntity);
// 更新引用
this.entity = newEntity;
this.messageHub.publish('entity:added', { entity: newEntity });
}
getDescription(): string {
return `删除实体: ${this.entityName}`;
}
}
@@ -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;
}
}
@@ -0,0 +1,8 @@
export { CreateEntityCommand } from './CreateEntityCommand';
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
export { CreateTilemapEntityCommand } from './CreateTilemapEntityCommand';
export { DeleteEntityCommand } from './DeleteEntityCommand';
export { ReparentEntityCommand, DropPosition } from './ReparentEntityCommand';
@@ -0,0 +1,4 @@
export type { ICommand } from './ICommand';
export { BaseCommand } from './BaseCommand';
export { CommandManager } from './CommandManager';
export { TransformCommand, type TransformState, type TransformOperationType } from './transform/TransformCommand';
@@ -0,0 +1,65 @@
/**
* 应用预制体命令
* Apply prefab command
*
* 将预制体实例的修改应用到源预制体文件。
* Applies modifications from a prefab instance to the source prefab file.
*/
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 应用预制体命令
* Apply prefab command
*/
export class ApplyPrefabCommand extends BaseCommand {
private previousModifiedProperties: string[] = [];
private previousOriginalValues: Record<string, unknown> = {};
private success: boolean = false;
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
async execute(): Promise<void> {
// 保存当前状态用于撤销 | Save current state for undo
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
this.previousModifiedProperties = [...comp.modifiedProperties];
this.previousOriginalValues = { ...comp.originalValues };
}
// 执行应用操作 | Execute apply operation
this.success = await this.prefabService.applyToPrefab(this.entity);
if (!this.success) {
throw new Error('Failed to apply changes to prefab');
}
}
undo(): void {
// 恢复修改状态 | Restore modification state
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
comp.modifiedProperties = this.previousModifiedProperties;
comp.originalValues = this.previousOriginalValues;
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('component:property:changed', {
entityId: this.entity.id
});
}
getDescription(): string {
const comp = this.entity.getComponent(PrefabInstanceComponent);
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `应用修改到预制体: ${prefabName}`;
}
}
@@ -0,0 +1,128 @@
/**
* 断开预制体链接命令
* Break prefab link command
*
* 断开实体与源预制体的关联,使其成为普通实体。
* Breaks the link between an entity and its source prefab, making it a regular entity.
*/
import { Entity, PrefabInstanceComponent, Core } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 保存的预制体实例组件状态
* Saved prefab instance component state
*/
interface PrefabInstanceState {
entityId: number;
sourcePrefabGuid: string;
sourcePrefabPath: string;
isRoot: boolean;
rootInstanceEntityId: number | null;
modifiedProperties: string[];
originalValues: Record<string, unknown>;
instantiatedAt: number;
}
/**
* 断开预制体链接命令
* Break prefab link command
*/
export class BreakPrefabLinkCommand extends BaseCommand {
private removedStates: PrefabInstanceState[] = [];
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
execute(): void {
// 保存所有将被移除的组件状态 | Save all component states that will be removed
this.removedStates = [];
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (!comp) {
throw new Error('Entity is not a prefab instance');
}
// 保存根实体的状态 | Save root entity state
this.saveComponentState(this.entity);
// 如果是根节点,也保存所有子实体的状态
// If it's root, also save all children's state
if (comp.isRoot) {
const scene = Core.scene;
if (scene) {
scene.entities.forEach((e) => {
if (e.id === this.entity.id) return;
const childComp = e.getComponent(PrefabInstanceComponent);
if (childComp && childComp.rootInstanceEntityId === this.entity.id) {
this.saveComponentState(e);
}
});
}
}
// 执行断开链接操作 | Execute break link operation
this.prefabService.breakPrefabLink(this.entity);
}
undo(): void {
// 恢复所有被移除的组件 | Restore all removed components
const scene = Core.scene;
if (!scene) return;
for (const state of this.removedStates) {
const entity = scene.findEntityById(state.entityId);
if (!entity) continue;
// 创建并恢复组件 | Create and restore component
const comp = new PrefabInstanceComponent(
state.sourcePrefabGuid,
state.sourcePrefabPath,
state.isRoot
);
comp.rootInstanceEntityId = state.rootInstanceEntityId;
comp.modifiedProperties = state.modifiedProperties;
comp.originalValues = state.originalValues;
comp.instantiatedAt = state.instantiatedAt;
entity.addComponent(comp);
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('prefab:link:restored', {
entityId: this.entity.id
});
}
getDescription(): string {
const state = this.removedStates.find(s => s.entityId === this.entity.id);
const prefabName = state?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `断开预制体链接: ${prefabName}`;
}
/**
* 保存实体的预制体实例组件状态
* Save entity's prefab instance component state
*/
private saveComponentState(entity: Entity): void {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) return;
this.removedStates.push({
entityId: entity.id,
sourcePrefabGuid: comp.sourcePrefabGuid,
sourcePrefabPath: comp.sourcePrefabPath,
isRoot: comp.isRoot,
rootInstanceEntityId: comp.rootInstanceEntityId,
modifiedProperties: [...comp.modifiedProperties],
originalValues: { ...comp.originalValues },
instantiatedAt: comp.instantiatedAt
});
}
}
@@ -0,0 +1,150 @@
/**
* 创建预制体命令
* Create prefab command
*
* 从选中的实体创建预制体资产并保存到文件系统。
* Creates a prefab asset from the selected entity and saves it to the file system.
*/
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
import type { PrefabData } from '@esengine/ecs-framework';
import type { MessageHub, IFileAPI, ProjectService, AssetRegistryService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 创建预制体命令选项
* Create prefab command options
*/
export interface CreatePrefabOptions {
/** 预制体名称 | Prefab name */
name: string;
/** 保存路径(不包含文件名) | Save path (without filename) */
savePath?: string;
/** 预制体描述 | Prefab description */
description?: string;
/** 预制体标签 | Prefab tags */
tags?: string[];
/** 是否包含子实体 | Whether to include child entities */
includeChildren?: boolean;
}
/**
* 创建预制体命令
* Create prefab command
*/
export class CreatePrefabCommand extends BaseCommand {
private savedFilePath: string | null = null;
private savedGuid: string | null = null;
constructor(
private messageHub: MessageHub,
private fileAPI: IFileAPI,
private projectService: ProjectService | undefined,
private assetRegistry: AssetRegistryService | null,
private sourceEntity: Entity,
private options: CreatePrefabOptions
) {
super();
}
async execute(): Promise<void> {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化 | Scene not initialized');
}
// 获取层级系统 | Get hierarchy system
const hierarchySystem = scene.getSystem(HierarchySystem);
// 创建预制体数据 | Create prefab data
const prefabData = PrefabSerializer.createPrefab(
this.sourceEntity,
{
name: this.options.name,
description: this.options.description,
tags: this.options.tags,
includeChildren: this.options.includeChildren ?? true
},
hierarchySystem ?? undefined
);
// 序列化为 JSON | Serialize to JSON
const prefabJson = PrefabSerializer.serialize(prefabData, true);
// 确定保存路径 | Determine save path
let savePath = this.options.savePath;
if (!savePath && this.projectService?.isProjectOpen()) {
// 默认保存到项目的 prefabs 目录 | Default save to project's prefabs directory
const currentProject = this.projectService.getCurrentProject();
if (currentProject) {
const projectRoot = currentProject.path;
const sep = projectRoot.includes('\\') ? '\\' : '/';
savePath = `${projectRoot}${sep}assets${sep}prefabs`;
// 确保目录存在 | Ensure directory exists
await this.fileAPI.createDirectory(savePath);
}
}
// 构建完整文件路径 | Build complete file path
let fullPath: string | null = null;
if (savePath) {
const sep = savePath.includes('\\') ? '\\' : '/';
fullPath = `${savePath}${sep}${this.options.name}.prefab`;
} else {
// 打开保存对话框 | Open save dialog
fullPath = await this.fileAPI.saveSceneDialog(`${this.options.name}.prefab`);
}
if (!fullPath) {
throw new Error('保存被取消 | Save cancelled');
}
// 确保扩展名正确 | Ensure correct extension
if (!fullPath.endsWith('.prefab')) {
fullPath += '.prefab';
}
// 保存文件 | Save file
await this.fileAPI.writeFileContent(fullPath, prefabJson);
this.savedFilePath = fullPath;
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
if (this.assetRegistry) {
const guid = await this.assetRegistry.registerAsset(fullPath);
this.savedGuid = guid;
console.log(`[CreatePrefabCommand] Registered prefab asset with GUID: ${guid}`);
}
// 发布事件 | Publish event
await this.messageHub.publish('prefab:created', {
path: fullPath,
guid: this.savedGuid,
name: this.options.name,
sourceEntityId: this.sourceEntity.id,
sourceEntityName: this.sourceEntity.name
});
}
undo(): void {
// 预制体创建是一个文件系统操作,撤销意味着删除文件
// Prefab creation is a file system operation, undo means deleting the file
// 但为了安全,我们不自动删除文件,只是清除引用
// But for safety, we don't auto-delete the file, just clear the reference
this.savedFilePath = null;
// TODO: 如果需要完整撤销,可以实现文件删除
// TODO: If full undo is needed, implement file deletion
}
getDescription(): string {
return `创建预制体: ${this.options.name}`;
}
/**
* 获取保存的文件路径
* Get saved file path
*/
getSavedFilePath(): string | null {
return this.savedFilePath;
}
}
@@ -0,0 +1,143 @@
/**
* 实例化预制体命令
* Instantiate prefab command
*
* 从预制体资产创建实体实例。
* Creates an entity instance from a prefab asset.
*/
import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { BaseCommand } from '../BaseCommand';
/**
* 实例化预制体命令选项
* Instantiate prefab command options
*/
export interface InstantiatePrefabOptions {
/** 父实体 | Parent entity */
parent?: Entity;
/** 实例名称(可选,默认使用预制体名称) | Instance name (optional, defaults to prefab name) */
name?: string;
/** 位置覆盖 | Position override */
position?: { x: number; y: number };
/** 是否追踪为预制体实例 | Whether to track as prefab instance */
trackInstance?: boolean;
}
/**
* 实例化预制体命令
* Instantiate prefab command
*/
export class InstantiatePrefabCommand extends BaseCommand {
private createdEntity: Entity | null = null;
private createdEntityIds: number[] = [];
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private prefabData: PrefabData,
private options: InstantiatePrefabOptions = {}
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化 | Scene not initialized');
}
// 获取组件注册表 | Get component registry
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
// We need to cast it to Map<string, ComponentType>
const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
// 实例化预制体 | Instantiate prefab
this.createdEntity = PrefabSerializer.instantiate(
this.prefabData,
scene,
componentRegistry,
{
parentId: this.options.parent?.id,
name: this.options.name,
position: this.options.position,
trackInstance: this.options.trackInstance ?? true
}
);
// 收集所有创建的实体 ID(用于撤销) | Collect all created entity IDs (for undo)
this.collectEntityIds(this.createdEntity);
// 更新 EntityStore | Update EntityStore
this.entityStore.syncFromScene();
// 选中创建的实体 | Select created entity
this.entityStore.selectEntity(this.createdEntity);
// 发布事件 | Publish event
this.messageHub.publish('entity:added', { entity: this.createdEntity });
this.messageHub.publish('prefab:instantiated', {
entity: this.createdEntity,
prefabName: this.prefabData.metadata.name,
prefabGuid: this.prefabData.metadata.guid
});
}
undo(): void {
if (!this.createdEntity) return;
const scene = Core.scene;
if (!scene) return;
// 移除所有创建的实体 | Remove all created entities
for (const entityId of this.createdEntityIds) {
const entity = scene.findEntityById(entityId);
if (entity) {
scene.entities.remove(entity);
}
}
// 更新 EntityStore | Update EntityStore
this.entityStore.syncFromScene();
// 发布事件 | Publish event
this.messageHub.publish('entity:removed', { entityId: this.createdEntity.id });
this.createdEntity = null;
this.createdEntityIds = [];
}
getDescription(): string {
const name = this.options.name || this.prefabData.metadata.name;
return `实例化预制体: ${name}`;
}
/**
* 获取创建的根实体
* Get created root entity
*/
getCreatedEntity(): Entity | null {
return this.createdEntity;
}
/**
* 递归收集实体 ID
* Recursively collect entity IDs
*/
private collectEntityIds(entity: Entity): void {
this.createdEntityIds.push(entity.id);
const scene = Core.scene;
if (!scene) return;
const hierarchySystem = scene.getSystem(HierarchySystem);
if (hierarchySystem) {
const children = hierarchySystem.getChildren(entity);
for (const child of children) {
this.collectEntityIds(child);
}
}
}
}
@@ -0,0 +1,155 @@
/**
* 还原预制体实例命令
* Revert prefab instance command
*
* 将预制体实例还原为源预制体的状态。
* Reverts a prefab instance to the state of the source prefab.
*/
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
* 组件快照
* Component snapshot
*/
interface ComponentSnapshot {
typeName: string;
data: Record<string, unknown>;
}
/**
* 还原预制体实例命令
* Revert prefab instance command
*/
export class RevertPrefabCommand extends BaseCommand {
private previousSnapshots: ComponentSnapshot[] = [];
private previousModifiedProperties: string[] = [];
private previousOriginalValues: Record<string, unknown> = {};
private success: boolean = false;
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
async execute(): Promise<void> {
// 保存当前状态用于撤销 | Save current state for undo
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
this.previousModifiedProperties = [...comp.modifiedProperties];
this.previousOriginalValues = { ...comp.originalValues };
// 保存所有修改的属性当前值 | Save current values of all modified properties
this.previousSnapshots = [];
for (const key of comp.modifiedProperties) {
const [componentType, ...pathParts] = key.split('.');
const propertyPath = pathParts.join('.');
for (const compInstance of this.entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
const value = this.getNestedValue(compInstance, propertyPath);
this.previousSnapshots.push({
typeName: key,
data: { value: this.deepClone(value) }
});
break;
}
}
}
}
// 执行还原操作 | Execute revert operation
this.success = await this.prefabService.revertInstance(this.entity);
if (!this.success) {
throw new Error('Failed to revert prefab instance');
}
}
undo(): void {
// 恢复修改的属性值 | Restore modified property values
for (const snapshot of this.previousSnapshots) {
const [componentType, ...pathParts] = snapshot.typeName.split('.');
const propertyPath = pathParts.join('.');
for (const compInstance of this.entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
this.setNestedValue(compInstance, propertyPath, snapshot.data.value);
break;
}
}
}
// 恢复修改状态 | Restore modification state
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
comp.modifiedProperties = this.previousModifiedProperties;
comp.originalValues = this.previousOriginalValues;
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('component:property:changed', {
entityId: this.entity.id
});
}
getDescription(): string {
const comp = this.entity.getComponent(PrefabInstanceComponent);
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `还原预制体实例: ${prefabName}`;
}
/**
* 获取嵌套属性值
* Get nested property value
*/
private getNestedValue(obj: any, path: string): unknown {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
current = current[part];
}
return current;
}
/**
* 设置嵌套属性值
* Set nested property value
*/
private setNestedValue(obj: any, path: string, value: unknown): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]!;
if (current[key] === null || current[key] === undefined) {
current[key] = {};
}
current = current[key];
}
current[parts[parts.length - 1]!] = value;
}
/**
* 深拷贝值
* Deep clone value
*/
private deepClone(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (typeof value === 'object') {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
return value;
}
}
@@ -0,0 +1,14 @@
/**
* 预制体命令导出
* Prefab commands export
*/
export { CreatePrefabCommand } from './CreatePrefabCommand';
export type { CreatePrefabOptions } from './CreatePrefabCommand';
export { InstantiatePrefabCommand } from './InstantiatePrefabCommand';
export type { InstantiatePrefabOptions } from './InstantiatePrefabCommand';
export { ApplyPrefabCommand } from './ApplyPrefabCommand';
export { RevertPrefabCommand } from './RevertPrefabCommand';
export { BreakPrefabLinkCommand } from './BreakPrefabLinkCommand';
@@ -0,0 +1,156 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { BaseCommand } from '../BaseCommand';
import { ICommand } from '../ICommand';
/**
* Transform 状态快照
* Transform state snapshot
*/
export interface TransformState {
positionX?: number;
positionY?: number;
positionZ?: number;
rotationX?: number;
rotationY?: number;
rotationZ?: number;
scaleX?: number;
scaleY?: number;
scaleZ?: number;
}
/**
* 变换操作类型
* Transform operation type
*/
export type TransformOperationType = 'move' | 'rotate' | 'scale';
/**
* 变换命令
* Transform command for undo/redo support
*/
export class TransformCommand extends BaseCommand {
private readonly timestamp: number;
constructor(
private readonly messageHub: MessageHub,
private readonly entity: Entity,
private readonly component: TransformComponent,
private readonly operationType: TransformOperationType,
private readonly oldState: TransformState,
private newState: TransformState
) {
super();
this.timestamp = Date.now();
}
execute(): void {
this.applyState(this.newState);
this.notifyChange();
}
undo(): void {
this.applyState(this.oldState);
this.notifyChange();
}
getDescription(): string {
const opNames: Record<TransformOperationType, string> = {
move: '移动',
rotate: '旋转',
scale: '缩放'
};
return `${opNames[this.operationType]} ${this.entity.name || 'Entity'}`;
}
/**
* 检查是否可以与另一个命令合并
* 只有相同实体、相同操作类型、且在短时间内的命令可以合并
*/
canMergeWith(other: ICommand): boolean {
if (!(other instanceof TransformCommand)) return false;
// 相同实体、相同组件、相同操作类型
if (this.entity !== other.entity) return false;
if (this.component !== other.component) return false;
if (this.operationType !== other.operationType) return false;
// 时间间隔小于 500ms 才能合并(连续拖动)
const timeDiff = other.timestamp - this.timestamp;
return timeDiff < 500;
}
mergeWith(other: ICommand): ICommand {
if (!(other instanceof TransformCommand)) {
throw new Error('无法合并不同类型的命令');
}
// 保留原始 oldState,使用新命令的 newState
return new TransformCommand(
this.messageHub,
this.entity,
this.component,
this.operationType,
this.oldState,
other.newState
);
}
/**
* 应用变换状态
* Apply transform state
*/
private applyState(state: TransformState): void {
const transform = this.component;
if (state.positionX !== undefined) transform.position.x = state.positionX;
if (state.positionY !== undefined) transform.position.y = state.positionY;
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
}
/**
* 通知属性变更
* Notify property change
*/
private notifyChange(): void {
const propertyName = this.operationType === 'move'
? 'position'
: this.operationType === 'rotate'
? 'rotation'
: 'scale';
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName,
value: this.component[propertyName as keyof TransformComponent]
});
// 通知 Inspector 刷新 | Notify Inspector to refresh
this.messageHub.publish('entity:select', { entityId: this.entity.id });
}
/**
* 从 TransformComponent 捕获状态
* Capture state from TransformComponent
*/
static captureTransformState(transform: TransformComponent): TransformState {
return {
positionX: transform.position.x,
positionY: transform.position.y,
positionZ: transform.position.z,
rotationX: transform.rotation.x,
rotationY: transform.rotation.y,
rotationZ: transform.rotation.z,
scaleX: transform.scale.x,
scaleY: transform.scale.y,
scaleZ: transform.scale.z
};
}
}
@@ -0,0 +1 @@
export * from './commands';
@@ -0,0 +1,222 @@
import { useState, useEffect } from 'react';
import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-react';
import { checkForUpdates, installUpdate } from '../utils/updater';
import { getVersion } from '@tauri-apps/api/app';
import { open } from '@tauri-apps/plugin-shell';
import { MiniParticleLogo } from './MiniParticleLogo';
import { useLocale } from '../hooks/useLocale';
import '../styles/AboutDialog.css';
interface AboutDialogProps {
onClose: () => void;
}
export function AboutDialog({ onClose }: AboutDialogProps) {
const { t } = useLocale();
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error' | 'installing'>('idle');
const [version, setVersion] = useState<string>('1.0.0');
const [newVersion, setNewVersion] = useState<string>('');
useEffect(() => {
// Fetch version on mount
const fetchVersion = async () => {
try {
const currentVersion = await getVersion();
setVersion(currentVersion);
} catch (error) {
console.error('Failed to get version:', error);
}
};
fetchVersion();
}, []);
const handleCheckUpdate = async () => {
setChecking(true);
setUpdateStatus('checking');
try {
const currentVersion = await getVersion();
setVersion(currentVersion);
// 使用我们的 updater 工具检查更新(仅检查,不自动安装)
const result = await checkForUpdates();
if (result.error) {
setUpdateStatus('error');
} else if (result.available) {
setUpdateStatus('available');
if (result.version) {
setNewVersion(result.version);
}
} else {
setUpdateStatus('latest');
}
} catch (error) {
console.error('Check update failed:', error);
setUpdateStatus('error');
} finally {
setChecking(false);
}
};
const handleInstallUpdate = async () => {
setInstalling(true);
setUpdateStatus('installing');
try {
const success = await installUpdate();
if (!success) {
setUpdateStatus('error');
setInstalling(false);
}
// 如果成功,应用会重启,不需要处理
} catch (error) {
console.error('Install update failed:', error);
setUpdateStatus('error');
setInstalling(false);
}
};
const getStatusIcon = () => {
switch (updateStatus) {
case 'checking':
return <RefreshCw size={16} className="animate-spin" />;
case 'available':
return <Download size={16} className="status-available" />;
case 'installing':
return <Loader2 size={16} className="animate-spin" />;
case 'latest':
return <Check size={16} className="status-latest" />;
case 'error':
return <AlertCircle size={16} className="status-error" />;
default:
return null;
}
};
const getStatusText = () => {
switch (updateStatus) {
case 'checking':
return t('about.checking');
case 'available':
return `${t('about.updateAvailable')} (v${newVersion})`;
case 'installing':
return t('about.installing');
case 'latest':
return t('about.latest');
case 'error':
return t('about.error');
default:
return '';
}
};
const handleOpenGithub = async () => {
try {
await open('https://github.com/esengine/esengine');
} catch (error) {
console.error('Failed to open GitHub link:', error);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="about-dialog" onClick={(e) => e.stopPropagation()}>
<div className="about-header">
<h2>{t('about.title')}</h2>
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="about-content">
<div className="about-logo">
<MiniParticleLogo text="ESEngine" width={360} height={60} fontSize={42} />
</div>
<div className="about-info">
<h3>ESEngine Editor</h3>
<p className="about-version">
{t('about.version')}: Editor {version}
</p>
<p className="about-description">
{t('about.description')}
</p>
</div>
<div className="about-update">
<button
className="update-btn"
onClick={handleCheckUpdate}
disabled={checking || installing}
>
{checking ? (
<>
<RefreshCw size={16} className="animate-spin" />
<span>{t('about.checking')}</span>
</>
) : (
<>
<RefreshCw size={16} />
<span>{t('about.checkUpdate')}</span>
</>
)}
</button>
{updateStatus !== 'idle' && (
<div className={`update-status status-${updateStatus}`}>
{getStatusIcon()}
<span>{getStatusText()}</span>
</div>
)}
{updateStatus === 'available' && (
<button
className="update-btn install-btn"
onClick={handleInstallUpdate}
disabled={installing}
>
{installing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('about.installing')}</span>
</>
) : (
<>
<Download size={16} />
<span>{t('about.download')}</span>
</>
)}
</button>
)}
</div>
<div className="about-links">
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleOpenGithub();
}}
className="about-link"
>
{t('about.github')}
</a>
</div>
<div className="about-footer">
<p>{t('about.copyright')}</p>
</div>
</div>
<div className="about-actions">
<button className="btn-primary" onClick={onClose}>
{t('about.close')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,917 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Activity, Pause, Play, RefreshCw, Search, ChevronDown, ChevronUp,
ChevronRight, ArrowRight, Cpu, BarChart3, Settings
} from 'lucide-react';
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;
frameTime: number;
fps: number;
memory: {
usedHeapSize: number;
totalHeapSize: number;
heapSizeLimit: number;
utilizationPercent: number;
gcCount: number;
};
};
frameTimeHistory: Array<{
frameNumber: number;
time: number;
duration: number;
}>;
categoryStats: Array<{
category: string;
totalTime: number;
percentOfFrame: number;
sampleCount: number;
expanded?: boolean;
items: Array<{
name: string;
inclusiveTime: number;
exclusiveTime: number;
callCount: number;
percentOfCategory: number;
percentOfFrame: number;
}>;
}>;
hotspots: HotspotItem[];
callGraph: {
currentFunction: string | null;
callers: Array<{
name: string;
callCount: number;
totalTime: number;
percentOfCurrent: number;
}>;
callees: Array<{
name: string;
callCount: number;
totalTime: number;
percentOfCurrent: number;
}>;
};
longTasks: Array<{
startTime: number;
duration: number;
attribution: string[];
}>;
memoryTrend: Array<{
time: number;
usedMB: number;
totalMB: number;
gcCount: number;
}>;
summary: {
totalFrames: number;
averageFrameTime: number;
minFrameTime: number;
maxFrameTime: number;
p95FrameTime: number;
p99FrameTime: number;
currentMemoryMB: number;
peakMemoryMB: number;
gcCount: number;
longTaskCount: number;
};
}
interface ProfilerServiceInterface {
subscribeAdvanced: (listener: (data: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => void) => () => void;
isConnected: () => boolean;
requestAdvancedProfilerData?: () => void;
setProfilerSelectedFunction?: (name: string | null) => void;
}
interface AdvancedProfilerProps {
profilerService: ProfilerServiceInterface | null;
}
type SortColumn = 'name' | 'incTime' | 'incPercent' | 'excTime' | 'excPercent' | 'calls' | 'avgTime' | 'framePercent';
type SortDirection = 'asc' | 'desc';
const CATEGORY_COLORS: Record<string, string> = {
'ECS': '#3b82f6',
'Rendering': '#8b5cf6',
'Physics': '#f59e0b',
'Audio': '#ec4899',
'Network': '#14b8a6',
'Script': '#84cc16',
'Memory': '#ef4444',
'Animation': '#f97316',
'AI': '#6366f1',
'Input': '#06b6d4',
'Loading': '#a855f7',
'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(() => {
if (!profilerService) return;
const unsubscribe = profilerService.subscribeAdvanced((rawData: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => {
if (isPaused) return;
// 解析高级性能数据
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, updateHotspotHistory]);
// 当选中函数变化时,通知服务端
useEffect(() => {
if (profilerService?.setProfilerSelectedFunction) {
profilerService.setProfilerSelectedFunction(selectedFunction);
}
}, [selectedFunction, profilerService]);
// 绘制帧时间图表
useEffect(() => {
if (!canvasRef.current || !data) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 更新帧历史
if (data.currentFrame.frameTime > 0) {
frameHistoryRef.current.push({
time: Date.now(),
duration: data.currentFrame.frameTime
});
if (frameHistoryRef.current.length > 300) {
frameHistoryRef.current.shift();
}
}
drawFrameTimeGraph(ctx, canvas, frameHistoryRef.current);
}, [data]);
const drawFrameTimeGraph = useCallback((
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
history: Array<{ time: number; duration: number }>
) => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, width, height);
if (history.length < 2) return;
// 计算最大值
const maxTime = Math.max(...history.map((h) => h.duration), 33.33);
const targetLine = 16.67; // 60 FPS
// 绘制网格线
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
// 16.67ms 线 (60 FPS)
const targetY = height - (targetLine / maxTime) * height;
ctx.beginPath();
ctx.moveTo(0, targetY);
ctx.lineTo(width, targetY);
ctx.stroke();
// 33.33ms 线 (30 FPS)
const halfY = height - (33.33 / maxTime) * height;
ctx.beginPath();
ctx.moveTo(0, halfY);
ctx.lineTo(width, halfY);
ctx.stroke();
ctx.setLineDash([]);
// 绘制帧时间曲线
const stepX = width / (history.length - 1);
ctx.beginPath();
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 1.5;
history.forEach((frame, i) => {
const x = i * stepX;
const y = height - (frame.duration / maxTime) * height;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
// 如果超过阈值,改变颜色
if (frame.duration > 33.33) {
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = '#ef4444';
ctx.moveTo(x, y);
} else if (frame.duration > 16.67) {
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = '#fbbf24';
ctx.moveTo(x, y);
}
});
ctx.stroke();
// 绘制填充区域
ctx.beginPath();
ctx.fillStyle = 'rgba(74, 222, 128, 0.1)';
ctx.moveTo(0, height);
history.forEach((frame, i) => {
const x = i * stepX;
const y = height - (frame.duration / maxTime) * height;
ctx.lineTo(x, y);
});
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
}, []);
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection((d) => d === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('desc');
}
};
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const handleReset = () => {
frameHistoryRef.current = [];
setData(null);
};
const getFrameTimeClass = (frameTime: number): string => {
if (frameTime > 33.33) return 'critical';
if (frameTime > 16.67) return 'warning';
return '';
};
const formatTime = (ms: number): string => {
if (ms < 0.01) return '< 0.01';
return ms.toFixed(2);
};
const formatPercent = (percent: number): string => {
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 = (): HotspotItem[] => {
if (!data) return [];
// 先根据数据模式处理 hotspots
const processedHotspots = processHotspotsWithDataMode(data.hotspots, dataMode);
const flattened = flattenHotspots(processedHotspots);
// 扁平模式下排序
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) => {
if (sortColumn !== column) return null;
return sortDirection === 'asc' ? <ChevronUp size={10} /> : <ChevronDown size={10} />;
};
if (!profilerService) {
return (
<div className="advanced-profiler">
<div className="profiler-empty-state">
<Cpu size={48} />
<div className="profiler-empty-state-title">Profiler Service Unavailable</div>
<div className="profiler-empty-state-hint">
Connect to a running game to start profiling
</div>
</div>
</div>
);
}
return (
<div className="advanced-profiler">
{/* Top Toolbar */}
<div className="profiler-top-bar">
<div className="profiler-thread-selector">
<button className="profiler-thread-btn active">Main Thread</button>
</div>
<div className="profiler-frame-time">
<span className="profiler-frame-time-label">Frame:</span>
<span className={`profiler-frame-time-value ${getFrameTimeClass(data?.currentFrame.frameTime || 0)}`}>
{formatTime(data?.currentFrame.frameTime || 0)} ms
</span>
<span className="profiler-frame-time-label">FPS:</span>
<span className="profiler-frame-time-value">
{data?.currentFrame.fps || 0}
</span>
</div>
<div className="profiler-controls">
<button
className={`profiler-control-btn ${isPaused ? '' : 'active'}`}
onClick={() => setIsPaused(!isPaused)}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
className="profiler-control-btn"
onClick={handleReset}
title="Reset"
>
<RefreshCw size={14} />
</button>
<button className="profiler-control-btn" title="Settings">
<Settings size={14} />
</button>
</div>
</div>
<div className="profiler-main">
{/* Left Panel - Stats Groups */}
<div className="profiler-left-panel">
<div className="profiler-search-box">
<Search size={14} />
<input
type="text"
placeholder="Search stats..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="profiler-group-controls">
<select className="profiler-group-select" defaultValue="category">
<option value="category">Group by Category</option>
<option value="name">Group by Name</option>
</select>
</div>
<div className="profiler-type-filters">
<button className="profiler-type-filter hier active">Hier</button>
<button className="profiler-type-filter float">Float</button>
<button className="profiler-type-filter int">Int</button>
<button className="profiler-type-filter mem">Mem</button>
</div>
<div className="profiler-groups-list">
{data?.categoryStats.map(cat => (
<div key={cat.category}>
<div
className={`profiler-group-item ${expandedCategories.has(cat.category) ? 'selected' : ''}`}
onClick={() => toggleCategory(cat.category)}
>
<input
type="checkbox"
className="profiler-group-checkbox"
checked={expandedCategories.has(cat.category)}
onChange={() => {}}
/>
<span
className="category-dot"
style={{ background: CATEGORY_COLORS[cat.category] || '#666' }}
/>
<span className="profiler-group-name">{cat.category}</span>
<span className="profiler-group-count">({cat.sampleCount})</span>
</div>
</div>
))}
</div>
</div>
{/* Right Content */}
<div className="profiler-content">
{/* Graph View */}
<div className="profiler-graph-section">
<div className="profiler-graph-header">
<BarChart3 size={14} />
<span className="profiler-graph-title">Graph View</span>
<div className="profiler-graph-stats">
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Avg:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.averageFrameTime || 0)} ms
</span>
</div>
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Min:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.minFrameTime || 0)} ms
</span>
</div>
<div className="profiler-graph-stat">
<span className="profiler-graph-stat-label">Max:</span>
<span className="profiler-graph-stat-value">
{formatTime(data?.summary.maxFrameTime || 0)} ms
</span>
</div>
</div>
</div>
<div className="profiler-graph-canvas">
<canvas ref={canvasRef} />
<div className="profiler-graph-overlay">
<div className="profiler-graph-line" style={{ top: '50%' }}>
<span className="profiler-graph-line-label">16.67ms</span>
</div>
</div>
</div>
</div>
{/* Call Graph */}
<div className="profiler-callgraph-section">
<div className="profiler-callgraph-header">
<Activity size={14} />
<span className="profiler-graph-title">Call Graph</span>
<div className="profiler-callgraph-controls">
<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>
</select>
<div className="profiler-callgraph-view-mode">
<button
className={`profiler-callgraph-view-btn ${viewMode === 'hierarchical' ? 'active' : ''}`}
onClick={() => setViewMode('hierarchical')}
>
Hierarchical
</button>
<button
className={`profiler-callgraph-view-btn ${viewMode === 'flat' ? 'active' : ''}`}
onClick={() => setViewMode('flat')}
>
Flat
</button>
</div>
</div>
</div>
<div className="profiler-callgraph-content">
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
<ArrowRight size={10} />
Calling Functions
</div>
<div className="profiler-callgraph-list">
{data?.callGraph.callers.map((caller, i) => (
<div
key={i}
className="profiler-callgraph-item"
onClick={() => setSelectedFunction(caller.name)}
>
<span className="profiler-callgraph-item-name">{caller.name}</span>
<span className="profiler-callgraph-item-percent">
{formatPercent(caller.percentOfCurrent)}
</span>
<span className="profiler-callgraph-item-time">
{formatTime(caller.totalTime)} ms
</span>
</div>
))}
</div>
</div>
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
Current Function
</div>
<div className="profiler-callgraph-list">
{selectedFunction ? (
<div className="profiler-callgraph-item current">
<span className="profiler-callgraph-item-name">{selectedFunction}</span>
</div>
) : (
<div className="profiler-callgraph-item">
<span className="profiler-callgraph-item-name" style={{ color: '#666' }}>
Select a function from the table
</span>
</div>
)}
</div>
</div>
<div className="profiler-callgraph-column">
<div className="profiler-callgraph-column-header">
Called Functions
<ArrowRight size={10} />
</div>
<div className="profiler-callgraph-list">
{data?.callGraph.callees.map((callee, i) => (
<div
key={i}
className="profiler-callgraph-item"
onClick={() => setSelectedFunction(callee.name)}
>
<span className="profiler-callgraph-item-name">{callee.name}</span>
<span className="profiler-callgraph-item-percent">
{formatPercent(callee.percentOfCurrent)}
</span>
<span className="profiler-callgraph-item-time">
{formatTime(callee.totalTime)} ms
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Data Table */}
<div className="profiler-table-section">
<div className="profiler-table-header">
<div
className={`profiler-table-header-cell col-name ${sortColumn === 'name' ? 'sorted' : ''}`}
onClick={() => handleSort('name')}
>
Event Name {renderSortIcon('name')}
</div>
<div
className={`profiler-table-header-cell col-inc-time ${sortColumn === 'incTime' ? 'sorted' : ''}`}
onClick={() => handleSort('incTime')}
>
Inc Time (ms) {renderSortIcon('incTime')}
</div>
<div
className={`profiler-table-header-cell col-inc-percent ${sortColumn === 'incPercent' ? 'sorted' : ''}`}
onClick={() => handleSort('incPercent')}
>
Inc % {renderSortIcon('incPercent')}
</div>
<div
className={`profiler-table-header-cell col-exc-time ${sortColumn === 'excTime' ? 'sorted' : ''}`}
onClick={() => handleSort('excTime')}
>
Exc Time (ms) {renderSortIcon('excTime')}
</div>
<div
className={`profiler-table-header-cell col-exc-percent ${sortColumn === 'excPercent' ? 'sorted' : ''}`}
onClick={() => handleSort('excPercent')}
>
Exc % {renderSortIcon('excPercent')}
</div>
<div
className={`profiler-table-header-cell col-calls ${sortColumn === 'calls' ? 'sorted' : ''}`}
onClick={() => handleSort('calls')}
>
Calls {renderSortIcon('calls')}
</div>
<div
className={`profiler-table-header-cell col-avg-calls ${sortColumn === 'avgTime' ? 'sorted' : ''}`}
onClick={() => handleSort('avgTime')}
>
Avg (ms) {renderSortIcon('avgTime')}
</div>
<div
className={`profiler-table-header-cell col-frame-percent ${sortColumn === 'framePercent' ? 'sorted' : ''}`}
onClick={() => handleSort('framePercent')}
>
% of Frame {renderSortIcon('framePercent')}
</div>
</div>
<div className="profiler-table-body">
{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' }}
/>
{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>
</div>
</div>
</div>
</div>
);
}
/**
* 从传统数据构建高级性能数据
*/
function buildFromLegacyData(rawData: any): AdvancedProfilerData {
const performance = rawData.performance || {};
const systems = rawData.systems?.systemsInfo || [];
const frameTime = performance.frameTime || 0;
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
// 构建 hotspots
const hotspots: HotspotItem[] = systems.map((sys: any) => ({
name: sys.name || sys.type || 'Unknown',
category: 'ECS',
inclusiveTime: sys.executionTime || 0,
inclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
exclusiveTime: sys.executionTime || 0,
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
callCount: 1,
avgCallTime: sys.executionTime || 0,
depth: 0
}));
// 构建 categoryStats
const totalECSTime = hotspots.reduce((sum: number, h: any) => sum + h.inclusiveTime, 0);
const categoryStats = [{
category: 'ECS',
totalTime: totalECSTime,
percentOfFrame: frameTime > 0 ? (totalECSTime / frameTime) * 100 : 0,
sampleCount: hotspots.length,
items: hotspots.map((h: any) => ({
name: h.name,
inclusiveTime: h.inclusiveTime,
exclusiveTime: h.exclusiveTime,
callCount: h.callCount,
percentOfCategory: totalECSTime > 0 ? (h.inclusiveTime / totalECSTime) * 100 : 0,
percentOfFrame: h.inclusiveTimePercent
}))
}];
return {
currentFrame: {
frameNumber: 0,
frameTime,
fps,
memory: {
usedHeapSize: (performance.memoryUsage || 0) * 1024 * 1024,
totalHeapSize: 0,
heapSizeLimit: 0,
utilizationPercent: 0,
gcCount: 0
}
},
frameTimeHistory: performance.frameTimeHistory?.map((t: number, i: number) => ({
frameNumber: i,
time: Date.now() - (performance.frameTimeHistory.length - i) * 16,
duration: t
})) || [],
categoryStats,
hotspots,
callGraph: {
currentFunction: null,
callers: [],
callees: []
},
longTasks: [],
memoryTrend: [],
summary: {
totalFrames: 0,
averageFrameTime: performance.averageFrameTime || frameTime,
minFrameTime: performance.minFrameTime || frameTime,
maxFrameTime: performance.maxFrameTime || frameTime,
p95FrameTime: frameTime,
p99FrameTime: frameTime,
currentMemoryMB: performance.memoryUsage || 0,
peakMemoryMB: performance.memoryUsage || 0,
gcCount: 0,
longTaskCount: 0
}
};
}
@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from 'react';
import { X, BarChart3, Maximize2, Minimize2 } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProfilerServiceToken, type IProfilerService } from '../services/tokens';
import { AdvancedProfiler } from './AdvancedProfiler';
import '../styles/ProfilerWindow.css';
interface AdvancedProfilerWindowProps {
onClose: () => void;
}
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
const [profilerService, setProfilerService] = useState<IProfilerService | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
try {
const service = Core.pluginServices.get(ProfilerServiceToken);
if (service) {
setProfilerService(service);
}
} catch {
// Core 可能还没有初始化
}
}, []);
useEffect(() => {
if (!profilerService) return;
const checkStatus = () => {
setIsConnected(profilerService.isConnected());
};
checkStatus();
const interval = setInterval(checkStatus, 1000);
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 ${isFullscreen ? 'fullscreen' : ''}`}
onClick={isFullscreen ? undefined : onClose}
>
<div
className={`profiler-window advanced-profiler-window ${isFullscreen ? 'fullscreen' : ''}`}
onClick={(e) => e.stopPropagation()}
style={windowStyle}
>
<div className="profiler-window-header">
<div className="profiler-window-title">
<BarChart3 size={20} />
<h2>Advanced Performance Profiler</h2>
{!isConnected && (
<span className="paused-indicator" style={{ background: '#ef4444' }}>
DISCONNECTED
</span>
)}
</div>
<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 }}>
<AdvancedProfiler profilerService={profilerService} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,724 @@
/**
* Build Settings Panel.
* 构建设置面板。
*
* Provides build settings interface for managing platform builds,
* scenes, and player settings.
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
*
* 使用 Zustand store 管理状态,避免 useEffect 过多导致的重渲染问题
* Uses Zustand store for state management to avoid re-render issues from too many useEffects
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
Plus, Minus, ChevronDown, ChevronRight, Settings,
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check, FolderOpen
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
import { BuildStatus } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { useShallow } from 'zustand/react/shallow';
import {
useBuildSettingsStore,
type PlatformType,
type BuildProfile,
type BuildSettings,
} from '../stores/BuildSettingsStore';
import '../styles/BuildSettingsPanel.css';
// ==================== Types | 类型定义 ====================
// 类型定义已移至 BuildSettingsStore.ts
// Type definitions moved to BuildSettingsStore.ts
/** Platform configuration | 平台配置 */
interface PlatformConfig {
platform: PlatformType;
label: string;
icon: React.ReactNode;
available: boolean;
}
// ==================== Constants | 常量 ====================
const PLATFORMS: PlatformConfig[] = [
{ platform: 'windows', label: 'Windows', icon: <Monitor size={16} />, available: true },
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
];
// ==================== Status Key Mapping | 状态键映射 ====================
/** Map BuildStatus to translation key | 将 BuildStatus 映射到翻译键 */
const buildStatusKeys: Record<BuildStatus, string> = {
[BuildStatus.Idle]: 'buildSettings.preparing',
[BuildStatus.Preparing]: 'buildSettings.preparing',
[BuildStatus.Compiling]: 'buildSettings.compiling',
[BuildStatus.Packaging]: 'buildSettings.packaging',
[BuildStatus.Copying]: 'buildSettings.copying',
[BuildStatus.PostProcessing]: 'buildSettings.postProcessing',
[BuildStatus.Completed]: 'buildSettings.completed',
[BuildStatus.Failed]: 'buildSettings.failed',
[BuildStatus.Cancelled]: 'buildSettings.cancelled'
};
// ==================== Build Error Display Component | 构建错误显示组件 ====================
/**
* Format and display build errors in a readable way.
* 以可读的方式格式化和显示构建错误。
*/
function BuildErrorDisplay({ error }: { error: string }) {
const { t } = useLocale();
const [isExpanded, setIsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
// Extract first line as summary | 提取第一行作为摘要
const lines = error.split('\n');
const firstErrorMatch = error.match(/X \[ERROR\][^\n]*/);
const firstLine = lines[0] || '';
const matchedError = firstErrorMatch?.[0] || '';
const summary = matchedError
? matchedError.slice(0, 100) + (matchedError.length > 100 ? '...' : '')
: firstLine.slice(0, 100) + (firstLine.length > 100 ? '...' : '');
// Check if error is long (needs expansion) | 检查错误是否很长(需要展开)
const isLongError = error.length > 200 || lines.length > 3;
// Copy error to clipboard | 复制错误到剪贴板
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(error);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
return (
<div className="build-result-error">
<div className="build-error-header">
<AlertTriangle size={16} />
<span className="build-error-summary">{summary}</span>
<button
className="build-error-copy-btn"
onClick={handleCopy}
title={t('buildSettings.copyError')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
{isLongError && (
<>
<button
className="build-error-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? t('buildSettings.collapse') : t('buildSettings.showDetails')}
<ChevronDown
size={14}
className={isExpanded ? 'rotated' : ''}
/>
</button>
{isExpanded && (
<pre className="build-error-details">{error}</pre>
)}
</>
)}
</div>
);
}
// ==================== Props | 属性 ====================
interface BuildSettingsPanelProps {
projectPath?: string;
buildService?: BuildService;
sceneManager?: SceneManagerService;
projectService?: ProjectService;
/** Available scenes in the project | 项目中可用的场景列表 */
availableScenes?: string[];
onBuild?: (profile: BuildProfile, settings: BuildSettings) => void;
onClose?: () => void;
}
// ==================== Component | 组件 ====================
export function BuildSettingsPanel({
projectPath,
buildService,
sceneManager,
projectService,
availableScenes,
onBuild,
onClose
}: BuildSettingsPanelProps) {
const { t } = useLocale();
// 使用 Zustand store 替代本地状态(使用 useShallow 避免不必要的重渲染)
// Use Zustand store instead of local state (use useShallow to avoid unnecessary re-renders)
const {
profiles,
selectedPlatform,
selectedProfile,
settings,
expandedSections,
isBuilding,
buildProgress,
buildResult,
showBuildProgress,
} = useBuildSettingsStore(useShallow(state => ({
profiles: state.profiles,
selectedPlatform: state.selectedPlatform,
selectedProfile: state.selectedProfile,
settings: state.settings,
expandedSections: state.expandedSections,
isBuilding: state.isBuilding,
buildProgress: state.buildProgress,
buildResult: state.buildResult,
showBuildProgress: state.showBuildProgress,
})));
// 获取 store actions(通过 getState 获取,这些不会触发重渲染)
// Get store actions via getState (these don't trigger re-renders)
const store = useBuildSettingsStore.getState();
const {
setSelectedPlatform: handlePlatformSelect,
setSelectedProfile: handleProfileSelect,
addProfile: handleAddProfile,
updateSettings,
setSceneEnabled,
addDefine,
removeDefine: handleRemoveDefine,
toggleSection,
cancelBuild: handleCancelBuild,
closeBuildProgress: handleCloseBuildProgress,
} = store;
// 初始化 store(仅在 mount 时)
// Initialize store (only on mount)
useEffect(() => {
if (projectPath) {
useBuildSettingsStore.getState().initialize({
projectPath,
buildService,
projectService,
availableScenes,
});
}
return () => useBuildSettingsStore.getState().cleanup();
}, [projectPath]); // 只依赖 projectPath,避免频繁重初始化
// 当前平台的配置列表(使用 useMemo 避免每次重新过滤)
// Profiles for current platform (use useMemo to avoid re-filtering every time)
const platformProfiles = useMemo(
() => profiles.filter(p => p.platform === selectedPlatform),
[profiles, selectedPlatform]
);
// 构建处理 | Build handler
const handleBuild = useCallback(async () => {
if (!selectedProfile || !projectPath) return;
// Call external handler if provided
if (onBuild) {
onBuild(selectedProfile, settings);
}
// 使用 store 的构建操作 | Use store's build action
await useBuildSettingsStore.getState().startBuild();
}, [selectedProfile, projectPath, onBuild, settings]);
// 添加当前场景 | Add current scene
const handleAddScene = useCallback(() => {
if (!sceneManager) {
console.warn('SceneManagerService not available');
return;
}
const sceneState = sceneManager.getSceneState();
const currentScenePath = sceneState.currentScenePath;
if (!currentScenePath) {
console.warn('No scene is currently open');
return;
}
// 检查场景是否已在列表中 | Check if scene is already in the list
const exists = settings.scenes.some(s => s.path === currentScenePath);
if (exists) {
console.log('Scene already in list:', currentScenePath);
return;
}
// 使用 store 添加场景 | Use store to add scene
useBuildSettingsStore.getState().addScene(currentScenePath);
}, [sceneManager, settings.scenes]);
// 添加脚本定义(带 prompt| Add scripting define (with prompt)
const handleAddDefine = useCallback(() => {
const define = prompt('Enter scripting define:');
if (define) {
addDefine(define);
}
}, [addDefine]);
// 获取状态消息 | Get status message
const getStatusMessage = useCallback((status: BuildStatus): string => {
return t(buildStatusKeys[status]) || status;
}, [t]);
// Get platform config | 获取平台配置
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
return (
<div className="build-settings-panel">
{/* Header Tabs | 头部标签 */}
<div className="build-settings-header">
<div className="build-settings-tabs">
<div className="build-settings-tab active">
<Package size={14} />
{t('buildSettings.buildProfiles')}
</div>
</div>
<div className="build-settings-header-actions">
<button className="build-settings-header-btn">{t('buildSettings.playerSettings')}</button>
<button className="build-settings-header-btn">{t('buildSettings.assetImportOverrides')}</button>
</div>
</div>
{/* Add Profile Bar | 添加配置栏 */}
<div className="build-settings-add-bar">
<button className="build-settings-add-btn" onClick={handleAddProfile}>
<Plus size={14} />
{t('buildSettings.addBuildProfile')}
</button>
</div>
{/* Main Content | 主要内容 */}
<div className="build-settings-content">
{/* Left Sidebar | 左侧边栏 */}
<div className="build-settings-sidebar">
{/* Platforms Section | 平台部分 */}
<div className="build-settings-section">
<div className="build-settings-section-header">{t('buildSettings.platforms')}</div>
<div className="build-settings-platform-list">
{PLATFORMS.map(platform => {
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
return (
<div
key={platform.platform}
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
onClick={() => handlePlatformSelect(platform.platform)}
>
<span className="build-settings-platform-icon">{platform.icon}</span>
<span className="build-settings-platform-label">{platform.label}</span>
{isActive && <span className="build-settings-active-badge">{t('buildSettings.active')}</span>}
</div>
);
})}
</div>
</div>
{/* Build Profiles Section | 构建配置部分 */}
<div className="build-settings-section">
<div className="build-settings-section-header">{t('buildSettings.buildProfiles')}</div>
<div className="build-settings-profile-list">
{profiles
.filter(p => p.platform === selectedPlatform)
.map(profile => (
<div
key={profile.id}
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
onClick={() => handleProfileSelect(profile)}
>
<span className="build-settings-profile-icon">
{currentPlatformConfig?.icon}
</span>
<span className="build-settings-profile-name">{profile.name}</span>
</div>
))}
</div>
</div>
</div>
{/* Right Panel | 右侧面板 */}
<div className="build-settings-details">
{selectedProfile ? (
<>
{/* Profile Header | 配置头部 */}
<div className="build-settings-details-header">
<div className="build-settings-details-title">
<span className="build-settings-details-icon">
{currentPlatformConfig?.icon}
</span>
<div className="build-settings-details-info">
<h3>{selectedProfile.name}</h3>
<span>{currentPlatformConfig?.label}</span>
</div>
</div>
<div className="build-settings-details-actions">
<button className="build-settings-btn secondary">{t('buildSettings.switchProfile')}</button>
<button className="build-settings-btn primary" onClick={handleBuild}>
{t('buildSettings.build')}
<ChevronDown size={14} />
</button>
</div>
</div>
{/* Build Data Section | 构建数据部分 */}
<div className="build-settings-card">
<div className="build-settings-card-header">{t('buildSettings.buildData')}</div>
{/* Scene List | 场景列表 */}
<div className="build-settings-field-group">
<div
className="build-settings-field-header"
onClick={() => toggleSection('sceneList')}
>
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t('buildSettings.sceneList')}</span>
</div>
{expandedSections.sceneList && (
<div className="build-settings-field-content">
<div className="build-settings-scene-list">
{settings.scenes.length === 0 ? (
<div className="build-settings-empty-list">
{t('buildSettings.noScenesFound')}
</div>
) : (
settings.scenes.map((scene, index) => (
<div key={index} className="build-settings-scene-item">
<input
type="checkbox"
checked={scene.enabled}
onChange={e => setSceneEnabled(index, e.target.checked)}
/>
<span>{scene.path}</span>
</div>
))
)}
</div>
<div className="build-settings-field-actions">
<button className="build-settings-btn text" onClick={handleAddScene}>
{t('buildSettings.addOpenScenes')}
</button>
</div>
</div>
)}
</div>
{/* Scripting Defines | 脚本定义 */}
<div className="build-settings-field-group">
<div
className="build-settings-field-header"
onClick={() => toggleSection('scriptingDefines')}
>
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t('buildSettings.scriptingDefines')}</span>
</div>
{expandedSections.scriptingDefines && (
<div className="build-settings-field-content">
<div className="build-settings-defines-list">
{settings.scriptingDefines.length === 0 ? (
<div className="build-settings-empty-text">{t('buildSettings.listIsEmpty')}</div>
) : (
settings.scriptingDefines.map((define, index) => (
<div key={index} className="build-settings-define-item">
<span>{define}</span>
<button onClick={() => handleRemoveDefine(index)}>
<Minus size={12} />
</button>
</div>
))
)}
</div>
<div className="build-settings-list-actions">
<button onClick={handleAddDefine}><Plus size={14} /></button>
<button disabled={settings.scriptingDefines.length === 0}>
<Minus size={14} />
</button>
</div>
</div>
)}
</div>
</div>
{/* Platform Settings Section | 平台设置部分 */}
<div className="build-settings-card">
<div className="build-settings-card-header">{t('buildSettings.platformSettings')}</div>
<div className="build-settings-field-group">
<div
className="build-settings-field-header"
onClick={() => toggleSection('platformSettings')}
>
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{currentPlatformConfig?.label} {t('buildSettings.settings')}</span>
</div>
{expandedSections.platformSettings && (
<div className="build-settings-field-content">
<div className="build-settings-form">
<div className="build-settings-form-row">
<label>{t('buildSettings.developmentBuild')}</label>
<input
type="checkbox"
checked={settings.developmentBuild}
onChange={e => updateSettings({ developmentBuild: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.sourceMap')}</label>
<input
type="checkbox"
checked={settings.sourceMap}
onChange={e => updateSettings({ sourceMap: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.compressionMethod')}</label>
<select
value={settings.compressionMethod}
onChange={e => updateSettings({ compressionMethod: e.target.value as 'Default' | 'LZ4' | 'LZ4HC' })}
>
<option value="Default">Default</option>
<option value="LZ4">LZ4</option>
<option value="LZ4HC">LZ4HC</option>
</select>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.buildMode')}</label>
<div className="build-settings-toggle-group">
<select
value={settings.buildMode}
onChange={e => updateSettings({ buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file' })}
>
<option value="split-bundles">{t('buildSettings.splitBundles')}</option>
<option value="single-bundle">{t('buildSettings.singleBundle')}</option>
<option value="single-file">{t('buildSettings.singleFile')}</option>
</select>
<span className="build-settings-hint">
{settings.buildMode === 'split-bundles'
? t('buildSettings.splitBundlesHint')
: settings.buildMode === 'single-bundle'
? t('buildSettings.singleBundleHint')
: t('buildSettings.singleFileHint')}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Player Settings Overrides | 玩家设置覆盖 */}
<div className="build-settings-card">
<div className="build-settings-card-header">
{t('buildSettings.playerSettingsOverrides')}
<button className="build-settings-more-btn">
<Settings size={14} />
</button>
</div>
<div className="build-settings-field-group">
<div
className="build-settings-field-header"
onClick={() => toggleSection('playerSettings')}
>
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t('buildSettings.playerSettings')}</span>
</div>
{expandedSections.playerSettings && (
<div className="build-settings-field-content">
<div className="build-settings-form">
<div className="build-settings-form-row">
<label>{t('buildSettings.companyName')}</label>
<input
type="text"
value={settings.companyName}
onChange={e => updateSettings({ companyName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.productName')}</label>
<input
type="text"
value={settings.productName}
onChange={e => updateSettings({ productName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.version')}</label>
<input
type="text"
value={settings.version}
onChange={e => updateSettings({ version: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.defaultIcon')}</label>
<div className="build-settings-icon-picker">
<span>{t('buildSettings.none')}</span>
<span className="build-settings-icon-hint">(Texture 2D)</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</>
) : (
<div className="build-settings-no-selection">
<p>{t('buildSettings.selectPlatform')}</p>
</div>
)}
</div>
</div>
{/* Build Progress Dialog | 构建进度对话框 */}
{showBuildProgress && (
<div className="build-progress-overlay">
<div className="build-progress-dialog">
<div className="build-progress-header">
<h3>{t('buildSettings.buildInProgress')}</h3>
{!isBuilding && (
<button
className="build-progress-close"
onClick={handleCloseBuildProgress}
>
<X size={16} />
</button>
)}
</div>
<div className="build-progress-content">
{/* Status Icon | 状态图标 */}
<div className="build-progress-status-icon">
{isBuilding ? (
<Loader2 size={36} className="build-progress-spinner" />
) : buildResult?.success ? (
<CheckCircle size={40} className="build-progress-success" />
) : (
<XCircle size={40} className="build-progress-error" />
)}
</div>
{/* Status Message | 状态消息 */}
<div className="build-progress-message">
{isBuilding ? (
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
) : buildResult?.success ? (
t('buildSettings.buildSucceeded')
) : (
t('buildSettings.buildFailed')
)}
</div>
{/* Progress Bar | 进度条 */}
{isBuilding && buildProgress && (
<div className="build-progress-bar-container">
<div
className="build-progress-bar"
style={{ width: `${buildProgress.progress}%` }}
/>
<span className="build-progress-percent">
{Math.round(buildProgress.progress)}%
</span>
</div>
)}
{/* Build Result Details | 构建结果详情 */}
{!isBuilding && buildResult && (
<div className="build-result-details">
{buildResult.success && (
<>
<div className="build-result-row">
<span className="build-result-label">{t('buildSettings.outputPath')}:</span>
<span className="build-result-value">{buildResult.outputPath}</span>
</div>
<div className="build-result-row">
<span className="build-result-label">{t('buildSettings.duration')}:</span>
<span className="build-result-value">
{(buildResult.duration / 1000).toFixed(2)}s
</span>
</div>
</>
)}
{/* Error Message | 错误消息 */}
{buildResult.error && (
<BuildErrorDisplay error={buildResult.error} />
)}
{/* Warnings | 警告 */}
{buildResult.warnings.length > 0 && (
<div className="build-result-warnings">
<div className="build-result-warnings-header">
<AlertTriangle size={14} />
<span>{t('buildSettings.warnings')} ({buildResult.warnings.length})</span>
</div>
<ul className="build-result-warnings-list">
{buildResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
{/* Actions | 操作按钮 */}
<div className="build-progress-actions">
{isBuilding ? (
<button
className="build-settings-btn secondary"
onClick={handleCancelBuild}
>
{t('buildSettings.cancel')}
</button>
) : (
<>
<button
className="build-settings-btn secondary"
onClick={handleCloseBuildProgress}
>
{t('buildSettings.close')}
</button>
{buildResult?.success && buildResult.outputPath && (
<button
className="build-settings-btn primary"
onClick={() => {
// 使用 Tauri 打开文件夹
// Use Tauri to open folder
invoke('open_folder', { path: buildResult.outputPath }).catch(e => {
console.error('Failed to open folder:', e);
});
}}
>
<FolderOpen size={14} />
{t('buildSettings.openFolder')}
</button>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
export default BuildSettingsPanel;
@@ -0,0 +1,63 @@
/**
* Build Settings Window.
* 构建设置窗口。
*
* A modal window that displays the build settings panel.
* 显示构建设置面板的模态窗口。
*/
import { X } from 'lucide-react';
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
import { BuildSettingsPanel } from './BuildSettingsPanel';
import { useLocale } from '../hooks/useLocale';
import '../styles/BuildSettingsWindow.css';
interface BuildSettingsWindowProps {
projectPath?: string;
buildService?: BuildService;
sceneManager?: SceneManagerService;
projectService?: ProjectService;
/** Available scenes in the project | 项目中可用的场景列表 */
availableScenes?: string[];
onClose: () => void;
}
export function BuildSettingsWindow({
projectPath,
buildService,
sceneManager,
projectService,
availableScenes,
onClose
}: BuildSettingsWindowProps) {
const { t } = useLocale();
return (
<div className="build-settings-window-overlay">
<div className="build-settings-window">
<div className="build-settings-window-header">
<h2>{t('build.settingsTitle')}</h2>
<button
className="build-settings-window-close"
onClick={onClose}
title="Close"
>
<X size={18} />
</button>
</div>
<div className="build-settings-window-content">
<BuildSettingsPanel
projectPath={projectPath}
buildService={buildService}
sceneManager={sceneManager}
projectService={projectService}
availableScenes={availableScenes}
onClose={onClose}
/>
</div>
</div>
</div>
);
}
export default BuildSettingsWindow;
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react';
import { X, Cpu } from 'lucide-react';
import { ICompiler, CompileResult, CompilerContext } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import '../styles/CompileDialog.css';
interface CompileDialogProps<TOptions = unknown> {
isOpen: boolean;
onClose: () => void;
compiler: ICompiler<TOptions>;
context: CompilerContext;
initialOptions?: TOptions;
}
export function CompileDialog<TOptions = unknown>({
isOpen,
onClose,
compiler,
context,
initialOptions
}: CompileDialogProps<TOptions>) {
const { t } = useLocale();
const [options, setOptions] = useState<TOptions>(initialOptions as TOptions);
const [isCompiling, setIsCompiling] = useState(false);
const [result, setResult] = useState<CompileResult | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && initialOptions) {
setOptions(initialOptions);
setResult(null);
setValidationError(null);
}
}, [isOpen, initialOptions]);
useEffect(() => {
if (compiler.validateOptions && options) {
const error = compiler.validateOptions(options);
setValidationError(error);
}
}, [options, compiler]);
if (!isOpen) return null;
const handleCompile = async () => {
if (validationError) {
return;
}
setIsCompiling(true);
setResult(null);
try {
const compileResult = await compiler.compile(options, context);
setResult(compileResult);
} catch (error) {
setResult({
success: false,
message: `${t('compileDialog.compileFailed')}: ${error}`,
errors: [String(error)]
});
} finally {
setIsCompiling(false);
}
};
return (
<div className="compile-dialog-overlay">
<div className="compile-dialog">
<div className="compile-dialog-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cpu size={20} />
<h3>{compiler.name}</h3>
</div>
<button onClick={onClose} className="compile-dialog-close">
<X size={20} />
</button>
</div>
<div className="compile-dialog-content">
{compiler.description && (
<div className="compile-dialog-description">
{compiler.description}
</div>
)}
{compiler.createConfigUI && compiler.createConfigUI(setOptions, context)}
{validationError && (
<div className="compile-dialog-error">
{validationError}
</div>
)}
{result && (
<div className={`compile-dialog-result ${result.success ? 'success' : 'error'}`}>
<div className="compile-dialog-result-message">
{result.message}
</div>
{result.outputFiles && result.outputFiles.length > 0 && (
<div className="compile-dialog-output-files">
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{t('compileDialog.outputFiles')}:</div>
{result.outputFiles.map((file, index) => (
<div key={index} className="compile-dialog-output-file">
{file}
</div>
))}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="compile-dialog-errors">
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{t('compileDialog.errors')}:</div>
{result.errors.map((error, index) => (
<div key={index} className="compile-dialog-error-item">
{error}
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="compile-dialog-footer">
<button
onClick={onClose}
className="compile-dialog-btn compile-dialog-btn-cancel"
disabled={isCompiling}
>
{t('compileDialog.close')}
</button>
<button
onClick={handleCompile}
className="compile-dialog-btn compile-dialog-btn-primary"
disabled={isCompiling || !!validationError}
>
{isCompiling ? t('compileDialog.compiling') : t('compileDialog.compile')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Core, IService, ServiceType } from '@esengine/ecs-framework';
import { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
import { X, Play, Loader2 } from 'lucide-react';
import { open as tauriOpen, save as tauriSave, message as tauriMessage, confirm as tauriConfirm } from '@tauri-apps/plugin-dialog';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { useLocale } from '../hooks/useLocale';
import '../styles/CompilerConfigDialog.css';
interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
}
interface CompilerConfigDialogProps {
isOpen: boolean;
compilerId: string;
projectPath: string | null;
currentFileName?: string;
onClose: () => void;
onCompileComplete?: (result: CompileResult) => void;
}
export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
isOpen,
compilerId,
projectPath,
currentFileName,
onClose,
onCompileComplete
}) => {
const { t } = useLocale();
const [compiler, setCompiler] = useState<ICompiler | null>(null);
const [options, setOptions] = useState<unknown>(null);
const [isCompiling, setIsCompiling] = useState(false);
const [compileResult, setCompileResult] = useState<CompileResult | null>(null);
const optionsRef = useRef<unknown>(null);
useEffect(() => {
if (isOpen && compilerId) {
try {
const registry = Core.services.resolve(CompilerRegistry);
const comp = registry.get(compilerId);
setCompiler(comp || null);
} catch (error) {
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);
setCompiler(null);
}
}
}, [isOpen, compilerId]);
const handleOptionsChange = useCallback((newOptions: unknown) => {
optionsRef.current = newOptions;
setOptions(newOptions);
}, []);
const createFileSystem = (): IFileSystem => ({
readFile: async (path: string) => {
return await invoke<string>('read_file_content', { path });
},
writeFile: async (path: string, content: string) => {
await invoke('write_file_content', { path, content });
},
writeBinary: async (path: string, data: Uint8Array) => {
await invoke('write_binary_file', { filePath: path, content: Array.from(data) });
},
exists: async (path: string) => {
return await invoke<boolean>('path_exists', { path });
},
createDirectory: async (path: string) => {
await invoke('create_directory', { path });
},
listDirectory: async (path: string): Promise<FileEntry[]> => {
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
return entries.map((e) => ({
name: e.name,
path: e.path,
isDirectory: e.is_dir
}));
},
deleteFile: async (path: string) => {
await invoke('delete_file', { path });
},
deleteDirectory: async (path: string) => {
await invoke('delete_folder', { path });
},
scanFiles: async (dir: string, pattern: string) => {
// Check if directory exists, create if not
const dirExists = await invoke<boolean>('path_exists', { path: dir });
if (!dirExists) {
await invoke('create_directory', { path: dir });
return []; // New directory has no files
}
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
const ext = pattern.replace(/\*/g, '');
return entries
.filter((e) => !e.is_dir && e.name.endsWith(ext))
.map((e) => e.name.replace(ext, ''));
},
convertToAssetUrl: (filePath: string) => {
return convertFileSrc(filePath);
},
dispose: () => {}
});
const createDialog = (): IDialog => ({
openDialog: async (opts) => {
const result = await tauriOpen({
directory: opts.directory,
multiple: opts.multiple,
title: opts.title,
defaultPath: opts.defaultPath
});
return result;
},
saveDialog: async (opts) => {
const result = await tauriSave({
title: opts.title,
defaultPath: opts.defaultPath,
filters: opts.filters
});
return result;
},
showMessage: async (title: string, message: string, type?: 'info' | 'warning' | 'error') => {
await tauriMessage(message, { title, kind: type || 'info' });
},
showConfirm: async (title: string, message: string) => {
return await tauriConfirm(message, { title });
},
dispose: () => {}
});
const createContext = (): CompilerContext => ({
projectPath,
moduleContext: {
fileSystem: createFileSystem(),
dialog: createDialog()
},
getService: <T extends IService>(serviceClass: ServiceType<T>): T | undefined => {
try {
return Core.services.resolve(serviceClass);
} catch {
return undefined;
}
}
});
const handleCompile = async () => {
if (!compiler || !optionsRef.current) return;
setIsCompiling(true);
setCompileResult(null);
try {
const context = createContext();
const result = await compiler.compile(optionsRef.current, context);
setCompileResult(result);
onCompileComplete?.(result);
if (result.success) {
setTimeout(() => {
onClose();
}, 2000);
}
} catch (error) {
setCompileResult({
success: false,
message: t('compilerConfig.compileFailed', { error: String(error) }),
errors: [String(error)]
});
} finally {
setIsCompiling(false);
}
};
if (!isOpen) return null;
const context = createContext();
return (
<div className="compiler-dialog-overlay">
<div className="compiler-dialog">
<div className="compiler-dialog-header">
<h3>{compiler?.name || t('compilerConfig.title')}</h3>
<button className="close-button" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="compiler-dialog-body">
{compiler?.createConfigUI ? (
compiler.createConfigUI(handleOptionsChange, context)
) : (
<div className="no-config">
{compiler ? t('compilerConfig.noConfigUI') : t('compilerConfig.compilerNotFound')}
</div>
)}
</div>
{compileResult && (
<div className={`compile-result ${compileResult.success ? 'success' : 'error'}`}>
<div className="result-message">{compileResult.message}</div>
{compileResult.outputFiles && compileResult.outputFiles.length > 0 && (
<div className="output-files">
{t('compilerConfig.generatedFiles', { count: compileResult.outputFiles.length })}
</div>
)}
{compileResult.errors && compileResult.errors.length > 0 && (
<div className="error-list">
{compileResult.errors.map((err, i) => (
<div key={i} className="error-item">{err}</div>
))}
</div>
)}
</div>
)}
<div className="compiler-dialog-footer">
<button
className="cancel-button"
onClick={onClose}
disabled={isCompiling}
>
{t('compilerConfig.cancel')}
</button>
<button
className="compile-button"
onClick={handleCompile}
disabled={isCompiling || !compiler || !options}
>
{isCompiling ? (
<>
<Loader2 size={16} className="spinning" />
{t('compilerConfig.compiling')}
</>
) : (
<>
<Play size={16} />
{t('compilerConfig.compile')}
</>
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { X } from 'lucide-react';
import '../styles/ConfirmDialog.css';
interface ConfirmDialogProps {
title: string;
message: string;
confirmText: string;
cancelText: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ title, message, confirmText, cancelText, onConfirm, onCancel }: ConfirmDialogProps) {
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onCancel}>
<X size={16} />
</button>
</div>
<div className="confirm-dialog-content">
<p>{message}</p>
</div>
<div className="confirm-dialog-footer">
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
{cancelText}
</button>
<button className="confirm-dialog-btn confirm" onClick={onConfirm}>
{confirmText}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,373 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import '../styles/ContextMenu.css';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
separator?: boolean;
/** 快捷键提示文本 | Shortcut hint text */
shortcut?: string;
/** 子菜单项 | Submenu items */
children?: ContextMenuItem[];
}
interface ContextMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
onClose: () => void;
}
interface SubMenuProps {
items: ContextMenuItem[];
parentRect: DOMRect;
onClose: () => void;
level: number;
}
/**
* 计算子菜单位置,处理屏幕边界
* Calculate submenu position, handle screen boundaries
*/
function calculateSubmenuPosition(
parentRect: DOMRect,
menuWidth: number,
menuHeight: number
): { x: number; y: number; flipHorizontal: boolean } {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 10;
let x = parentRect.right;
let y = parentRect.top;
let flipHorizontal = false;
// 检查右侧空间是否足够 | Check if there's enough space on the right
if (x + menuWidth > viewportWidth - padding) {
// 尝试显示在左侧 | Try to show on the left side
const leftPosition = parentRect.left - menuWidth;
if (leftPosition >= padding) {
x = leftPosition;
flipHorizontal = true;
} else {
// 两侧都不够,选择空间更大的一侧 | Neither side has enough space, choose the larger one
if (parentRect.left > viewportWidth - parentRect.right) {
x = padding;
flipHorizontal = true;
} else {
x = viewportWidth - menuWidth - padding;
}
}
}
// 检查底部空间是否足够 | Check if there's enough space at the bottom
if (y + menuHeight > viewportHeight - padding) {
y = Math.max(padding, viewportHeight - menuHeight - padding);
}
// 确保不超出顶部 | Ensure it doesn't go above the top
if (y < padding) {
y = padding;
}
return { x, y, flipHorizontal };
}
/**
* 子菜单组件
* SubMenu component
*/
function SubMenu({ items, parentRect, onClose, level }: SubMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算位置 | Calculate position
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const { x, y } = calculateSubmenuPosition(parentRect, rect.width, rect.height);
setPosition({ x, y });
}
}, [parentRect]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSubmenuRect(itemRect);
} else {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = position
? { left: `${position.x}px`, top: `${position.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu submenu"
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{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' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{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}
level={level + 1}
/>
)}
</div>
);
})}
</div>
);
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算调整后的位置 | Calculate adjusted position
useEffect(() => {
const adjustPosition = () => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const STATUS_BAR_HEIGHT = 28;
const TITLE_BAR_HEIGHT = 32;
const padding = 10;
let x = position.x;
let y = position.y;
// 检查右边界 | Check right boundary
if (x + rect.width > viewportWidth - padding) {
x = Math.max(padding, viewportWidth - rect.width - padding);
}
// 检查下边界 | Check bottom boundary
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - padding) {
y = Math.max(TITLE_BAR_HEIGHT + padding, viewportHeight - STATUS_BAR_HEIGHT - rect.height - padding);
}
// 确保不超出左边界 | Ensure not beyond left boundary
if (x < padding) {
x = padding;
}
// 确保不超出上边界 | Ensure not beyond top boundary
if (y < TITLE_BAR_HEIGHT + padding) {
y = TITLE_BAR_HEIGHT + padding;
}
setAdjustedPosition({ x, y });
}
};
adjustPosition();
const rafId = requestAnimationFrame(adjustPosition);
return () => cancelAnimationFrame(rafId);
}, [position]);
// 点击外部关闭 | Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
// 使用 mousedown 而不是 click,以便更快响应
// Use mousedown instead of click for faster response
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSubmenuRect(itemRect);
} else {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = adjustedPosition
? { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu"
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{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' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{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}
level={1}
/>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,390 @@
/**
* EditorViewport Component
* 编辑器视口组件
*
* A reusable viewport component for editor panels that need engine rendering.
* Supports camera controls, overlays, and preview scenes.
*
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
* 支持相机控制、覆盖层和预览场景。
*/
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
import { ViewportService } from '../services/ViewportService';
import '../styles/EditorViewport.css';
/**
* EditorViewport configuration
* 编辑器视口配置
*/
export interface EditorViewportConfig {
/** Unique viewport identifier | 唯一视口标识符 */
viewportId: string;
/** Initial camera config | 初始相机配置 */
initialCamera?: ViewportCameraConfig;
/** Whether to show grid | 是否显示网格 */
showGrid?: boolean;
/** Whether to show gizmos | 是否显示辅助线 */
showGizmos?: boolean;
/** Background clear color | 背景清除颜色 */
clearColor?: { r: number; g: number; b: number; a: number };
/** Min zoom level | 最小缩放级别 */
minZoom?: number;
/** Max zoom level | 最大缩放级别 */
maxZoom?: number;
/** Enable camera pan | 启用相机平移 */
enablePan?: boolean;
/** Enable camera zoom | 启用相机缩放 */
enableZoom?: boolean;
}
/**
* EditorViewport props
* 编辑器视口属性
*/
export interface EditorViewportProps extends EditorViewportConfig {
/** Class name for styling | 样式类名 */
className?: string;
/** Called when camera changes | 相机变化时的回调 */
onCameraChange?: (camera: ViewportCameraConfig) => void;
/** Called when viewport is ready | 视口准备就绪时的回调 */
onReady?: () => void;
/** Called on mouse down | 鼠标按下时的回调 */
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse move | 鼠标移动时的回调 */
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse up | 鼠标抬起时的回调 */
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse wheel | 鼠标滚轮时的回调 */
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
/** Render custom overlays | 渲染自定义覆盖层 */
renderOverlays?: () => React.ReactNode;
}
/**
* EditorViewport handle for imperative access
* 编辑器视口句柄,用于命令式访问
*/
export interface EditorViewportHandle {
/** Get current camera | 获取当前相机 */
getCamera(): ViewportCameraConfig;
/** Set camera | 设置相机 */
setCamera(camera: ViewportCameraConfig): void;
/** Reset camera to initial state | 重置相机到初始状态 */
resetCamera(): void;
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
/** Get canvas element | 获取画布元素 */
getCanvas(): HTMLCanvasElement | null;
/** Request render | 请求渲染 */
requestRender(): void;
}
/**
* EditorViewport Component
* 编辑器视口组件
*/
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
{
viewportId,
initialCamera = { x: 0, y: 0, zoom: 1 },
showGrid = true,
showGizmos = false,
clearColor,
minZoom = 0.1,
maxZoom = 10,
enablePan = true,
enableZoom = true,
className,
onCameraChange,
onReady,
onMouseDown,
onMouseMove,
onMouseUp,
onWheel,
renderOverlays
},
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Camera state
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
const cameraRef = useRef(camera);
// Drag state
const isDraggingRef = useRef(false);
const lastMousePosRef = useRef({ x: 0, y: 0 });
// Keep camera ref in sync
useEffect(() => {
cameraRef.current = camera;
}, [camera]);
// Screen to world conversion
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Convert to canvas pixel coordinates
const canvasX = (screenX - rect.left) * dpr;
const canvasY = (screenY - rect.top) * dpr;
// Convert to centered coordinates (Y-up)
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
// Apply inverse zoom and add camera position
const cam = cameraRef.current;
const worldX = centeredX / cam.zoom + cam.x;
const worldY = centeredY / cam.zoom + cam.y;
return { x: worldX, y: worldY };
}, []);
// World to screen conversion
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cam = cameraRef.current;
// Apply camera transform
const centeredX = (worldX - cam.x) * cam.zoom;
const centeredY = (worldY - cam.y) * cam.zoom;
// Convert from centered coordinates
const canvasX = centeredX + canvas.width / 2;
const canvasY = canvas.height / 2 - centeredY;
// Convert to screen coordinates
const screenX = canvasX / dpr + rect.left;
const screenY = canvasY / dpr + rect.top;
return { x: screenX, y: screenY };
}, []);
// Request render
const requestRender = useCallback(() => {
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.renderToViewport(viewportId);
}
}, [viewportId]);
// Expose imperative handle
useImperativeHandle(ref, () => ({
getCamera: () => cameraRef.current,
setCamera: (newCamera: ViewportCameraConfig) => {
setCamera(newCamera);
onCameraChange?.(newCamera);
},
resetCamera: () => {
setCamera(initialCamera);
onCameraChange?.(initialCamera);
},
screenToWorld,
worldToScreen,
getCanvas: () => canvasRef.current,
requestRender
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
// Initialize viewport
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const canvasId = `editor-viewport-canvas-${viewportId}`;
canvas.id = canvasId;
const viewportService = ViewportService.getInstance();
// Wait for service to be initialized
const checkInit = () => {
if (viewportService.isInitialized()) {
// Register viewport
viewportService.registerViewport(viewportId, canvasId);
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
viewportService.setViewportCamera(viewportId, camera);
setIsReady(true);
onReady?.();
} else {
// Retry after a short delay
setTimeout(checkInit, 100);
}
};
checkInit();
return () => {
if (viewportService.isInitialized()) {
viewportService.unregisterViewport(viewportId);
}
};
}, [viewportId]);
// Update viewport config when props change
useEffect(() => {
if (!isReady) return;
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
}
}, [viewportId, showGrid, showGizmos, isReady]);
// Sync camera to viewport service
useEffect(() => {
if (!isReady) return;
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.setViewportCamera(viewportId, camera);
}
}, [viewportId, camera, isReady]);
// Handle resize
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const resizeCanvas = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
if (isReady) {
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
}
}
};
resizeCanvas();
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
resizeCanvas();
rafId = null;
});
});
resizeObserver.observe(container);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
resizeObserver.disconnect();
};
}, [viewportId, isReady]);
// Mouse handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
// Middle or right button for camera pan
if (enablePan && (e.button === 1 || e.button === 2)) {
isDraggingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
}
onMouseDown?.(e, worldPos);
}, [enablePan, screenToWorld, onMouseDown]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
if (isDraggingRef.current && enablePan) {
const deltaX = e.clientX - lastMousePosRef.current.x;
const deltaY = e.clientY - lastMousePosRef.current.y;
const dpr = window.devicePixelRatio || 1;
setCamera(prev => {
const newCamera = {
...prev,
x: prev.x - (deltaX * dpr) / prev.zoom,
y: prev.y + (deltaY * dpr) / prev.zoom
};
onCameraChange?.(newCamera);
return newCamera;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
}
onMouseMove?.(e, worldPos);
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
isDraggingRef.current = false;
onMouseUp?.(e, worldPos);
}, [screenToWorld, onMouseUp]);
const handleWheel = useCallback((e: React.WheelEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
if (enableZoom) {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
setCamera(prev => {
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
const newCamera = { ...prev, zoom: newZoom };
onCameraChange?.(newCamera);
return newCamera;
});
}
onWheel?.(e, worldPos);
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
}, []);
return (
<div
ref={containerRef}
className={`editor-viewport ${className || ''}`}
>
<canvas
ref={canvasRef}
className="editor-viewport-canvas"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
/>
{renderOverlays?.()}
</div>
);
});
export default EditorViewport;
@@ -0,0 +1,74 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { DomainError } from '../domain/errors';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('ErrorBoundary caught error:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error);
}
return <DefaultErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
function DefaultErrorFallback({ error }: { error: Error }): JSX.Element {
const message = error instanceof DomainError ? error.getUserMessage() : error.message;
return (
<div
style={{
padding: '20px',
backgroundColor: '#fee',
border: '1px solid #fcc',
borderRadius: '4px',
margin: '20px'
}}
>
<h2 style={{ color: '#c00', marginTop: 0 }}></h2>
<p>{message}</p>
<details style={{ marginTop: '10px' }}>
<summary style={{ cursor: 'pointer' }}></summary>
<pre
style={{
marginTop: '10px',
padding: '10px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
overflow: 'auto'
}}
>
{error.stack}
</pre>
</details>
</div>
);
}
@@ -0,0 +1,31 @@
import { X } from 'lucide-react';
import '../styles/ErrorDialog.css';
interface ErrorDialogProps {
title: string;
message: string;
onClose: () => void;
}
export function ErrorDialog({ title, message, onClose }: ErrorDialogProps) {
return (
<div className="error-dialog-overlay" onClick={onClose}>
<div className="error-dialog" onClick={(e) => e.stopPropagation()}>
<div className="error-dialog-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="error-dialog-content">
<p>{message}</p>
</div>
<div className="error-dialog-footer">
<button className="error-dialog-btn" onClick={onClose}>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,453 @@
import { useState, useEffect } from 'react';
import { X, File, FolderTree, FolderOpen } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { useLocale } from '../hooks/useLocale';
import '../styles/ExportRuntimeDialog.css';
interface ExportRuntimeDialogProps {
isOpen: boolean;
onClose: () => void;
onExport: (options: ExportOptions) => void;
hasProject: boolean;
availableFiles: string[];
currentFileName?: string;
projectPath?: string;
}
export interface ExportOptions {
mode: 'single' | 'workspace';
assetOutputPath: string;
typeOutputPath: string;
selectedFiles: string[];
fileFormats: Map<string, 'json' | 'binary'>;
}
/**
* 导出运行时资产对话框
*/
export const ExportRuntimeDialog: React.FC<ExportRuntimeDialogProps> = ({
isOpen,
onClose,
onExport,
hasProject,
availableFiles,
currentFileName,
projectPath
}) => {
const { t } = useLocale();
const [selectedMode, setSelectedMode] = useState<'single' | 'workspace'>('workspace');
const [assetOutputPath, setAssetOutputPath] = useState('');
const [typeOutputPath, setTypeOutputPath] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
const [selectAll, setSelectAll] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [exportMessage, setExportMessage] = useState('');
// 从 localStorage 加载上次的路径
useEffect(() => {
if (isOpen && projectPath) {
const savedAssetPath = localStorage.getItem('export-asset-path');
const savedTypePath = localStorage.getItem('export-type-path');
if (savedAssetPath) {
setAssetOutputPath(savedAssetPath);
}
if (savedTypePath) {
setTypeOutputPath(savedTypePath);
}
}
}, [isOpen, projectPath]);
useEffect(() => {
if (isOpen) {
if (selectedMode === 'workspace') {
const newSelectedFiles = new Set(availableFiles);
setSelectedFiles(newSelectedFiles);
setSelectAll(true);
const newFormats = new Map<string, 'json' | 'binary'>();
availableFiles.forEach((file) => {
newFormats.set(file, 'binary');
});
setFileFormats(newFormats);
} else {
setSelectedFiles(new Set());
setSelectAll(false);
if (currentFileName) {
const newFormats = new Map<string, 'json' | 'binary'>();
newFormats.set(currentFileName, 'binary');
setFileFormats(newFormats);
}
}
}
}, [isOpen, selectedMode, availableFiles, currentFileName]);
if (!isOpen) return null;
const handleSelectAll = () => {
if (selectAll) {
setSelectedFiles(new Set());
setSelectAll(false);
} else {
setSelectedFiles(new Set(availableFiles));
setSelectAll(true);
}
};
const handleToggleFile = (file: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(file)) {
newSelected.delete(file);
} else {
newSelected.add(file);
}
setSelectedFiles(newSelected);
setSelectAll(newSelected.size === availableFiles.length);
};
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
const newFormats = new Map(fileFormats);
newFormats.set(file, format);
setFileFormats(newFormats);
};
const handleBrowseAssetPath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t('exportRuntime.selectAssetDir'),
defaultPath: assetOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setAssetOutputPath(path);
localStorage.setItem('export-asset-path', path);
}
} catch (error) {
console.error('Failed to browse asset path:', error);
}
};
const handleBrowseTypePath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t('exportRuntime.selectTypeDir'),
defaultPath: typeOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setTypeOutputPath(path);
localStorage.setItem('export-type-path', path);
}
} catch (error) {
console.error('Failed to browse type path:', error);
}
};
const handleExport = async () => {
if (!assetOutputPath) {
setExportMessage(t('exportRuntime.errorSelectAssetPath'));
return;
}
if (!typeOutputPath) {
setExportMessage(t('exportRuntime.errorSelectTypePath'));
return;
}
if (selectedMode === 'workspace' && selectedFiles.size === 0) {
setExportMessage(t('exportRuntime.errorSelectFile'));
return;
}
if (selectedMode === 'single' && !currentFileName) {
setExportMessage(t('exportRuntime.errorNoCurrentFile'));
return;
}
// 保存路径到 localStorage
localStorage.setItem('export-asset-path', assetOutputPath);
localStorage.setItem('export-type-path', typeOutputPath);
setIsExporting(true);
setExportProgress(0);
setExportMessage(t('exportRuntime.exporting'));
try {
await onExport({
mode: selectedMode,
assetOutputPath,
typeOutputPath,
selectedFiles: selectedMode === 'workspace' ? Array.from(selectedFiles) : [currentFileName!],
fileFormats
});
setExportProgress(100);
setExportMessage(t('exportRuntime.exportSuccess'));
} catch (error) {
setExportMessage(t('exportRuntime.exportFailed', { error: String(error) }));
} finally {
setIsExporting(false);
}
};
return (
<div className="export-dialog-overlay">
<div className="export-dialog" style={{ maxWidth: '700px', width: '90%' }}>
<div className="export-dialog-header">
<h3>{t('exportRuntime.title')}</h3>
<button onClick={onClose} className="export-dialog-close">
<X size={20} />
</button>
</div>
<div className="export-dialog-content" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
{/* Tab 页签 */}
<div className="export-mode-tabs">
<button
className={`export-mode-tab ${selectedMode === 'workspace' ? 'active' : ''}`}
onClick={() => hasProject ? setSelectedMode('workspace') : null}
disabled={!hasProject}
>
<FolderTree size={16} />
{t('exportRuntime.workspaceExport')}
</button>
<button
className={`export-mode-tab ${selectedMode === 'single' ? 'active' : ''}`}
onClick={() => setSelectedMode('single')}
>
<File size={16} />
{t('exportRuntime.currentFile')}
</button>
</div>
{/* 资产输出路径 */}
<div className="export-section">
<h4>{t('exportRuntime.assetOutputPath')}</h4>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={assetOutputPath}
onChange={(e) => setAssetOutputPath(e.target.value)}
placeholder={t('exportRuntime.selectAssetDirPlaceholder')}
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseAssetPath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
{t('exportRuntime.browse')}
</button>
</div>
</div>
{/* TypeScript 类型定义输出路径 */}
<div className="export-section">
<h4>{t('exportRuntime.typeOutputPath')}</h4>
<div style={{ marginBottom: '12px', fontSize: '11px', color: '#999', lineHeight: '1.5', whiteSpace: 'pre-line' }}>
{selectedMode === 'workspace'
? t('exportRuntime.typeOutputHintWorkspace')
: t('exportRuntime.typeOutputHintSingle')
}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={typeOutputPath}
onChange={(e) => setTypeOutputPath(e.target.value)}
placeholder={t('exportRuntime.selectTypeDirPlaceholder')}
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseTypePath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
{t('exportRuntime.browse')}
</button>
</div>
</div>
{/* 文件列表 */}
{selectedMode === 'workspace' && availableFiles.length > 0 && (
<div className="export-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 style={{ margin: 0, fontSize: '13px', color: '#ccc' }}>
{t('exportRuntime.selectFilesToExport')} ({selectedFiles.size}/{availableFiles.length})
</h4>
<button
onClick={handleSelectAll}
style={{
padding: '4px 12px',
backgroundColor: '#3a3a3a',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
{selectAll ? t('exportRuntime.deselectAll') : t('exportRuntime.selectAll')}
</button>
</div>
<div className="export-file-list">
{availableFiles.map((file) => (
<div
key={file}
className={`export-file-item ${selectedFiles.has(file) ? 'selected' : ''}`}
>
<input
type="checkbox"
className="export-file-checkbox"
checked={selectedFiles.has(file)}
onChange={() => handleToggleFile(file)}
/>
<div className="export-file-name">
<File size={14} />
{file}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(file) || 'binary'}
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
onClick={(e) => e.stopPropagation()}
>
<option value="binary">{t('exportRuntime.binary')}</option>
<option value="json">{t('exportRuntime.json')}</option>
</select>
</div>
))}
</div>
</div>
)}
{/* 单文件模式 */}
{selectedMode === 'single' && (
<div className="export-section">
<h4>{t('exportRuntime.currentFile')}</h4>
{currentFileName ? (
<div className="export-file-list">
<div className="export-file-item selected">
<div className="export-file-name" style={{ paddingLeft: '8px' }}>
<File size={14} />
{currentFileName}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(currentFileName) || 'binary'}
onChange={(e) => handleFileFormatChange(currentFileName, e.target.value as 'json' | 'binary')}
>
<option value="binary">{t('exportRuntime.binary')}</option>
<option value="json">{t('exportRuntime.json')}</option>
</select>
</div>
</div>
) : (
<div style={{
padding: '40px 20px',
textAlign: 'center',
color: '#999',
fontSize: '13px',
backgroundColor: '#252525',
borderRadius: '6px',
border: '1px solid #3a3a3a'
}}>
<File size={32} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
<div>{t('exportRuntime.noOpenFile')}</div>
<div style={{ fontSize: '11px', marginTop: '8px' }}>
{t('exportRuntime.openFileHint')}
</div>
</div>
)}
</div>
)}
</div>
<div className="export-dialog-footer">
{exportMessage && (
<div style={{
flex: 1,
fontSize: '12px',
color: exportMessage.includes('Error') || exportMessage.includes('错误') ? '#f48771' : exportMessage.includes('success') || exportMessage.includes('成功') ? '#89d185' : '#ccc',
paddingLeft: '8px'
}}>
{exportMessage}
</div>
)}
{isExporting && (
<div style={{
flex: 1,
height: '4px',
backgroundColor: '#3a3a3a',
borderRadius: '2px',
overflow: 'hidden',
marginRight: '12px'
}}>
<div style={{
height: '100%',
width: `${exportProgress}%`,
backgroundColor: '#0e639c',
transition: 'width 0.3s'
}}></div>
</div>
)}
<button onClick={onClose} className="export-dialog-btn export-dialog-btn-cancel">
{t('exportRuntime.close')}
</button>
<button
onClick={handleExport}
className="export-dialog-btn export-dialog-btn-primary"
disabled={isExporting}
style={{ opacity: isExporting ? 0.5 : 1 }}
>
{isExporting ? t('exportRuntime.exporting') : t('exportRuntime.export')}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,58 @@
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
import '../styles/ConfirmDialog.css';
interface ExternalModificationDialogProps {
sceneName: string;
onReload: () => void;
onOverwrite: () => void;
onCancel: () => void;
}
/**
* 外部修改对话框
* External Modification Dialog
*
* 当场景文件被外部修改时显示,让用户选择操作
* Shown when scene file is modified externally, let user choose action
*/
export function ExternalModificationDialog({
sceneName,
onReload,
onOverwrite,
onCancel
}: ExternalModificationDialogProps) {
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<AlertTriangle size={20} className="warning-icon" />
<h2></h2>
<button className="close-btn" onClick={onCancel}>
<X size={16} />
</button>
</div>
<div className="confirm-dialog-content">
<p>
<strong>{sceneName}</strong>
</p>
<p className="hint-text">
</p>
</div>
<div className="confirm-dialog-footer external-modification-footer">
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
</button>
<button className="confirm-dialog-btn reload" onClick={onReload}>
<RefreshCw size={14} />
</button>
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
<Save size={14} />
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,568 @@
/**
* FlexLayoutDockContainer - Dockable panel container based on FlexLayout
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
*/
import { useCallback, useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
export type { FlexDockPanel };
/** LocalStorage key for persisting layout | 持久化布局的 localStorage 键 */
const LAYOUT_STORAGE_KEY = 'esengine-editor-layout';
/** Layout version for migration | 布局版本用于迁移 */
const LAYOUT_VERSION = 1;
/** Saved layout data structure | 保存的布局数据结构 */
interface SavedLayoutData {
version: number;
layout: IJsonModel;
timestamp: number;
}
/**
* Save layout to localStorage.
* 保存布局到 localStorage。
*/
function saveLayoutToStorage(layout: IJsonModel): void {
try {
const data: SavedLayoutData = {
version: LAYOUT_VERSION,
layout,
timestamp: Date.now()
};
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save layout to localStorage:', error);
}
}
/**
* Load layout from localStorage.
* 从 localStorage 加载布局。
*/
function loadLayoutFromStorage(): IJsonModel | null {
try {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (!saved) return null;
const data: SavedLayoutData = JSON.parse(saved);
// Version check for future migrations
if (data.version !== LAYOUT_VERSION) {
console.info('Layout version mismatch, using default layout');
return null;
}
return data.layout;
} catch (error) {
console.warn('Failed to load layout from localStorage:', error);
return null;
}
}
/**
* Clear saved layout from localStorage.
* 从 localStorage 清除保存的布局。
*/
function clearLayoutStorage(): void {
try {
localStorage.removeItem(LAYOUT_STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear layout from localStorage:', error);
}
}
/**
* Public handle for FlexLayoutDockContainer.
* FlexLayoutDockContainer 的公开句柄。
*/
export interface FlexLayoutDockContainerHandle {
/** Reset layout to default | 重置布局到默认状态 */
resetLayout: () => void;
}
/**
* Panel IDs that should persist in DOM when switching tabs.
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
* 切换 tab 时需要保持 DOM 存在的面板 ID。
* 这些面板包含 WebGL canvas 或其他不能卸载的有状态内容。
*/
const PERSISTENT_PANEL_IDS = ['viewport'];
/** Tab header height in pixels | Tab 标签栏高度(像素) */
const TAB_HEADER_HEIGHT = 28;
interface PanelRect {
domRect: DOMRect;
isSelected: boolean;
isVisible?: boolean;
}
/**
* Get panel rectangle from FlexLayout model.
* 从 FlexLayout 模型获取面板矩形。
*/
function getPanelRectFromModel(model: Model, panelId: string): PanelRect | null {
const node = model.getNodeById(panelId);
if (!node || node.getType() !== 'tab') return null;
const parent = node.getParent();
if (!parent || parent.getType() !== 'tabset') return null;
const tabset = parent as any;
const selectedNode = tabset.getSelectedNode();
const isSelected = selectedNode?.getId() === panelId;
const tabsetRect = tabset.getRect();
if (!tabsetRect) return null;
return {
domRect: new DOMRect(
tabsetRect.x,
tabsetRect.y + TAB_HEADER_HEIGHT,
tabsetRect.width,
tabsetRect.height - TAB_HEADER_HEIGHT
),
isSelected
};
}
/**
* Get panel rectangle from DOM placeholder element.
* 从 DOM 占位符元素获取面板矩形。
*/
function getPanelRectFromDOM(panelId: string): PanelRect | null {
const placeholder = document.querySelector(`[data-panel-id="${panelId}"]`);
if (!placeholder) return null;
const placeholderRect = placeholder.getBoundingClientRect();
if (placeholderRect.width <= 0 || placeholderRect.height <= 0) return null;
const container = document.querySelector('.flexlayout-dock-container');
if (!container) return null;
const containerRect = container.getBoundingClientRect();
const parentTab = placeholder.closest('.flexlayout__tabset_content');
const isVisible = parentTab ? (parentTab as HTMLElement).offsetParent !== null : false;
return {
domRect: new DOMRect(
placeholderRect.x - containerRect.x,
placeholderRect.y - containerRect.y,
placeholderRect.width,
placeholderRect.height
),
isSelected: false,
isVisible
};
}
interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
activePanelId?: string;
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
}
export const FlexLayoutDockContainer = forwardRef<FlexLayoutDockContainerHandle, FlexLayoutDockContainerProps>(
function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) {
const layoutRef = useRef<Layout>(null);
const previousLayoutJsonRef = useRef<string | null>(null);
const previousPanelIdsRef = useRef<string>('');
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
/** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */
const skipNextSaveRef = useRef(false);
// Persistent panel state | 持久化面板状态
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
() => new Set(PERSISTENT_PANEL_IDS)
);
const [isAnyTabsetMaximized, setIsAnyTabsetMaximized] = useState(false);
const persistentPanels = useMemo(
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
[panels]
);
const createDefaultLayout = useCallback((): IJsonModel => {
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
}, [panels, activePanelId]);
/**
* Try to load saved layout and merge with current panels.
* 尝试加载保存的布局并与当前面板合并。
*/
const loadSavedLayoutOrDefault = useCallback((): IJsonModel => {
const savedLayout = loadLayoutFromStorage();
if (savedLayout) {
try {
// Merge saved layout with current panels (handle new/removed panels)
const defaultLayout = createDefaultLayout();
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
return mergedLayout;
} catch (error) {
console.warn('Failed to merge saved layout, using default:', error);
}
}
return createDefaultLayout();
}, [createDefaultLayout, panels]);
const [model, setModel] = useState<Model>(() => {
try {
return Model.fromJson(loadSavedLayoutOrDefault());
} catch (error) {
console.warn('Failed to load saved layout, using default:', error);
return Model.fromJson(createDefaultLayout());
}
});
/**
* Reset layout to default and clear saved layout.
* 重置布局到默认状态并清除保存的布局。
*/
const resetLayout = useCallback(() => {
clearLayoutStorage();
skipNextSaveRef.current = true;
previousLayoutJsonRef.current = null;
previousPanelIdsRef.current = '';
const defaultLayout = createDefaultLayout();
setModel(Model.fromJson(defaultLayout));
}, [createDefaultLayout]);
// Expose resetLayout method via ref | 通过 ref 暴露 resetLayout 方法
useImperativeHandle(ref, () => ({
resetLayout
}), [resetLayout]);
useEffect(() => {
try {
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
const previousIds = previousPanelIdsRef.current;
// 检查标题是否变化
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
const titleChanges: Array<{ id: string; newTitle: string }> = [];
for (const panel of panels) {
const previousTitle = previousPanelTitlesRef.current.get(panel.id);
if (previousTitle && previousTitle !== panel.title) {
titleChanges.push({ id: panel.id, newTitle: panel.title });
}
}
// 更新标题引用
previousPanelTitlesRef.current = currentTitles;
// 如果只是标题变化,更新tab名称
if (titleChanges.length > 0 && currentPanelIds === previousIds && model) {
titleChanges.forEach(({ id, newTitle }) => {
const node = model.getNodeById(id);
if (node && node.getType() === 'tab') {
model.doAction(Actions.renameTab(id, newTitle));
}
});
return;
}
if (currentPanelIds === previousIds) {
return;
}
// 计算新增和移除的面板
const prevSet = new Set(previousIds.split(',').filter((id) => id));
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
previousPanelIdsRef.current = currentPanelIds;
// 如果已经有布局且只是添加新面板,使用Action动态添加
// 检查新面板是否需要独立 tabset(如 bottom 位置的面板)
// Check if new panels require separate tabset (e.g., bottom position panels)
const newPanelsWithConfig = panels.filter((p) => newPanelIds.includes(p.id));
const hasSpecialLayoutPanels = newPanelsWithConfig.some((p) =>
p.layout?.requiresSeparateTabset || p.layout?.position === 'bottom'
);
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds && !hasSpecialLayoutPanels) {
// 找到要添加的面板
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
// 构建面板位置映射 | Build panel position map
const panelPositionMap = new Map(panels.map((p) => [p.id, p.layout?.position || 'center']));
// 找到中心区域的tabset ID | Find center tabset ID
let centerTabsetId: string | null = null;
model.visitNodes((node: any) => {
if (node.getType() === 'tabset') {
const tabset = node as any;
// 检查是否是中心tabset(包含 center 位置的面板)
// Check if this is center tabset (contains center position panels)
const children = tabset.getChildren();
const hasCenterPanel = children.some((child: any) => {
const id = child.getId();
const position = panelPositionMap.get(id);
return position === 'center' || position === undefined;
});
if (hasCenterPanel && !centerTabsetId) {
centerTabsetId = tabset.getId();
}
}
});
if (centerTabsetId) {
// 动态添加tab到中心tabset
newPanels.forEach((panel) => {
model.doAction(Actions.addNode(
{
type: 'tab',
name: panel.title,
id: panel.id,
component: panel.id,
enableClose: panel.closable !== false
},
centerTabsetId!,
DockLocation.CENTER,
-1 // 添加到末尾
));
});
// 选中最后添加的面板
const lastPanel = newPanels[newPanels.length - 1];
if (lastPanel) {
setTimeout(() => {
const node = model.getNodeById(lastPanel.id);
if (node) {
model.doAction(Actions.selectTab(lastPanel.id));
}
}, 0);
}
return;
}
}
// 否则完全重建布局
const defaultLayout = createDefaultLayout();
// 如果有保存的布局,尝试合并
// 注意:如果新面板需要特殊布局(独立 tabset),直接使用默认布局
// Note: If new panels need special layout (separate tabset), use default layout directly
if (previousLayoutJsonRef.current && previousIds && !hasSpecialLayoutPanels) {
try {
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
const newModel = Model.fromJson(mergedLayout);
setModel(newModel);
return;
} catch (error) {
// 合并失败,使用默认布局
}
}
// 使用默认布局
const newModel = Model.fromJson(defaultLayout);
setModel(newModel);
} catch (error) {
throw new Error(`Failed to update layout model: ${error instanceof Error ? error.message : String(error)}`);
}
}, [createDefaultLayout, panels]);
/**
* Track persistent panel positions and visibility.
* Uses FlexLayout model to determine if panel tab is selected,
* falls back to DOM measurement if model data unavailable.
* 追踪持久化面板的位置和可见性。
* 使用 FlexLayout 模型判断面板 tab 是否被选中,
* 如果模型数据不可用则回退到 DOM 测量。
*/
useEffect(() => {
if (!model) return;
const updatePersistentPanelPositions = () => {
const newRects = new Map<string, DOMRect>();
const newVisible = new Set<string>();
for (const panelId of PERSISTENT_PANEL_IDS) {
// Try to get position from FlexLayout model
const rect = getPanelRectFromModel(model, panelId);
if (rect) {
newRects.set(panelId, rect.domRect);
if (rect.isSelected) {
newVisible.add(panelId);
}
continue;
}
// Fallback: measure placeholder element in DOM
const placeholderRect = getPanelRectFromDOM(panelId);
if (placeholderRect) {
newRects.set(panelId, placeholderRect.domRect);
if (placeholderRect.isVisible) {
newVisible.add(panelId);
}
}
}
setPersistentPanelRects(newRects);
setVisiblePersistentPanels(newVisible);
};
// Initial update after DOM render
requestAnimationFrame(updatePersistentPanelPositions);
// Observe layout changes
const container = document.querySelector('.flexlayout-dock-container');
if (!container) return;
const mutationObserver = new MutationObserver(() => {
requestAnimationFrame(updatePersistentPanelPositions);
});
mutationObserver.observe(container, { childList: true, subtree: true, attributes: true });
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(updatePersistentPanelPositions);
});
resizeObserver.observe(container);
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [model]);
const factory = useCallback((node: TabNode) => {
const componentId = node.getComponent() || '';
// Persistent panels render as placeholder, actual content is in overlay
// 持久化面板渲染为占位符,实际内容在覆盖层中
if (PERSISTENT_PANEL_IDS.includes(componentId)) {
return <div className="persistent-panel-placeholder" data-panel-id={componentId} />;
}
const panel = panels.find((p) => p.id === componentId);
return panel?.content ?? <div>Panel not found</div>;
}, [panels]);
const onAction = useCallback((action: Action) => {
if (action.type === Actions.DELETE_TAB) {
const tabId = (action.data as { node: string }).node;
if (onPanelClose) {
onPanelClose(tabId);
}
}
return action;
}, [onPanelClose]);
const onModelChange = useCallback((newModel: Model) => {
// 保存布局状态以便在panels变化时恢复
const layoutJson = newModel.toJson();
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
// Save to localStorage (unless skipped) | 保存到 localStorage(除非跳过)
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
} else {
saveLayoutToStorage(layoutJson);
}
// Check if any tabset is maximized
let hasMaximized = false;
newModel.visitNodes((node) => {
if (node.getType() === 'tabset') {
const tabset = node as TabSetNode;
if (tabset.isMaximized()) {
hasMaximized = true;
}
}
});
setIsAnyTabsetMaximized(hasMaximized);
}, []);
useEffect(() => {
if (!messageHub || !model) return;
const unsubscribe = messageHub.subscribe('panel:select', (data: { panelId: string }) => {
const { panelId } = data;
const node = model.getNodeById(panelId);
if (node && node.getType() === 'tab') {
model.doAction(Actions.selectTab(panelId));
}
});
return () => unsubscribe?.();
}, [messageHub, model]);
return (
<div className="flexlayout-dock-container">
<Layout
ref={layoutRef}
model={model}
factory={factory}
onAction={onAction}
onModelChange={onModelChange}
/>
{/* Persistent panel overlay - always mounted, visibility controlled by CSS */}
{/* 持久化面板覆盖层 - 始终挂载,通过 CSS 控制可见性 */}
{persistentPanels.map((panel) => (
<PersistentPanelContainer
key={panel.id}
panel={panel}
rect={persistentPanelRects.get(panel.id)}
isVisible={visiblePersistentPanels.has(panel.id)}
isMaximized={isAnyTabsetMaximized}
/>
))}
</div>
);
});
/**
* Container for persistent panel content.
* 持久化面板内容容器。
*/
function PersistentPanelContainer({
panel,
rect,
isVisible,
isMaximized
}: {
panel: FlexDockPanel;
rect?: DOMRect;
isVisible: boolean;
isMaximized: boolean;
}) {
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
// Hide persistent panel completely when another tabset is maximized
// (unless this panel itself is in the maximized tabset)
const shouldHide = isMaximized && !isVisible;
return (
<div
className="persistent-panel-container"
style={{
position: 'absolute',
left: hasValidRect ? rect.x : 0,
top: hasValidRect ? rect.y : 0,
width: hasValidRect ? rect.width : '100%',
height: hasValidRect ? rect.height : '100%',
visibility: (isVisible && !shouldHide) ? 'visible' : 'hidden',
pointerEvents: (isVisible && !shouldHide) ? 'auto' : 'none',
// 使用较低的 z-index,确保不会遮挡 FlexLayout 的 tab bar
zIndex: 0,
overflow: 'hidden'
}}
>
{panel.content}
</div>
);
}
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Github, AlertCircle, CheckCircle, Loader, ExternalLink } from 'lucide-react';
import { GitHubService } from '../services/GitHubService';
import { open } from '@tauri-apps/plugin-shell';
import { useLocale } from '../hooks/useLocale';
import '../styles/GitHubAuth.css';
interface GitHubAuthProps {
githubService: GitHubService;
onSuccess: () => void;
}
export function GitHubAuth({ githubService, onSuccess }: GitHubAuthProps) {
const { t } = useLocale();
const [useOAuth, setUseOAuth] = useState(true);
const [githubToken, setGithubToken] = useState('');
const [userCode, setUserCode] = useState('');
const [verificationUri, setVerificationUri] = useState('');
const [authStatus, setAuthStatus] = useState<'idle' | 'pending' | 'authorized' | 'error'>('idle');
const [error, setError] = useState('');
const handleOAuthLogin = async () => {
setAuthStatus('pending');
setError('');
try {
const deviceCodeResp = await githubService.requestDeviceCode();
setUserCode(deviceCodeResp.user_code);
setVerificationUri(deviceCodeResp.verification_uri);
await open(deviceCodeResp.verification_uri);
await githubService.authenticateWithDeviceFlow(
deviceCodeResp.device_code,
deviceCodeResp.interval,
(status) => {
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
}
);
setAuthStatus('authorized');
setTimeout(() => {
onSuccess();
}, 1000);
} catch (err) {
console.error('[GitHubAuth] OAuth failed:', err);
setAuthStatus('error');
const errorMessage = err instanceof Error ? err.message : 'OAuth authorization failed';
const fullError = err instanceof Error && err.stack ? `${errorMessage}\n\nDetails: ${err.stack}` : errorMessage;
setError(fullError);
}
};
const handleTokenAuth = async () => {
if (!githubToken.trim()) {
setError(t('github.enterToken'));
return;
}
try {
await githubService.authenticate(githubToken);
setError('');
onSuccess();
} catch (err) {
console.error('[GitHubAuth] Token auth failed:', err);
setError(t('github.authFailedToken'));
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const openCreateTokenPage = async () => {
await githubService.openAuthorizationPage();
};
return (
<div className="github-auth">
<Github size={48} style={{ color: '#0366d6' }} />
<p>{t('github.githubLogin')}</p>
<div className="auth-tabs">
<button
className={`auth-tab ${useOAuth ? 'active' : ''}`}
onClick={() => setUseOAuth(true)}
>
{t('github.oauthLogin')}
</button>
<button
className={`auth-tab ${!useOAuth ? 'active' : ''}`}
onClick={() => setUseOAuth(false)}
>
{t('github.tokenLogin')}
</button>
</div>
{useOAuth ? (
<div className="oauth-auth">
{authStatus === 'idle' && (
<>
<div className="oauth-instructions">
<p>{t('github.oauthStep1')}</p>
<p>{t('github.oauthStep2')}</p>
<p>{t('github.oauthStep3')}</p>
</div>
<button className="btn-primary" onClick={handleOAuthLogin}>
<Github size={16} />
{t('github.startAuth')}
</button>
</>
)}
{authStatus === 'pending' && (
<div className="oauth-pending">
<Loader size={48} className="spinning" style={{ color: '#0366d6' }} />
<h4>{t('github.authorizing')}</h4>
{userCode && (
<div className="user-code-display">
<label>{t('github.userCode')}</label>
<div className="code-box">
<span className="code-text">{userCode}</span>
<button
className="btn-copy"
onClick={() => copyToClipboard(userCode)}
title={t('github.copyCode')}
>
📋
</button>
</div>
<button
className="btn-link"
onClick={() => open(verificationUri)}
>
<ExternalLink size={14} />
{t('github.openBrowser')}
</button>
</div>
)}
</div>
)}
{authStatus === 'authorized' && (
<div className="oauth-success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h4>{t('github.authorized')}</h4>
</div>
)}
{authStatus === 'error' && (
<div className="oauth-error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h4>{t('github.authFailed')}</h4>
{error && (
<div className="error-details">
<pre>{error}</pre>
</div>
)}
<button className="btn-secondary" onClick={() => setAuthStatus('idle')}>
{t('github.back')}
</button>
</div>
)}
</div>
) : (
<div className="token-auth">
<div className="form-group">
<label>{t('github.tokenLabel')}</label>
<input
type="password"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
placeholder={t('github.tokenPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTokenAuth();
}
}}
/>
<small>{t('github.tokenHint')}</small>
</div>
<button className="btn-link" onClick={openCreateTokenPage}>
<ExternalLink size={14} />
{t('github.createToken')}
</button>
<button className="btn-primary" onClick={handleTokenAuth}>
{t('github.login')}
</button>
</div>
)}
{error && !useOAuth && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { X } from 'lucide-react';
import { GitHubService } from '../services/GitHubService';
import { GitHubAuth } from './GitHubAuth';
import { useLocale } from '../hooks/useLocale';
import '../styles/GitHubLoginDialog.css';
interface GitHubLoginDialogProps {
githubService: GitHubService;
onClose: () => void;
}
export function GitHubLoginDialog({ githubService, onClose }: GitHubLoginDialogProps) {
const { t } = useLocale();
return (
<div className="github-login-overlay" onClick={onClose}>
<div className="github-login-dialog" onClick={(e) => e.stopPropagation()}>
<div className="github-login-header">
<h2>{t('github.title')}</h2>
<button className="github-login-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="github-login-content">
<GitHubAuth
githubService={githubService}
onSuccess={onClose}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { X, ChevronRight, ChevronDown, Copy, Check } from 'lucide-react';
import '../styles/JsonViewer.css';
interface JsonViewerProps {
data: any;
onClose: () => void;
}
export function JsonViewer({ data, onClose }: JsonViewerProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="json-viewer-overlay" onClick={onClose}>
<div className="json-viewer-modal" onClick={(e) => e.stopPropagation()}>
<div className="json-viewer-header">
<h3>JSON Viewer</h3>
<div className="json-viewer-actions">
<button
className="json-viewer-btn"
onClick={handleCopy}
title="Copy JSON"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
<button
className="json-viewer-btn"
onClick={onClose}
title="Close"
>
<X size={16} />
</button>
</div>
</div>
<div className="json-viewer-content">
<JsonTree data={data} name="root" />
</div>
</div>
</div>
);
}
interface JsonTreeProps {
data: any;
name: string;
level?: number;
}
function JsonTree({ data, name, level = 0 }: JsonTreeProps) {
const [expanded, setExpanded] = useState(level < 2);
const getValueType = (value: any): string => {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
};
const getValueColor = (type: string): string => {
switch (type) {
case 'string': return 'json-string';
case 'number': return 'json-number';
case 'boolean': return 'json-boolean';
case 'null': return 'json-null';
case 'array': return 'json-array';
case 'object': return 'json-object';
default: return '';
}
};
const renderValue = (value: any): JSX.Element => {
const type = getValueType(value);
const colorClass = getValueColor(type);
if (type === 'object' || type === 'array') {
const isArray = Array.isArray(value);
const keys = Object.keys(value);
const preview = isArray
? `Array(${value.length})`
: `Object {${keys.length} ${keys.length === 1 ? 'key' : 'keys'}}`;
return (
<div className="json-tree-node">
<div
className="json-tree-header"
onClick={() => setExpanded(!expanded)}
>
<span className="json-tree-expander">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="json-tree-key">{name}:</span>
<span className={`json-tree-preview ${colorClass}`}>
{preview}
</span>
</div>
{expanded && (
<div className="json-tree-children">
{isArray ? (
value.map((item: any, index: number) => (
<JsonTree
key={index}
data={item}
name={`[${index}]`}
level={level + 1}
/>
))
) : (
Object.entries(value).map(([key, val]) => (
<JsonTree
key={key}
data={val}
name={key}
level={level + 1}
/>
))
)}
</div>
)}
</div>
);
}
return (
<div className="json-tree-leaf">
<span className="json-tree-key">{name}:</span>
<span className={`json-tree-value ${colorClass}`}>
{type === 'string' ? `"${value}"` : String(value)}
</span>
</div>
);
};
return renderValue(data);
}
@@ -0,0 +1,289 @@
import { useState, useEffect, useRef } from 'react';
import {
Play,
Pause,
Square,
SkipForward,
Save,
FolderOpen,
Undo2,
Redo2,
Eye,
Globe,
QrCode,
ChevronDown
} from 'lucide-react';
import type { MessageHub, CommandManager } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import '../styles/MainToolbar.css';
export type PlayState = 'stopped' | 'playing' | 'paused';
interface MainToolbarProps {
messageHub?: MessageHub;
commandManager?: CommandManager;
onSaveScene?: () => void;
onOpenScene?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onPlay?: () => void;
onPause?: () => void;
onStop?: () => void;
onStep?: () => void;
onRunInBrowser?: () => void;
onRunOnDevice?: () => void;
}
interface ToolButtonProps {
icon: React.ReactNode;
label: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
}
function ToolButton({ icon, label, active, disabled, onClick }: ToolButtonProps) {
return (
<button
className={`toolbar-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={onClick}
disabled={disabled}
title={label}
type="button"
>
{icon}
</button>
);
}
function ToolSeparator() {
return <div className="toolbar-separator" />;
}
export function MainToolbar({
messageHub,
commandManager,
onSaveScene,
onOpenScene,
onUndo,
onRedo,
onPlay,
onPause,
onStop,
onStep,
onRunInBrowser,
onRunOnDevice
}: MainToolbarProps) {
const { t } = useLocale();
const [playState, setPlayState] = useState<PlayState>('stopped');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [showRunMenu, setShowRunMenu] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(null);
// Close run menu when clicking outside
useEffect(() => {
if (!showRunMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
setShowRunMenu(false);
}
};
const timer = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 10);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
}, [showRunMenu]);
useEffect(() => {
if (commandManager) {
const updateUndoRedo = () => {
setCanUndo(commandManager.canUndo());
setCanRedo(commandManager.canRedo());
};
updateUndoRedo();
if (messageHub) {
const unsubscribe = messageHub.subscribe('command:executed', updateUndoRedo);
return () => unsubscribe();
}
}
}, [commandManager, messageHub]);
useEffect(() => {
if (messageHub) {
const unsubscribePlay = messageHub.subscribe('preview:started', () => {
setPlayState('playing');
});
const unsubscribePause = messageHub.subscribe('preview:paused', () => {
setPlayState('paused');
});
const unsubscribeStop = messageHub.subscribe('preview:stopped', () => {
setPlayState('stopped');
});
return () => {
unsubscribePlay();
unsubscribePause();
unsubscribeStop();
};
}
}, [messageHub]);
const handlePlay = () => {
if (playState === 'stopped' || playState === 'paused') {
onPlay?.();
messageHub?.publish('preview:start', {});
}
};
const handlePause = () => {
if (playState === 'playing') {
onPause?.();
messageHub?.publish('preview:pause', {});
}
};
const handleStop = () => {
if (playState !== 'stopped') {
onStop?.();
messageHub?.publish('preview:stop', {});
}
};
const handleStep = () => {
onStep?.();
messageHub?.publish('preview:step', {});
};
const handleUndo = () => {
if (commandManager?.canUndo()) {
commandManager.undo();
onUndo?.();
}
};
const handleRedo = () => {
if (commandManager?.canRedo()) {
commandManager.redo();
onRedo?.();
}
};
const handleRunInBrowser = () => {
setShowRunMenu(false);
onRunInBrowser?.();
messageHub?.publish('viewport:run-in-browser', {});
};
const handleRunOnDevice = () => {
setShowRunMenu(false);
onRunOnDevice?.();
messageHub?.publish('viewport:run-on-device', {});
};
return (
<div className="main-toolbar">
{/* File Operations */}
<div className="toolbar-group">
<ToolButton
icon={<Save size={16} />}
label={t('toolbar.save')}
onClick={onSaveScene}
/>
<ToolButton
icon={<FolderOpen size={16} />}
label={t('toolbar.open')}
onClick={onOpenScene}
/>
</div>
<ToolSeparator />
{/* Undo/Redo */}
<div className="toolbar-group">
<ToolButton
icon={<Undo2 size={16} />}
label={t('toolbar.undo')}
disabled={!canUndo}
onClick={handleUndo}
/>
<ToolButton
icon={<Redo2 size={16} />}
label={t('toolbar.redo')}
disabled={!canRedo}
onClick={handleRedo}
/>
</div>
{/* Play Controls - Absolutely Centered */}
<div className="toolbar-center-wrapper">
<div className="toolbar-group toolbar-center">
<ToolButton
icon={playState === 'playing' ? <Pause size={18} /> : <Play size={18} />}
label={playState === 'playing' ? t('toolbar.pause') : t('toolbar.play')}
onClick={playState === 'playing' ? handlePause : handlePlay}
/>
<ToolButton
icon={<Square size={16} />}
label={t('toolbar.stop')}
disabled={playState === 'stopped'}
onClick={handleStop}
/>
<ToolButton
icon={<SkipForward size={16} />}
label={t('toolbar.step')}
disabled={playState === 'playing'}
onClick={handleStep}
/>
<ToolSeparator />
{/* Run Options Dropdown */}
<div className="toolbar-dropdown" ref={runMenuRef}>
<button
className="toolbar-button toolbar-dropdown-trigger"
onClick={(e) => {
e.stopPropagation();
setShowRunMenu(prev => !prev);
}}
title={t('toolbar.runOptions')}
type="button"
>
<Globe size={16} />
<ChevronDown size={12} />
</button>
{showRunMenu && (
<div className="toolbar-dropdown-menu">
<button type="button" onClick={handleRunInBrowser}>
<Globe size={14} />
<span>{t('toolbar.runInBrowser')}</span>
</button>
<button type="button" onClick={handleRunOnDevice}>
<QrCode size={14} />
<span>{t('toolbar.runOnDevice')}</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Preview Mode Indicator - Right aligned */}
<div className="toolbar-right">
{playState !== 'stopped' && (
<div className="preview-indicator">
<Eye size={14} />
<span>{t('toolbar.preview')}</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,223 @@
import { useState, useRef, useEffect } from 'react';
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import '../styles/MenuBar.css';
interface MenuItem {
label?: string;
shortcut?: string;
icon?: string;
disabled?: boolean;
separator?: boolean;
submenu?: MenuItem[];
onClick?: () => void;
}
interface MenuBarProps {
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: PluginManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
onSaveSceneAs?: () => void;
onOpenProject?: () => void;
onCloseProject?: () => void;
onExit?: () => void;
onOpenPluginManager?: () => void;
onOpenProfiler?: () => void;
onOpenPortManager?: () => void;
onOpenSettings?: () => void;
onToggleDevtools?: () => void;
onOpenAbout?: () => void;
onCreatePlugin?: () => void;
onReloadPlugins?: () => void;
onOpenBuildSettings?: () => void;
}
export function MenuBar({
uiRegistry,
messageHub,
pluginManager,
onNewScene,
onOpenScene,
onSaveScene,
onSaveSceneAs,
onOpenProject,
onCloseProject,
onExit,
onOpenPluginManager,
onOpenProfiler: _onOpenProfiler,
onOpenPortManager,
onOpenSettings,
onToggleDevtools,
onOpenAbout,
onCreatePlugin,
onReloadPlugins,
onOpenBuildSettings
}: MenuBarProps) {
const { t } = useLocale();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const updateMenuItems = () => {
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
setPluginMenuItems(items);
}
};
useEffect(() => {
updateMenuItems();
}, [uiRegistry, pluginManager]);
useEffect(() => {
if (messageHub) {
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
updateMenuItems();
});
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
updateMenuItems();
});
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
updateMenuItems();
});
return () => {
unsubscribeInstalled();
unsubscribeEnabled();
unsubscribeDisabled();
};
}
}, [messageHub, uiRegistry, pluginManager]);
const menus: Record<string, MenuItem[]> = {
file: [
{ label: t('menu.file.newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
{ label: t('menu.file.openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
{ separator: true },
{ label: t('menu.file.saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
{ label: t('menu.file.saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
{ separator: true },
{ label: t('menu.file.buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
{ separator: true },
{ label: t('menu.file.openProject'), onClick: onOpenProject },
{ label: t('menu.file.closeProject'), onClick: onCloseProject },
{ separator: true },
{ label: t('menu.file.exit'), onClick: onExit }
],
edit: [
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: true },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: true },
{ separator: true },
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
{ label: t('menu.edit.paste'), shortcut: 'Ctrl+V', disabled: true },
{ label: t('menu.edit.delete'), shortcut: 'Delete', disabled: true },
{ separator: true },
{ label: t('menu.edit.selectAll'), shortcut: 'Ctrl+A', disabled: true }
],
window: [
...pluginMenuItems.map((item) => ({
label: item.label || '',
icon: item.icon,
disabled: item.disabled,
onClick: item.onClick
})),
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
{ label: t('menu.window.pluginManager'), onClick: onOpenPluginManager },
{ separator: true },
{ label: t('menu.window.devtools'), onClick: onToggleDevtools }
],
tools: [
{ label: t('menu.tools.createPlugin'), onClick: onCreatePlugin },
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
{ separator: true },
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
{ separator: true },
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
],
help: [
{ label: t('menu.help.documentation'), disabled: true },
{ separator: true },
{ label: t('menu.help.about'), onClick: onOpenAbout }
]
};
// 菜单键到翻译键的映射 | Map menu keys to translation keys
const menuTitleKeys: Record<string, string> = {
file: 'menu.file.title',
edit: 'menu.edit.title',
window: 'menu.window.title',
tools: 'menu.tools.title',
help: 'menu.help.title'
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleMenuClick = (menuKey: string) => {
setOpenMenu(openMenu === menuKey ? null : menuKey);
};
const handleMenuItemClick = (item: MenuItem) => {
if (!item.disabled && !item.separator && item.onClick && item.label) {
item.onClick();
setOpenMenu(null);
}
};
return (
<div className="menu-bar" ref={menuRef}>
{Object.keys(menus).map((menuKey) => (
<div key={menuKey} className="menu-item">
<button
className={`menu-button ${openMenu === menuKey ? 'active' : ''}`}
onClick={() => handleMenuClick(menuKey)}
>
{t(menuTitleKeys[menuKey] || menuKey)}
</button>
{openMenu === menuKey && menus[menuKey] && (
<div className="menu-dropdown">
{menus[menuKey].map((item, index) => {
if (item.separator) {
return <div key={index} className="menu-separator" />;
}
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
return (
<button
key={index}
className={`menu-dropdown-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => handleMenuItemClick(item)}
disabled={item.disabled}
>
<span className="menu-item-content">
{IconComponent && <IconComponent size={16} />}
<span>{item.label || ''}</span>
</span>
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
</button>
);
})}
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,178 @@
import { useEffect, useRef, useCallback } from 'react';
interface Particle {
x: number;
y: number;
targetX: number;
targetY: number;
size: number;
alpha: number;
color: string;
}
interface MiniParticleLogoProps {
/** Logo text to display / 要显示的Logo文字 */
text?: string;
/** Canvas width / 画布宽度 */
width?: number;
/** Canvas height / 画布高度 */
height?: number;
/** Font size / 字体大小 */
fontSize?: number;
}
/**
* Mini Particle Logo Component
* Logo组件 - About对话框等小空间显示
*/
export function MiniParticleLogo({
text = 'ECS',
width = 80,
height = 80,
fontSize = 28
}: MiniParticleLogoProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const particlesRef = useRef<Particle[]>([]);
const createParticles = useCallback((
canvasWidth: number,
canvasHeight: number,
displayText: string,
textSize: number
): Particle[] => {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return [];
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
const textMetrics = tempCtx.measureText(displayText);
const textWidth = textMetrics.width;
const textHeight = textSize;
tempCanvas.width = textWidth + 10;
tempCanvas.height = textHeight + 10;
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = '#ffffff';
tempCtx.fillText(displayText, tempCanvas.width / 2, tempCanvas.height / 2);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const pixels = imageData.data;
const particles: Particle[] = [];
const gap = 2; // 小间隔以增加粒子密度 / Small gap for higher particle density
const offsetX = (canvasWidth - tempCanvas.width) / 2;
const offsetY = (canvasHeight - tempCanvas.height) / 2;
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
for (let y = 0; y < tempCanvas.height; y += gap) {
for (let x = 0; x < tempCanvas.width; x += gap) {
const index = (y * tempCanvas.width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha > 128) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(canvasWidth, canvasHeight) * 0.8;
particles.push({
x: canvasWidth / 2 + Math.cos(angle) * distance,
y: canvasHeight / 2 + Math.sin(angle) * distance,
targetX: offsetX + x,
targetY: offsetY + y,
size: Math.random() * 1 + 0.8,
alpha: Math.random() * 0.5 + 0.5,
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
});
}
}
}
return particles;
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
particlesRef.current = createParticles(width, height, text, fontSize);
const startTime = performance.now();
const duration = 1500; // 动画持续时间 / Animation duration
let isCancelled = false;
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
const animate = (currentTime: number) => {
if (isCancelled) return;
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeOutQuart(progress);
// 透明背景 / Transparent background
ctx.clearRect(0, 0, width, height);
for (const particle of particlesRef.current) {
const moveProgress = Math.min(easedProgress * 1.2, 1);
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
ctx.beginPath();
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.alpha;
ctx.fill();
}
ctx.globalAlpha = 1;
// 动画完成后添加微光效果 / Add subtle glow after animation completes
if (progress >= 1) {
const glowAlpha = 0.3 + Math.sin(currentTime / 500) * 0.1;
ctx.save();
ctx.shadowColor = '#4EC9B0';
ctx.shadowBlur = 8;
ctx.fillStyle = `rgba(255, 255, 255, ${glowAlpha})`;
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
ctx.restore();
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
isCancelled = true;
if (animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
}
};
}, [width, height, text, fontSize, createParticles]);
return (
<canvas
ref={canvasRef}
style={{
display: 'block',
borderRadius: '16px'
}}
/>
);
}
@@ -0,0 +1,305 @@
/**
* Module List Setting Component.
*
*
* Renders a list of engine modules with checkboxes to enable/disable.
* /
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react';
import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core';
import './styles/ModuleListSetting.css';
/**
* Module entry with enabled state.
*
*/
interface ModuleEntry extends ModuleManifest {
enabled: boolean;
canDisable: boolean;
disableReason?: string;
}
/**
* Props for ModuleListSetting.
*/
interface ModuleListSettingProps {
/** Module manifests (static) | 模块清单列表(静态) */
modules?: ModuleManifest[];
/** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json */
getModules?: () => ModuleManifest[];
/**
* Module IDs list. Meaning depends on useBlacklist.
* ID useBlacklist
* - useBlacklist=false: enabled modules (whitelist)
* - useBlacklist=true: disabled modules (blacklist)
*/
value: string[];
/** Callback when modules change | 模块变更回调 */
onModulesChange: (moduleIds: string[]) => void;
/**
* Use blacklist mode: value contains disabled modules instead of enabled.
* 使value
* Default: false (whitelist mode)
*/
useBlacklist?: boolean;
/** Validate if module can be disabled | 验证模块是否可禁用 */
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
}
/**
* Format bytes to human readable string.
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Module List Setting Component.
*
*/
export const ModuleListSetting: React.FC<ModuleListSettingProps> = ({
modules: staticModules,
getModules,
value,
onModulesChange,
useBlacklist = false,
validateDisable
}) => {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
const [loading, setLoading] = useState<string | null>(null);
// Get modules from function or static prop
// 从函数或静态 prop 获取模块
const modules = useMemo(() => {
if (getModules) {
return getModules();
}
return staticModules || [];
}, [getModules, staticModules]);
// Build module entries with enabled state | 构建带启用状态的模块条目
// In blacklist mode: enabled = NOT in value list
// In whitelist mode: enabled = IN value list
const moduleEntries: ModuleEntry[] = useMemo(() => {
return modules.map(mod => {
let enabled: boolean;
if (mod.isCore) {
enabled = true; // Core modules always enabled
} else if (useBlacklist) {
enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled
} else {
enabled = value.includes(mod.id); // Whitelist: IN list = enabled
}
return {
...mod,
enabled,
canDisable: !mod.isCore,
disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined
};
});
}, [modules, value, useBlacklist]);
// Group by category | 按分类分组
const groupedModules = useMemo(() => {
const groups = new Map<string, ModuleEntry[]>();
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
// Initialize groups | 初始化分组
for (const cat of categoryOrder) {
groups.set(cat, []);
}
// Group modules | 分组模块
for (const mod of moduleEntries) {
const cat = mod.category || 'Other';
if (!groups.has(cat)) {
groups.set(cat, []);
}
groups.get(cat)!.push(mod);
}
// Filter empty groups | 过滤空分组
const result = new Map<string, ModuleEntry[]>();
for (const [cat, mods] of groups) {
if (mods.length > 0) {
result.set(cat, mods);
}
}
return result;
}, [moduleEntries]);
// Calculate total size (JS + WASM) | 计算总大小(JS + WASM
const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => {
let js = 0;
let wasm = 0;
for (const m of moduleEntries) {
if (m.enabled) {
js += m.jsSize || 0;
wasm += m.wasmSize || 0;
}
}
return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm };
}, [moduleEntries]);
// Toggle category expansion | 切换分类展开
const toggleCategory = useCallback((category: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
}, []);
// Handle module toggle | 处理模块切换
const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => {
if (module.isCore) return;
// If disabling, validate first | 如果禁用,先验证
if (!enabled && validateDisable) {
setLoading(module.id);
try {
const result = await validateDisable(module.id);
if (!result.canDisable) {
setValidationError({
moduleId: module.id,
message: result.reason || `Cannot disable ${module.displayName}`
});
setLoading(null);
return;
}
} finally {
setLoading(null);
}
}
// Update module list based on mode
let newValue: string[];
if (useBlacklist) {
// Blacklist mode: value contains disabled modules
if (enabled) {
// Remove from blacklist (and also remove dependencies)
const toRemove = new Set([module.id]);
// Also enable dependencies if they were disabled
for (const depId of module.dependencies) {
toRemove.add(depId);
}
newValue = value.filter(id => !toRemove.has(id));
} else {
// Add to blacklist
newValue = [...value, module.id];
}
} else {
// Whitelist mode: value contains enabled modules
if (enabled) {
// Add to whitelist (and dependencies)
newValue = [...value];
const toEnable = [module.id, ...module.dependencies];
for (const id of toEnable) {
if (!newValue.includes(id)) {
newValue.push(id);
}
}
} else {
// Remove from whitelist
newValue = value.filter(id => id !== module.id);
}
}
onModulesChange(newValue);
}, [value, useBlacklist, onModulesChange, validateDisable]);
return (
<div className="module-list-setting">
{/* Module categories | 模块分类 */}
<div className="module-list-categories">
{Array.from(groupedModules.entries()).map(([category, mods]) => (
<div key={category} className="module-category-group">
<div
className="module-category-header"
onClick={() => toggleCategory(category)}
>
{expandedCategories.has(category) ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<span className="module-category-name">{category}</span>
<span className="module-category-count">
{mods.filter(m => m.enabled).length}/{mods.length}
</span>
</div>
{expandedCategories.has(category) && (
<div className="module-category-items">
{mods.map(mod => (
<div
key={mod.id}
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
>
<label className="module-checkbox-label">
<input
type="checkbox"
checked={mod.enabled}
disabled={mod.isCore || loading === mod.id}
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
/>
<Package size={14} className="module-icon" />
<span className="module-name">{mod.displayName}</span>
{mod.isCore && (
<span className="module-badge core">Core</span>
)}
</label>
{(mod.jsSize || mod.wasmSize) ? (
<span className="module-size">
{mod.isCore ? '' : '+'}
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
{(mod.wasmSize ?? 0) > 0 && (
<span className="module-wasm-indicator" title="Includes WASM"></span>
)}
</span>
) : null}
</div>
))}
</div>
)}
</div>
))}
</div>
{/* Size footer | 大小页脚 */}
<div className="module-list-footer">
<span className="module-list-size-label">Runtime size:</span>
<span className="module-list-size-value">
{formatBytes(totalSize)}
{totalWasmSize > 0 && (
<span className="module-size-breakdown">
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
</span>
)}
</span>
</div>
{/* Validation error toast | 验证错误提示 */}
{validationError && (
<div className="module-validation-error">
<AlertCircle size={14} />
<span>{validationError.message}</span>
<button onClick={() => setValidationError(null)}>Dismiss</button>
</div>
)}
</div>
);
};
export default ModuleListSetting;
@@ -0,0 +1,27 @@
import React from 'react';
import * as LucideIcons from 'lucide-react';
interface NodeIconProps {
iconName?: string;
size?: number;
color?: string;
}
/**
*
*
* Lucide
*/
export const NodeIcon: React.FC<NodeIconProps> = ({ iconName, size = 16, color }) => {
if (!iconName) {
return null;
}
const IconComponent = (LucideIcons as any)[iconName];
if (!IconComponent) {
return <span>{iconName}</span>;
}
return <IconComponent size={size} color={color} />;
};
@@ -0,0 +1,471 @@
import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
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, Copy
} from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import '../styles/OutputLogPanel.css';
interface OutputLogPanelProps {
logService: LogService;
locale?: string;
onClose?: () => void;
}
const MAX_LOGS = 1000;
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, size: number = 14) {
switch (level) {
case LogLevel.Debug:
return <Bug size={size} />;
case LogLevel.Info:
return <Info size={size} />;
case LogLevel.Warn:
return <AlertTriangle size={size} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={size} />;
default:
return <AlertCircle size={size} />;
}
}
function getLevelClass(level: LogLevel): string {
switch (level) {
case LogLevel.Debug:
return 'output-log-entry-debug';
case LogLevel.Info:
return 'output-log-entry-info';
case LogLevel.Warn:
return 'output-log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'output-log-entry-error';
default:
return '';
}
}
/**
*
*/
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;
isExpanded: boolean;
onToggle: () => void;
onCopy: () => void;
}) => {
// 优先使用 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)} ${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>
{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>
);
});
LogEntryItem.displayName = 'LogEntryItem';
export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) {
const { t } = useLocale();
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
const [searchQuery, setSearchQuery] = 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);
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
const logContainerRef = useRef<HTMLDivElement>(null);
const filterMenuRef = useRef<HTMLDivElement>(null);
const settingsMenuRef = 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]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
setShowFilterMenu(false);
}
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
setShowSettingsMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleScroll = useCallback(() => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
}
}, []);
const handleClear = useCallback(() => {
logService.clear();
setLogs([]);
setExpandedLogIds(new Set());
}, [logService]);
const toggleLevelFilter = useCallback((level: LogLevel) => {
setLevelFilter((prev) => {
const newFilter = new Set(prev);
if (newFilter.has(level)) {
newFilter.delete(level);
} else {
newFilter.add(level);
}
return newFilter;
});
}, []);
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;
if (showRemoteOnly && log.source !== 'remote') return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!log.message.toLowerCase().includes(query) &&
!log.source.toLowerCase().includes(query)) {
return false;
}
}
return true;
});
}, [logs, levelFilter, showRemoteOnly, searchQuery]);
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]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (!levelFilter.has(LogLevel.Debug)) count++;
if (!levelFilter.has(LogLevel.Info)) count++;
if (!levelFilter.has(LogLevel.Warn)) count++;
if (!levelFilter.has(LogLevel.Error)) count++;
if (showRemoteOnly) count++;
return count;
}, [levelFilter, showRemoteOnly]);
return (
<div className="output-log-panel">
{/* Toolbar */}
<div className="output-log-toolbar">
<div className="output-log-toolbar-left">
<div className="output-log-search">
<Search size={14} />
<input
type="text"
placeholder={t('outputLog.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
className="output-log-search-clear"
onClick={() => setSearchQuery('')}
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="output-log-toolbar-right">
{/* Filter Dropdown */}
<div className="output-log-dropdown" ref={filterMenuRef}>
<button
className={`output-log-btn ${showFilterMenu ? 'active' : ''} ${activeFilterCount > 0 ? 'has-filter' : ''}`}
onClick={() => {
setShowFilterMenu(!showFilterMenu);
setShowSettingsMenu(false);
}}
>
<Filter size={14} />
<span>{t('outputLog.filters')}</span>
{activeFilterCount > 0 && (
<span className="filter-badge">{activeFilterCount}</span>
)}
<ChevronDown size={12} />
</button>
{showFilterMenu && (
<div className="output-log-menu">
<div className="output-log-menu-header">
{t('outputLog.logLevels')}
</div>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Debug)}
onChange={() => toggleLevelFilter(LogLevel.Debug)}
/>
<Bug size={14} className="level-icon debug" />
<span>Debug</span>
<span className="level-count">{levelCounts[LogLevel.Debug]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Info)}
onChange={() => toggleLevelFilter(LogLevel.Info)}
/>
<Info size={14} className="level-icon info" />
<span>Info</span>
<span className="level-count">{levelCounts[LogLevel.Info]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Warn)}
onChange={() => toggleLevelFilter(LogLevel.Warn)}
/>
<AlertTriangle size={14} className="level-icon warn" />
<span>Warning</span>
<span className="level-count">{levelCounts[LogLevel.Warn]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Error)}
onChange={() => toggleLevelFilter(LogLevel.Error)}
/>
<XCircle size={14} className="level-icon error" />
<span>Error</span>
<span className="level-count">{levelCounts[LogLevel.Error]}</span>
</label>
<div className="output-log-menu-divider" />
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showRemoteOnly}
onChange={() => setShowRemoteOnly(!showRemoteOnly)}
/>
<Wifi size={14} className="level-icon remote" />
<span>{t('outputLog.remoteOnly')}</span>
<span className="level-count">{remoteLogCount}</span>
</label>
</div>
)}
</div>
{/* Auto Scroll Toggle */}
<button
className={`output-log-icon-btn ${autoScroll ? 'active' : ''}`}
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll
? t('outputLog.pauseAutoScroll')
: t('outputLog.resumeAutoScroll')
}
>
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
</button>
{/* Settings Dropdown */}
<div className="output-log-dropdown" ref={settingsMenuRef}>
<button
className={`output-log-icon-btn ${showSettingsMenu ? 'active' : ''}`}
onClick={() => {
setShowSettingsMenu(!showSettingsMenu);
setShowFilterMenu(false);
}}
title={t('outputLog.settings')}
>
<Settings size={14} />
</button>
{showSettingsMenu && (
<div className="output-log-menu settings-menu">
<button
className="output-log-menu-action"
onClick={handleClear}
>
<Trash2 size={14} />
<span>{t('outputLog.clearLogs')}</span>
</button>
</div>
)}
</div>
{/* Close Button */}
{onClose && (
<button
className="output-log-close-btn"
onClick={onClose}
>
<X size={14} />
</button>
)}
</div>
</div>
{/* Log Content */}
<div
className="output-log-content"
ref={logContainerRef}
onScroll={handleScroll}
>
{filteredLogs.length === 0 ? (
<div className="output-log-empty">
<AlertCircle size={32} />
<p>{searchQuery
? t('outputLog.noMatchingLogs')
: t('outputLog.noLogs')
}</p>
</div>
) : (
filteredLogs.map((log, index) => (
<LogEntryItem
key={`${log.id}-${index}`}
log={log}
isExpanded={expandedLogIds.has(String(log.id))}
onToggle={() => toggleLogExpanded(String(log.id))}
onCopy={() => handleCopyLog(log)}
/>
))
)}
</div>
{/* Status Bar */}
<div className="output-log-status">
<span>{filteredLogs.length} / {logs.length} {t('outputLog.logs')}</span>
{!autoScroll && (
<button
className="output-log-scroll-btn"
onClick={() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}}
>
{t('outputLog.scrollToBottom')}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import React from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { PluginError } from '../domain/errors';
interface PluginErrorBoundaryProps {
pluginId: string;
pluginName: string;
children: React.ReactNode;
onPluginError?: (pluginId: string, error: Error) => void;
}
export function PluginErrorBoundary({
pluginId,
pluginName,
children,
onPluginError
}: PluginErrorBoundaryProps): JSX.Element {
const handleError = (error: Error) => {
onPluginError?.(pluginId, error);
};
const renderFallback = (error: Error) => {
const pluginError =
error instanceof PluginError ? error : new PluginError(error.message, pluginId, pluginName, 'execute', error);
return (
<div
style={{
padding: '16px',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
margin: '10px'
}}
>
<h3 style={{ margin: '0 0 8px 0', color: '#856404' }}></h3>
<p style={{ margin: '0 0 8px 0' }}>{pluginError.getUserMessage()}</p>
<small style={{ color: '#666' }}>ID: {pluginId}</small>
</div>
);
};
return (
<ErrorBoundary fallback={renderFallback} onError={handleError}>
{children}
</ErrorBoundary>
);
}
@@ -0,0 +1,189 @@
import { useState } from 'react';
import { X, FolderOpen } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
import { useLocale } from '../hooks/useLocale';
import '../styles/PluginGeneratorWindow.css';
interface PluginGeneratorWindowProps {
onClose: () => void;
projectPath: string | null;
onSuccess?: () => Promise<void>;
}
export function PluginGeneratorWindow({ onClose, projectPath, onSuccess }: PluginGeneratorWindowProps) {
const { t } = useLocale();
const [pluginName, setPluginName] = useState('');
const [pluginVersion, setPluginVersion] = useState('1.0.0');
const [outputPath, setOutputPath] = useState(projectPath ? `${projectPath}/plugins` : '');
const [includeExample, setIncludeExample] = useState(true);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectPath = async () => {
try {
const selected = await TauriAPI.openProjectDialog();
if (selected) {
setOutputPath(selected);
}
} catch (error) {
console.error('Failed to select path:', error);
}
};
const validatePluginName = (name: string): boolean => {
if (!name) {
setError(t('pluginGenerator.errorEmpty'));
return false;
}
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
setError(t('pluginGenerator.errorInvalidName'));
return false;
}
return true;
};
const handleGenerate = async () => {
setError(null);
if (!validatePluginName(pluginName)) {
return;
}
if (!outputPath) {
setError(t('pluginGenerator.errorNoPath'));
return;
}
setIsGenerating(true);
try {
const response = await fetch('/@plugin-generator', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pluginName,
pluginVersion,
outputPath,
includeExample
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate plugin');
}
const result = await response.json();
alert(t('pluginGenerator.success'));
if (result.path) {
await TauriAPI.showInFolder(result.path);
}
if (onSuccess) {
await onSuccess();
}
onClose();
} catch (error) {
console.error('Failed to generate plugin:', error);
setError(error instanceof Error ? error.message : String(error));
} finally {
setIsGenerating(false);
}
};
return (
<div className="modal-overlay">
<div className="modal-content plugin-generator-window">
<div className="modal-header">
<h2>{t('pluginGenerator.title')}</h2>
<button className="close-btn" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>{t('pluginGenerator.pluginName')}</label>
<input
type="text"
value={pluginName}
onChange={(e) => setPluginName(e.target.value)}
placeholder={t('pluginGenerator.pluginNamePlaceholder')}
disabled={isGenerating}
/>
</div>
<div className="form-group">
<label>{t('pluginGenerator.pluginVersion')}</label>
<input
type="text"
value={pluginVersion}
onChange={(e) => setPluginVersion(e.target.value)}
disabled={isGenerating}
/>
</div>
<div className="form-group">
<label>{t('pluginGenerator.outputPath')}</label>
<div className="path-input-group">
<input
type="text"
value={outputPath}
onChange={(e) => setOutputPath(e.target.value)}
disabled={isGenerating}
/>
<button
className="select-path-btn"
onClick={handleSelectPath}
disabled={isGenerating}
>
<FolderOpen size={16} />
{t('pluginGenerator.selectPath')}
</button>
</div>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={includeExample}
onChange={(e) => setIncludeExample(e.target.checked)}
disabled={isGenerating}
/>
<span>{t('pluginGenerator.includeExample')}</span>
</label>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button
className="btn btn-primary"
onClick={handleGenerate}
disabled={isGenerating}
>
{isGenerating ? t('pluginGenerator.generating') : t('pluginGenerator.generate')}
</button>
<button
className="btn btn-secondary"
onClick={onClose}
disabled={isGenerating}
>
{t('pluginGenerator.cancel')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
/**
* Plugin List Setting Component
*
*
*
* -
* -
* -
* - [Runtime] [Editor]
*/
import { useState, useEffect } from 'react';
import { Core } from '@esengine/ecs-framework';
import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core';
import { Check, Lock, Package } from 'lucide-react';
import { NotificationService } from '../services/NotificationService';
import '../styles/PluginListSetting.css';
interface PluginListSettingProps {
pluginManager: PluginManager;
}
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
Core: { zh: '核心', en: 'Core' },
Rendering: { zh: '渲染', en: 'Rendering' },
Physics: { zh: '物理', en: 'Physics' },
AI: { zh: 'AI', en: 'AI' },
Audio: { zh: '音频', en: 'Audio' },
Networking: { zh: '网络', en: 'Networking' },
Other: { zh: '其他', en: 'Other' }
};
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
useEffect(() => {
loadPlugins();
}, [pluginManager]);
const loadPlugins = () => {
const allPlugins = pluginManager.getAllPlugins();
setPlugins(allPlugins);
};
const showWarning = (message: string) => {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.show(message, 'warning', 3000);
}
};
const handleToggle = async (pluginId: string) => {
const plugin = plugins.find(p => p.plugin.manifest.id === pluginId);
if (!plugin) return;
const manifest = plugin.plugin.manifest;
// 核心插件不可禁用
if (manifest.isCore) {
showWarning('核心插件不可禁用');
return;
}
const newEnabled = !plugin.enabled;
// 检查依赖(启用时)
if (newEnabled) {
const deps = manifest.dependencies || [];
const missingDeps = deps.filter((depId: string) => {
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
return depPlugin && !depPlugin.enabled;
});
if (missingDeps.length > 0) {
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
return;
}
}
// 调用 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.plugin.manifest.id === pluginId) {
return { ...p, enabled: newEnabled };
}
return p;
}));
// 保存到项目配置
savePluginConfigToProject();
// 通知用户(如果有编辑器模块变更)
const hasEditorModule = !!plugin.plugin.editorModule;
if (hasEditorModule) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.show(
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
'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.manifest.isCore)
.map(p => p.plugin.manifest.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.plugin.manifest.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(plugin);
return acc;
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
// 按照 categoryOrder 排序
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
return (
<div className="plugin-list-setting">
{sortedCategories.map(category => (
<div key={category} className="plugin-category">
<div className="plugin-category-header">
{categoryLabels[category]?.zh || category}
</div>
<div className="plugin-list">
{groupedPlugins[category]?.map(plugin => {
const manifest = plugin.plugin.manifest;
const hasRuntime = !!plugin.plugin.runtimeModule;
const hasEditor = !!plugin.plugin.editorModule;
return (
<div
key={manifest.id}
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
onClick={() => handleToggle(manifest.id)}
>
<div className="plugin-checkbox">
{manifest.isCore ? (
<Lock size={10} />
) : (
plugin.enabled && <Check size={10} />
)}
</div>
<div className="plugin-info">
<div className="plugin-header">
<span className="plugin-name">{manifest.displayName}</span>
<span className="plugin-version">v{manifest.version}</span>
</div>
{manifest.description && (
<div className="plugin-description">
{manifest.description}
</div>
)}
<div className="plugin-modules">
{hasRuntime && (
<span className="plugin-module-badge runtime">Runtime</span>
)}
{hasEditor && (
<span className="plugin-module-badge editor">Editor</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
{plugins.length === 0 && (
<div className="plugin-list-empty">
<Package size={32} />
<p></p>
</div>
)}
</div>
);
}
@@ -0,0 +1,165 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { X, Server, WifiOff, Wifi } from 'lucide-react';
import { SettingsService } from '../services/SettingsService';
import { getProfilerService } from '../services/getService';
import '../styles/PortManager.css';
interface PortManagerProps {
onClose: () => void;
}
export function PortManager({ onClose }: PortManagerProps) {
const [isServerRunning, setIsServerRunning] = useState(false);
const [serverPort, setServerPort] = useState<string>('8080');
const [isChecking, setIsChecking] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [isStarting, setIsStarting] = useState(false);
useEffect(() => {
const settings = SettingsService.getInstance();
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 !== undefined) {
console.log('[PortManager] Updating port to:', newPort);
setServerPort(String(newPort));
}
}) as EventListener;
window.addEventListener('settings:changed', handleSettingsChange);
return () => {
window.removeEventListener('settings:changed', handleSettingsChange);
};
}, []);
useEffect(() => {
checkServerStatus();
}, []);
const checkServerStatus = async () => {
setIsChecking(true);
try {
const status = await invoke<boolean>('get_profiler_status');
setIsServerRunning(status);
} catch (error) {
console.error('[PortManager] Failed to check server status:', error);
setIsServerRunning(false);
} finally {
setIsChecking(false);
}
};
const handleStopServer = async () => {
setIsStopping(true);
try {
const profilerService = getProfilerService();
if (profilerService) {
await profilerService.manualStopServer();
setIsServerRunning(false);
}
} catch (error) {
console.error('[PortManager] Failed to stop server:', error);
} finally {
setIsStopping(false);
}
};
const handleStartServer = async () => {
setIsStarting(true);
try {
const profilerService = getProfilerService();
if (profilerService) {
await profilerService.manualStartServer();
await new Promise((resolve) => setTimeout(resolve, 500));
await checkServerStatus();
}
} catch (error) {
console.error('[PortManager] Failed to start server:', error);
} finally {
setIsStarting(false);
}
};
return (
<div className="port-manager-overlay" onClick={onClose}>
<div className="port-manager" onClick={(e) => e.stopPropagation()}>
<div className="port-manager-header">
<div className="port-manager-title">
<Server size={20} />
<h2>Port Manager</h2>
</div>
<button className="port-manager-close" onClick={onClose} title="Close">
<X size={20} />
</button>
</div>
<div className="port-manager-content">
<div className="port-section">
<h3>Profiler Server</h3>
<div className="port-info">
<div className="port-item">
<span className="port-label">Status:</span>
<span className={`port-status ${isServerRunning ? 'running' : 'stopped'}`}>
{isChecking ? 'Checking...' : isServerRunning ? 'Running' : 'Stopped'}
</span>
</div>
{isServerRunning && (
<div className="port-item">
<span className="port-label">Port:</span>
<span className="port-value">{serverPort}</span>
</div>
)}
</div>
{isServerRunning && (
<div className="port-actions">
<button
className="action-btn danger"
onClick={handleStopServer}
disabled={isStopping}
>
<WifiOff size={16} />
<span>{isStopping ? 'Stopping...' : 'Stop Server'}</span>
</button>
</div>
)}
{!isServerRunning && (
<>
<div className="port-actions">
<button
className="action-btn primary"
onClick={handleStartServer}
disabled={isStarting}
>
<Wifi size={16} />
<span>{isStarting ? 'Starting...' : 'Start Server'}</span>
</button>
</div>
<div className="port-hint">
<p>No server is currently running.</p>
<p className="hint-text">Click "Start Server" to start the profiler server.</p>
</div>
</>
)}
</div>
<div className="port-tips">
<h4>Tips</h4>
<ul>
<li>Use this when the Profiler server port is stuck and cannot be restarted</li>
<li>The server will automatically stop when the Profiler window is closed</li>
<li>Current configured port: {serverPort}</li>
</ul>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import { useState } from 'react';
import { Folder, Sparkles, X } from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import '../styles/ProjectCreationWizard.css';
// 项目模板类型(使用翻译键)
// Project template type (using translation keys)
interface ProjectTemplate {
id: string;
nameKey: string; // 翻译键 | Translation key
descriptionKey: string;
}
const templates: ProjectTemplate[] = [
{
id: 'blank',
nameKey: 'project.wizard.templates.blank',
descriptionKey: 'project.wizard.templates.blankDesc'
}
];
interface ProjectCreationWizardProps {
isOpen: boolean;
onClose: () => void;
onCreateProject: (projectName: string, projectPath: string, templateId: string) => void;
onBrowsePath: () => Promise<string | null>;
locale: string;
}
export function ProjectCreationWizard({
isOpen,
onClose,
onCreateProject,
onBrowsePath,
locale: _locale
}: ProjectCreationWizardProps) {
const { t } = useLocale();
const [selectedTemplate, setSelectedTemplate] = useState<string>('blank');
const [projectName, setProjectName] = useState('MyProject');
const [projectPath, setProjectPath] = useState('');
if (!isOpen) return null;
const currentTemplate = templates.find(tmpl => tmpl.id === selectedTemplate);
const handleBrowse = async () => {
const path = await onBrowsePath();
if (path) {
setProjectPath(path);
}
};
const handleCreate = () => {
if (projectName && projectPath) {
onCreateProject(projectName, projectPath, selectedTemplate);
onClose();
}
};
return (
<div className="project-wizard-overlay">
<div className="project-wizard">
<div className="wizard-header">
<h1>{t('project.wizard.title')}</h1>
<button className="wizard-close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="wizard-body">
{/* Templates grid */}
<div className="wizard-templates">
<div className="templates-header">
<h3>{t('project.wizard.selectTemplate')}</h3>
</div>
<div className="templates-grid">
{templates.map(template => (
<button
key={template.id}
className={`template-card ${selectedTemplate === template.id ? 'selected' : ''}`}
onClick={() => setSelectedTemplate(template.id)}
>
<div className="template-preview">
<Sparkles size={32} />
</div>
<div className="template-name">
{t(template.nameKey)}
</div>
</button>
))}
</div>
</div>
{/* Right - Preview and settings */}
<div className="wizard-details">
<div className="details-preview">
<div className="preview-placeholder">
<Sparkles size={48} />
</div>
</div>
<div className="details-info">
<h2>{currentTemplate ? t(currentTemplate.nameKey) : ''}</h2>
<p>{currentTemplate ? t(currentTemplate.descriptionKey) : ''}</p>
</div>
<div className="details-settings">
<h3>{t('project.wizard.projectSettings')}</h3>
<div className="setting-field">
<label>{t('project.wizard.projectName')}</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="MyProject"
/>
</div>
<div className="setting-field">
<label>{t('project.wizard.projectLocation')}</label>
<div className="path-input-group">
<input
type="text"
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
placeholder="D:\Projects"
/>
<button className="browse-btn" onClick={handleBrowse}>
<Folder size={14} />
</button>
</div>
</div>
</div>
</div>
</div>
<div className="wizard-footer">
<button className="wizard-btn secondary" onClick={onClose}>
{t('project.wizard.cancel')}
</button>
<button
className="wizard-btn primary"
onClick={handleCreate}
disabled={!projectName || !projectPath}
>
{t('project.wizard.create')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import '../styles/PromptDialog.css';
interface PromptDialogProps {
title: string;
message: string;
defaultValue?: string;
placeholder?: string;
confirmText: string;
cancelText: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
export function PromptDialog({
title,
message,
defaultValue = '',
placeholder,
confirmText,
cancelText,
onConfirm,
onCancel
}: PromptDialogProps) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleConfirm = () => {
if (value.trim()) {
onConfirm(value.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
return (
<div className="prompt-dialog-overlay" onClick={onCancel}>
<div className="prompt-dialog" onClick={(e) => e.stopPropagation()}>
<div className="prompt-dialog-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onCancel}>
<X size={16} />
</button>
</div>
<div className="prompt-dialog-content">
<p>{message}</p>
<input
ref={inputRef}
type="text"
className="prompt-dialog-input"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
</div>
<div className="prompt-dialog-footer">
<button className="prompt-dialog-btn cancel" onClick={onCancel}>
{cancelText}
</button>
<button
className="prompt-dialog-btn confirm"
onClick={handleConfirm}
disabled={!value.trim()}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { X, Copy, Check } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
import '../styles/QRCodeDialog.css';
interface QRCodeDialogProps {
url: string;
isOpen: boolean;
onClose: () => void;
}
export const QRCodeDialog: React.FC<QRCodeDialogProps> = ({ url, isOpen, onClose }) => {
const [qrCodeData, setQrCodeData] = useState<string>('');
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && url) {
setLoading(true);
TauriAPI.generateQRCode(url)
.then((base64) => {
setQrCodeData(`data:image/png;base64,${base64}`);
})
.catch((error) => {
console.error('Failed to generate QR code:', error);
})
.finally(() => {
setLoading(false);
});
}
}, [isOpen, url]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
if (!isOpen) return null;
return (
<div className="qrcode-dialog-overlay">
<div className="qrcode-dialog">
<div className="qrcode-dialog-header">
<h3></h3>
<button className="qrcode-dialog-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="qrcode-dialog-content">
{loading ? (
<div className="qrcode-loading">...</div>
) : qrCodeData ? (
<img src={qrCodeData} alt="QR Code" width={200} height={200} />
) : (
<div className="qrcode-error"></div>
)}
<div className="qrcode-url-container">
<input
type="text"
value={url}
readOnly
className="qrcode-url-input"
/>
<button
className="qrcode-copy-button"
onClick={handleCopy}
title={copied ? '已复制' : '复制链接'}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<p className="qrcode-hint">
</p>
</div>
</div>
</div>
);
};
export default QRCodeDialog;
@@ -0,0 +1,172 @@
import { useState, useRef, useEffect, ReactNode } from 'react';
import '../styles/ResizablePanel.css';
interface ResizablePanelProps {
direction: 'horizontal' | 'vertical';
leftOrTop: ReactNode;
rightOrBottom: ReactNode;
defaultSize?: number;
minSize?: number;
maxSize?: number;
side?: 'left' | 'right' | 'top' | 'bottom';
storageKey?: string;
}
export function ResizablePanel({
direction,
leftOrTop,
rightOrBottom,
defaultSize = 250,
minSize = 150,
maxSize = 600,
side = 'left',
storageKey
}: ResizablePanelProps) {
const getInitialSize = () => {
if (storageKey) {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsedSize = parseInt(saved, 10);
if (!isNaN(parsedSize)) {
return Math.max(minSize, Math.min(maxSize, parsedSize));
}
}
}
return defaultSize;
};
const [size, setSize] = useState(getInitialSize);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (storageKey && !isDragging) {
localStorage.setItem(storageKey, size.toString());
}
}, [size, isDragging, storageKey]);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let newSize: number;
if (direction === 'horizontal') {
if (side === 'right') {
newSize = rect.right - e.clientX;
} else {
newSize = e.clientX - rect.left;
}
} else {
if (side === 'bottom') {
newSize = rect.bottom - e.clientY;
} else {
newSize = e.clientY - rect.top;
}
}
newSize = Math.max(minSize, Math.min(maxSize, newSize));
setSize(newSize);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, direction, minSize, maxSize, side]);
const handleMouseDown = () => {
setIsDragging(true);
};
const className = `resizable-panel resizable-panel-${direction}`;
const resizerClassName = `resizer resizer-${direction}`;
if (direction === 'horizontal') {
if (side === 'right') {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ flex: 1 }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ width: `${size}px` }}>
{rightOrBottom}
</div>
</div>
);
} else {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ width: `${size}px` }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ flex: 1 }}>
{rightOrBottom}
</div>
</div>
);
}
} else {
if (side === 'bottom') {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ flex: 1 }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ height: `${size}px` }}>
{rightOrBottom}
</div>
</div>
);
} else {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ height: `${size}px` }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ flex: 1 }}>
{rightOrBottom}
</div>
</div>
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,725 @@
/**
* Settings Window -
* 稿
*/
import { useState, useEffect, useMemo } from 'react';
import {
X,
Search,
Settings as SettingsIcon,
ChevronDown,
ChevronRight
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { SettingsService } from '../services/SettingsService';
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest, isTranslationKey, getTranslationKey } from '@esengine/editor-core';
import { PluginListSetting } from './PluginListSetting';
import { ModuleListSetting } from './ModuleListSetting';
import { useLocale } from '../hooks/useLocale';
import '../styles/SettingsWindow.css';
interface SettingsWindowProps {
onClose: () => void;
settingsRegistry: SettingsRegistry;
initialCategoryId?: string;
}
// 主分类结构
interface MainCategory {
id: string;
title: string;
subCategories: SettingCategory[];
}
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
const { t } = useLocale();
/**
* Resolve localizable text - if it starts with '$', treat as translation key
* - '$'
*/
const resolveText = (text: string | undefined): string => {
if (!text) return '';
if (isTranslationKey(text)) {
return t(getTranslationKey(text));
}
return text;
};
const [categories, setCategories] = useState<SettingCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(initialCategoryId || null);
const [values, setValues] = useState<Map<string, any>>(new Map());
const [errors, setErrors] = useState<Map<string, string>>(new Map());
const [searchTerm, setSearchTerm] = useState('');
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [expandedMainCategories, setExpandedMainCategories] = useState<Set<string>>(new Set(['general']));
// 将分类组织成主分类和子分类
// Organize categories into main categories and sub-categories
const mainCategories = useMemo((): MainCategory[] => {
const categoryMap = new Map<string, SettingCategory[]>();
// 定义主分类映射(使用配置 ID 作为键)
// Main category mapping (using config IDs as keys)
const mainCategoryMapping: Record<string, string> = {
'appearance': 'general',
'general': 'general',
'project': 'general',
'plugins': 'general',
'editor': 'general',
'physics': 'global',
'rendering': 'global',
'audio': 'global',
'world': 'worldPartition',
'local': 'worldPartitionLocal',
'performance': 'performance'
};
categories.forEach((cat) => {
const mainCatId = mainCategoryMapping[cat.id] || 'other';
if (!categoryMap.has(mainCatId)) {
categoryMap.set(mainCatId, []);
}
categoryMap.get(mainCatId)!.push(cat);
});
// 定义固定的主分类顺序(使用配置 ID)
// Define fixed main category order (using config IDs)
const orderedMainCategories = [
'general',
'global',
'worldPartition',
'worldPartitionLocal',
'performance',
'other'
];
// 主分类 ID 到翻译键的映射
// Main category ID to translation key mapping
const categoryTranslationKeys: Record<string, string> = {
'general': 'settingsWindow.mainCategories.general',
'global': 'settingsWindow.mainCategories.global',
'worldPartition': 'settingsWindow.mainCategories.worldPartition',
'worldPartitionLocal': 'settingsWindow.mainCategories.worldPartitionLocal',
'performance': 'settingsWindow.mainCategories.performance',
'other': 'settingsWindow.mainCategories.other'
};
return orderedMainCategories
.filter((id) => categoryMap.has(id))
.map((id) => ({
id,
title: t(categoryTranslationKeys[id] || 'settingsWindow.mainCategories.other'),
subCategories: categoryMap.get(id)!
}));
}, [categories, t]);
// 获取显示的子分类标题
const subCategoryTitle = useMemo(() => {
if (!selectedCategoryId) return '';
const cat = categories.find((c) => c.id === selectedCategoryId);
return cat?.title || '';
}, [categories, selectedCategoryId]);
// 获取主分类标题
const mainCategoryTitle = useMemo(() => {
for (const main of mainCategories) {
if (main.subCategories.some((sub) => sub.id === selectedCategoryId)) {
return main.title;
}
}
return '';
}, [mainCategories, selectedCategoryId]);
useEffect(() => {
const allCategories = settingsRegistry.getAllCategories();
setCategories(allCategories);
// 默认展开所有section
const allSectionIds = new Set<string>();
allCategories.forEach((cat) => {
cat.sections.forEach((section) => {
allSectionIds.add(`${cat.id}-${section.id}`);
});
});
setExpandedSections(allSectionIds);
if (allCategories.length > 0 && !selectedCategoryId) {
if (initialCategoryId && allCategories.some((c) => c.id === initialCategoryId)) {
setSelectedCategoryId(initialCategoryId);
} else {
const firstCategory = allCategories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
}
}
}
const settings = SettingsService.getInstance();
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const allSettings = settingsRegistry.getAllSettings();
const initialValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
// 特定的 project 设置需要从 ProjectService 加载
// Specific project settings need to load from ProjectService
if (key === 'project.uiDesignResolution.width' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.width);
} else if (key === 'project.uiDesignResolution.height' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.height);
} else if (key === 'project.uiDesignResolution.preset' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else if (key === 'project.disabledModules' && projectService) {
// Load disabled modules from ProjectService
initialValues.set(key, projectService.getDisabledModules());
} else {
// 其他设置(包括 project.dynamicAtlas.*)从 SettingsService 加载
// Other settings (including project.dynamicAtlas.*) load from SettingsService
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
}
}
setValues(initialValues);
}, [settingsRegistry, initialCategoryId]);
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
const newValues = new Map(values);
newValues.set(key, value);
// When preset is selected, also update width and height values
// 当选择预设时,同时更新宽度和高度值
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
const [w, h] = value.split('x').map(Number);
if (w && h) {
newValues.set('project.uiDesignResolution.width', w);
newValues.set('project.uiDesignResolution.height', h);
}
}
setValues(newValues);
const newErrors = new Map(errors);
if (!settingsRegistry.validateSetting(descriptor, value)) {
newErrors.set(key, descriptor.validator?.errorMessage || t('settingsWindow.invalidValue'));
setErrors(newErrors);
return; // 验证失败,不保存 | Validation failed, don't save
} else {
newErrors.delete(key);
}
setErrors(newErrors);
// 实时保存设置
// Real-time save settings
const settings = SettingsService.getInstance();
// 除了特定的 project 设置需要延迟保存外,其他都实时保存
// Save in real-time except for specific project settings that need deferred save
const deferredProjectSettings = [
'project.uiDesignResolution.',
'project.disabledModules'
];
const shouldDeferSave = deferredProjectSettings.some(prefix => key.startsWith(prefix));
if (!shouldDeferSave) {
settings.set(key, value);
// 触发设置变更事件
// Trigger settings changed event
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: { [key]: value }
}));
}
};
const handleSave = async () => {
if (errors.size > 0) {
return;
}
const settings = SettingsService.getInstance();
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const changedSettings: Record<string, any> = {};
// Get width and height directly from values - these are the actual UI input values
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
const widthFromValues = values.get('project.uiDesignResolution.width');
const heightFromValues = values.get('project.uiDesignResolution.height');
// Use the width/height values directly (they are always set from either user input or initial load)
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
// Check if resolution differs from saved config
// 检查分辨率是否与保存的配置不同
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
let disabledModulesChanged = false;
let newDisabledModules: string[] = [];
for (const [key, value] of values.entries()) {
if (key.startsWith('project.') && projectService) {
if (key === 'project.disabledModules') {
newDisabledModules = value as string[];
disabledModulesChanged = true;
}
changedSettings[key] = value;
} else {
settings.set(key, value);
changedSettings[key] = value;
}
}
if (uiResolutionChanged && projectService) {
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
}
if (disabledModulesChanged && projectService) {
await projectService.setDisabledModules(newDisabledModules);
}
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: changedSettings
}));
onClose();
};
const handleCancel = () => {
onClose();
};
const handleResetToDefault = () => {
const allSettings = settingsRegistry.getAllSettings();
const defaultValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
defaultValues.set(key, descriptor.defaultValue);
}
setValues(defaultValues);
};
const handleExport = () => {
const exportData: Record<string, any> = {};
for (const [key, value] of values.entries()) {
exportData[key] = value;
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'editor-settings.json';
a.click();
URL.revokeObjectURL(url);
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const importData = JSON.parse(text);
const newValues = new Map(values);
for (const [key, value] of Object.entries(importData)) {
newValues.set(key, value);
}
setValues(newValues);
} catch (err) {
console.error('Failed to import settings:', err);
}
};
input.click();
};
const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
};
const toggleMainCategory = (categoryId: string) => {
setExpandedMainCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
const renderSettingInput = (setting: SettingDescriptor) => {
const value = values.get(setting.key) ?? setting.defaultValue;
const error = errors.get(setting.key);
switch (setting.type) {
case 'boolean':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<input
type="checkbox"
className="settings-checkbox"
checked={value}
onChange={(e) => handleValueChange(setting.key, e.target.checked, setting)}
/>
</div>
</div>
);
case 'number':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<input
type="number"
className={`settings-number-input ${error ? 'error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
placeholder={resolveText(setting.placeholder)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
</div>
</div>
);
case 'string':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<input
type="text"
className={`settings-text-input ${error ? 'error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
placeholder={resolveText(setting.placeholder)}
/>
</div>
</div>
);
case 'select':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<select
className={`settings-select ${error ? 'error' : ''}`}
value={value}
onChange={(e) => {
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
if (option) {
handleValueChange(setting.key, option.value, setting);
}
}}
>
{setting.options?.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{resolveText(option.label)}
</option>
))}
</select>
</div>
</div>
);
case 'range':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<input
type="range"
className="settings-range"
value={value}
onChange={(e) => handleValueChange(setting.key, parseFloat(e.target.value), setting)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
<span className="settings-range-value">{value}</span>
</div>
</div>
);
case 'color':
return (
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{resolveText(setting.label)}</span>
</div>
<div className="settings-row-value">
<div className="settings-color-bar" style={{ backgroundColor: value }}>
<input
type="color"
className="settings-color-input"
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
/>
</div>
</div>
</div>
);
case 'pluginList': {
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
if (!pluginManager) {
return (
<div className="settings-row">
<p className="settings-error">{t('settingsWindow.pluginManagerUnavailable')}</p>
</div>
);
}
return (
<div className="settings-plugin-list">
<PluginListSetting pluginManager={pluginManager} />
</div>
);
}
case 'collisionMatrix': {
const CustomRenderer = setting.customRenderer as React.ComponentType<any> | undefined;
if (CustomRenderer) {
return (
<div className="settings-custom-renderer">
<CustomRenderer />
</div>
);
}
return (
<div className="settings-row">
<p className="settings-hint">{t('settingsWindow.collisionMatrixNotConfigured')}</p>
</div>
);
}
case 'moduleList': {
// Get module data from setting's custom props
// 从设置的自定义属性获取模块数据
const moduleData = setting as SettingDescriptor & {
modules?: ModuleManifest[];
getModules?: () => ModuleManifest[];
useBlacklist?: boolean;
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
};
const moduleValue = value as string[] || [];
return (
<div className="settings-module-list">
<ModuleListSetting
modules={moduleData.modules}
getModules={moduleData.getModules}
value={moduleValue}
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
useBlacklist={moduleData.useBlacklist}
validateDisable={moduleData.validateDisable}
/>
</div>
);
}
default:
return null;
}
};
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
return (
<div className="settings-overlay" onClick={handleSave}>
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
{/* Left Sidebar */}
<div className="settings-sidebar-new">
<div className="settings-sidebar-header">
<SettingsIcon size={16} />
<span>{t('settingsWindow.editorPreferences')}</span>
<button className="settings-sidebar-close" onClick={handleSave}>
<X size={14} />
</button>
</div>
<div className="settings-sidebar-search">
<span>{t('settingsWindow.allSettings')}</span>
</div>
<div className="settings-sidebar-categories">
{mainCategories.map((mainCat) => (
<div key={mainCat.id} className="settings-main-category">
<div
className="settings-main-category-header"
onClick={() => toggleMainCategory(mainCat.id)}
>
{expandedMainCategories.has(mainCat.id) ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>{mainCat.title}</span>
</div>
{expandedMainCategories.has(mainCat.id) && (
<div className="settings-sub-categories">
{mainCat.subCategories.map((subCat) => (
<button
key={subCat.id}
className={`settings-sub-category ${selectedCategoryId === subCat.id ? 'active' : ''}`}
onClick={() => setSelectedCategoryId(subCat.id)}
>
{resolveText(subCat.title)}
</button>
))}
</div>
)}
</div>
))}
</div>
</div>
{/* Right Content */}
<div className="settings-content-new">
{/* Top Header */}
<div className="settings-content-header">
<div className="settings-search-bar">
<Search size={14} />
<input
type="text"
placeholder={t('settingsWindow.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="settings-header-actions">
<button className="settings-icon-btn" title={t('settingsWindow.settingsBtn')}>
<SettingsIcon size={14} />
</button>
<button className="settings-action-btn" onClick={handleExport}>
{t('settingsWindow.export')}
</button>
<button className="settings-action-btn" onClick={handleImport}>
{t('settingsWindow.import')}
</button>
</div>
</div>
{/* Category Title */}
<div className="settings-category-title-bar">
<div className="settings-category-breadcrumb">
<ChevronDown size={14} />
<span className="settings-breadcrumb-main">{mainCategoryTitle}</span>
<span className="settings-breadcrumb-separator">-</span>
<span className="settings-breadcrumb-sub">{subCategoryTitle}</span>
</div>
{selectedCategory?.description && (
<p className="settings-category-desc">{resolveText(selectedCategory.description)}</p>
)}
<div className="settings-category-actions">
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
{t('settingsWindow.resetToDefault')}
</button>
<button className="settings-category-action-btn" onClick={handleExport}>
{t('settingsWindow.export')}
</button>
<button className="settings-category-action-btn" onClick={handleImport}>
{t('settingsWindow.import')}
</button>
</div>
</div>
{/* Settings Content */}
<div className="settings-sections-container">
{selectedCategory && selectedCategory.sections.map((section) => {
const sectionKey = `${selectedCategory.id}-${section.id}`;
const isExpanded = expandedSections.has(sectionKey);
return (
<div key={section.id} className="settings-section-new">
<div
className="settings-section-header-new"
onClick={() => toggleSection(sectionKey)}
>
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>{resolveText(section.title)}</span>
</div>
{isExpanded && (
<div className="settings-section-content-new">
{section.settings.map((setting) => (
<div key={setting.key}>
{renderSettingInput(setting)}
</div>
))}
</div>
)}
</div>
);
})}
{!selectedCategory && (
<div className="settings-empty-new">
<SettingsIcon size={48} />
<p>{t('settingsWindow.selectCategory')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,223 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import '../styles/StartupLogo.css';
interface Particle {
x: number;
y: number;
targetX: number;
targetY: number;
size: number;
alpha: number;
color: string;
}
interface StartupLogoProps {
onAnimationComplete: () => void;
}
// 在组件外部创建粒子数据,确保只初始化一次
let particlesCache: Particle[] | null = null;
let cacheKey: string | null = null;
function createParticles(width: number, height: number, text: string, fontSize: number): Particle[] {
const key = `${width}-${height}-${fontSize}`;
if (particlesCache && cacheKey === key) {
// 重置粒子位置
for (const p of particlesCache) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(width, height);
p.x = width / 2 + Math.cos(angle) * distance;
p.y = height / 2 + Math.sin(angle) * distance;
}
return particlesCache;
}
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return [];
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
const textMetrics = tempCtx.measureText(text);
const textWidth = textMetrics.width;
const textHeight = fontSize;
tempCanvas.width = textWidth + 40;
tempCanvas.height = textHeight + 40;
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = '#ffffff';
tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const pixels = imageData.data;
const particles: Particle[] = [];
const gap = 4;
const offsetX = (width - tempCanvas.width) / 2;
const offsetY = (height - tempCanvas.height) / 2;
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
for (let y = 0; y < tempCanvas.height; y += gap) {
for (let x = 0; x < tempCanvas.width; x += gap) {
const index = (y * tempCanvas.width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha > 128) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(width, height);
particles.push({
x: width / 2 + Math.cos(angle) * distance,
y: height / 2 + Math.sin(angle) * distance,
targetX: offsetX + x,
targetY: offsetY + y,
size: Math.random() * 2 + 1.5,
alpha: Math.random() * 0.5 + 0.5,
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
});
}
}
}
particlesCache = particles;
cacheKey = key;
return particles;
}
export function StartupLogo({ onAnimationComplete }: StartupLogoProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [fadeOut, setFadeOut] = useState(false);
const onCompleteRef = useRef(onAnimationComplete);
onCompleteRef.current = onAnimationComplete;
const startAnimation = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return () => {};
const ctx = canvas.getContext('2d');
if (!ctx) return () => {};
const dpr = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
const text = 'ESEngine';
const fontSize = Math.min(width / 6, 120);
const particles = createParticles(width, height, text, fontSize);
const startTime = performance.now();
const duration = 2000;
const glowDuration = 500; // 发光过渡时长
const holdDuration = 800;
let animationId: number | null = null;
let glowStartTime: number | null = null;
let isCancelled = false;
let timeoutId1: ReturnType<typeof setTimeout> | null = null;
let timeoutId2: ReturnType<typeof setTimeout> | null = null;
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const animate = (currentTime: number) => {
if (isCancelled) return;
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeOutQuart(progress);
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, width, height);
// 计算发光进度
let glowProgress = 0;
if (progress >= 1) {
if (glowStartTime === null) {
glowStartTime = currentTime;
}
glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1);
glowProgress = easeOutCubic(glowProgress);
}
for (const particle of particles) {
// 使用线性插值移动
const moveProgress = Math.min(easedProgress * 1.2, 1);
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
ctx.beginPath();
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3); // 粒子逐渐变淡
ctx.fill();
}
ctx.globalAlpha = 1;
// 发光文字渐变显示
if (glowProgress > 0) {
ctx.save();
ctx.shadowColor = '#4EC9B0';
ctx.shadowBlur = 20 * glowProgress;
ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`;
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
ctx.restore();
}
// 发光完成后开始淡出
if (glowProgress >= 1) {
if (!timeoutId1) {
timeoutId1 = setTimeout(() => {
if (isCancelled) return;
setFadeOut(true);
timeoutId2 = setTimeout(() => {
if (isCancelled) return;
onCompleteRef.current();
}, 500);
}, holdDuration);
}
}
if (!isCancelled && (!timeoutId1 || glowProgress < 1)) {
animationId = requestAnimationFrame(animate);
}
};
animationId = requestAnimationFrame(animate);
// 返回 cleanup 函数
return () => {
isCancelled = true;
if (animationId !== null) {
cancelAnimationFrame(animationId);
}
if (timeoutId1 !== null) {
clearTimeout(timeoutId1);
}
if (timeoutId2 !== null) {
clearTimeout(timeoutId2);
}
};
}, []);
useEffect(() => {
const cleanup = startAnimation();
return cleanup;
}, [startAnimation]);
return (
<div className={`startup-logo-container ${fadeOut ? 'fade-out' : ''}`}>
<canvas ref={canvasRef} className="startup-logo-canvas" />
</div>
);
}
@@ -0,0 +1,463 @@
import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle, Terminal } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo';
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
import { useLocale, type Locale } from '../hooks/useLocale';
import '../styles/StartupPage.css';
interface StartupPageProps {
onOpenProject: () => void;
onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void;
onRemoveRecentProject?: (projectPath: string) => void;
onDeleteProject?: (projectPath: string) => Promise<void>;
onLocaleChange?: (locale: Locale) => void;
recentProjects?: string[];
}
const LANGUAGES = [
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' }
];
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onRemoveRecentProject, onDeleteProject, onLocaleChange, recentProjects = [] }: StartupPageProps) {
const { t, locale } = useLocale();
const [showLogo, setShowLogo] = useState(true);
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
const [showLangMenu, setShowLangMenu] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; project: string } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
const [showEnvStatus, setShowEnvStatus] = useState(false);
const [showEsbuildInstall, setShowEsbuildInstall] = useState(false);
const [isInstallingEsbuild, setIsInstallingEsbuild] = useState(false);
const [installProgress, setInstallProgress] = useState('');
const [installError, setInstallError] = useState('');
const langMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (langMenuRef.current && !langMenuRef.current.contains(e.target as Node)) {
setShowLangMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
}, []);
// 启动时检查更新
useEffect(() => {
checkForUpdatesOnStartup().then((result) => {
if (result.available) {
setUpdateInfo(result);
setShowUpdateBanner(true);
}
});
}, []);
// 启动时检测开发环境
useEffect(() => {
TauriAPI.checkEnvironment().then((result) => {
setEnvCheck(result);
// 如果环境就绪,在控制台显示信息
if (result.ready) {
console.log('[Environment] Ready ✓');
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
} else {
// esbuild 未安装,显示安装对话框
console.warn('[Environment] Not ready:', result.esbuild.error);
setShowEsbuildInstall(true);
}
}).catch((error) => {
console.error('[Environment] Check failed:', error);
});
}, []);
// 监听 esbuild 安装进度事件
useEffect(() => {
let unlisten: UnlistenFn | undefined;
const setupListeners = async () => {
// 监听安装进度
unlisten = await listen<string>('esbuild-install:progress', (event) => {
setInstallProgress(event.payload);
});
// 监听安装成功
const unlistenSuccess = await listen('esbuild-install:success', async () => {
// 重新检测环境
const result = await TauriAPI.checkEnvironment();
setEnvCheck(result);
if (result.ready) {
setShowEsbuildInstall(false);
setIsInstallingEsbuild(false);
setInstallProgress('');
setInstallError('');
}
});
// 监听安装错误
const unlistenError = await listen<string>('esbuild-install:error', (event) => {
setInstallError(event.payload);
setIsInstallingEsbuild(false);
});
return () => {
unlisten?.();
unlistenSuccess();
unlistenError();
};
};
setupListeners();
return () => {
unlisten?.();
};
}, []);
// 处理 esbuild 安装
const handleInstallEsbuild = async () => {
setIsInstallingEsbuild(true);
setInstallProgress(t('startup.installingEsbuild'));
setInstallError('');
try {
await TauriAPI.installEsbuild();
// 成功会通过事件处理
} catch (error) {
console.error('[Environment] Failed to install esbuild:', error);
setInstallError(String(error));
setIsInstallingEsbuild(false);
}
};
const handleInstallUpdate = async () => {
setIsInstalling(true);
const success = await installUpdate();
if (!success) {
setIsInstalling(false);
}
// 如果成功,应用会重启,不需要处理
};
const versionText = `${t('startup.version')} ${appVersion}`;
const handleLogoComplete = () => {
setShowLogo(false);
};
return (
<div className="startup-page">
{showLogo && <StartupLogo onAnimationComplete={handleLogoComplete} />}
<div className="startup-header">
<h1 className="startup-title">{t('startup.title')}</h1>
<p className="startup-subtitle">{t('startup.subtitle')}</p>
</div>
<div className="startup-content">
<div className="startup-actions">
<button className="startup-action-btn primary" onClick={onOpenProject}>
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
</svg>
<span>{t('startup.openProject')}</span>
</button>
<button className="startup-action-btn" onClick={onCreateProject}>
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>{t('startup.createProject')}</span>
</button>
</div>
<div className="startup-recent">
<h2 className="recent-title">{t('startup.recentProjects')}</h2>
{recentProjects.length === 0 ? (
<p className="recent-empty">{t('startup.noRecentProjects')}</p>
) : (
<ul className="recent-list">
{recentProjects.map((project, index) => (
<li
key={index}
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredProject(project)}
onMouseLeave={() => setHoveredProject(null)}
onClick={() => onOpenRecentProject?.(project)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, project });
}}
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
>
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
</svg>
<div className="recent-info">
<div className="recent-name">{project.split(/[\\/]/).pop()}</div>
<div className="recent-path">{project}</div>
</div>
{onRemoveRecentProject && (
<button
className="recent-remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemoveRecentProject(project);
}}
title={t('startup.removeFromList')}
>
<Trash2 size={14} />
</button>
)}
</li>
))}
</ul>
)}
</div>
</div>
{/* 更新提示条 */}
{showUpdateBanner && updateInfo?.available && (
<div className="startup-update-banner">
<div className="update-banner-content">
<Download size={16} />
<span className="update-banner-text">
{t('startup.updateAvailable')}: v{updateInfo.version}
</span>
<button
className="update-banner-btn primary"
onClick={handleInstallUpdate}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 size={14} className="animate-spin" />
{t('startup.installing')}
</>
) : (
t('startup.updateNow')
)}
</button>
<button
className="update-banner-close"
onClick={() => setShowUpdateBanner(false)}
disabled={isInstalling}
title={t('startup.later')}
>
<X size={14} />
</button>
</div>
</div>
)}
<div className="startup-footer">
<span className="startup-version">{versionText}</span>
{/* 环境状态指示器 | Environment Status Indicator */}
{envCheck && (
<div
className={`startup-env-status ${envCheck.ready ? 'ready' : 'warning'}`}
onClick={() => setShowEnvStatus(!showEnvStatus)}
title={envCheck.ready ? t('startup.envReady') : t('startup.envNotReady')}
>
{envCheck.ready ? (
<CheckCircle size={14} />
) : (
<AlertCircle size={14} />
)}
{showEnvStatus && (
<div className="startup-env-tooltip">
<div className="env-tooltip-title">
{envCheck.ready ? t('startup.envReady') : t('startup.envNotReady')}
</div>
<div className={`env-tooltip-item ${envCheck.esbuild.available ? 'ok' : 'error'}`}>
{envCheck.esbuild.available ? (
<>
<CheckCircle size={12} />
<span>esbuild {envCheck.esbuild.version}</span>
<span className="env-source">({envCheck.esbuild.source})</span>
</>
) : (
<>
<AlertCircle size={12} />
<span>{t('startup.esbuildMissing')}</span>
</>
)}
</div>
</div>
)}
</div>
)}
{onLocaleChange && (
<div className="startup-locale-dropdown" ref={langMenuRef}>
<button
className="startup-locale-btn"
onClick={() => setShowLangMenu(!showLangMenu)}
>
<Globe size={14} />
<span>{LANGUAGES.find(l => l.code === locale)?.name || 'English'}</span>
<ChevronDown size={12} />
</button>
{showLangMenu && (
<div className="startup-locale-menu">
{LANGUAGES.map(lang => (
<button
key={lang.code}
className={`startup-locale-item ${locale === lang.code ? 'active' : ''}`}
onClick={() => {
onLocaleChange(lang.code as Locale);
setShowLangMenu(false);
}}
>
{lang.name}
</button>
))}
</div>
)}
</div>
)}
</div>
{/* 右键菜单 | Context Menu */}
{contextMenu && (
<div
className="startup-context-menu-overlay"
onClick={() => setContextMenu(null)}
>
<div
className="startup-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="startup-context-menu-item"
onClick={() => {
onRemoveRecentProject?.(contextMenu.project);
setContextMenu(null);
}}
>
<X size={14} />
<span>{t('startup.removeFromList')}</span>
</button>
{onDeleteProject && (
<button
className="startup-context-menu-item danger"
onClick={() => {
setDeleteConfirm(contextMenu.project);
setContextMenu(null);
}}
>
<Trash2 size={14} />
<span>{t('startup.deleteProject')}</span>
</button>
)}
</div>
</div>
)}
{/* 删除确认对话框 | Delete Confirmation Dialog */}
{deleteConfirm && (
<div className="startup-dialog-overlay">
<div className="startup-dialog">
<div className="startup-dialog-header">
<Trash2 size={20} className="dialog-icon-danger" />
<h3>{t('startup.deleteConfirmTitle')}</h3>
</div>
<div className="startup-dialog-body">
<p>{t('startup.deleteConfirmMessage')}</p>
<p className="startup-dialog-path">{deleteConfirm}</p>
</div>
<div className="startup-dialog-footer">
<button
className="startup-dialog-btn"
onClick={() => setDeleteConfirm(null)}
>
{t('startup.cancel')}
</button>
<button
className="startup-dialog-btn danger"
onClick={async () => {
if (deleteConfirm && onDeleteProject) {
try {
await onDeleteProject(deleteConfirm);
} catch (error) {
console.error('[StartupPage] Failed to delete project:', error);
// Error will be handled by App.tsx error dialog
}
}
setDeleteConfirm(null);
}}
>
{t('startup.delete')}
</button>
</div>
</div>
</div>
)}
{/* esbuild 安装对话框 | esbuild Installation Dialog */}
{showEsbuildInstall && (
<div className="startup-dialog-overlay">
<div className="startup-dialog">
<div className="startup-dialog-header">
<Terminal size={20} className="dialog-icon-info" />
<h3>{t('startup.esbuildNotInstalled')}</h3>
</div>
<div className="startup-dialog-body">
<p>{t('startup.esbuildRequired')}</p>
<p className="startup-dialog-info">{t('startup.esbuildInstallPrompt')}</p>
{/* 安装进度 | Installation Progress */}
{isInstallingEsbuild && (
<div className="startup-dialog-progress">
<Loader2 size={16} className="animate-spin" />
<span>{installProgress}</span>
</div>
)}
{/* 错误信息 | Error Message */}
{installError && (
<div className="startup-dialog-error">
<AlertCircle size={16} />
<span>{installError}</span>
</div>
)}
</div>
<div className="startup-dialog-footer">
<button
className="startup-dialog-btn primary"
onClick={handleInstallEsbuild}
disabled={isInstallingEsbuild}
>
{isInstallingEsbuild ? (
<>
<Loader2 size={14} className="animate-spin" />
{t('startup.installing')}
</>
) : (
<>
<Download size={14} />
{t('startup.installNow')}
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,346 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
import type { MessageHub, LogService } from '@esengine/editor-core';
import { ContentBrowser } from './ContentBrowser';
import { OutputLogPanel } from './OutputLogPanel';
import { useLocale } from '../hooks/useLocale';
import '../styles/StatusBar.css';
interface StatusBarProps {
pluginCount?: number;
entityCount?: number;
messageHub?: MessageHub | null;
logService?: LogService | null;
locale?: string;
projectPath?: string | null;
onOpenScene?: (scenePath: string) => void;
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
onDockContentBrowser?: () => void;
/** 重置布局回调 | Callback to reset layout */
onResetLayout?: () => void;
}
type ActiveTab = 'output' | 'cmd';
export function StatusBar({
pluginCount = 0,
entityCount = 0,
messageHub,
logService,
locale = 'en',
projectPath,
onOpenScene,
onDockContentBrowser,
onResetLayout
}: StatusBarProps) {
const { t } = useLocale();
const [consoleInput, setConsoleInput] = useState('');
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
const [contentDrawerOpen, setContentDrawerOpen] = useState(false);
const [outputLogDrawerOpen, setOutputLogDrawerOpen] = useState(false);
const [contentDrawerHeight, setContentDrawerHeight] = useState(300);
const [outputLogDrawerHeight, setOutputLogDrawerHeight] = useState(300);
const [isResizingContent, setIsResizingContent] = useState(false);
const [isResizingOutputLog, setIsResizingOutputLog] = useState(false);
const [revealPath, setRevealPath] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const startY = useRef(0);
const startHeight = useRef(0);
// Subscribe to asset:reveal event
useEffect(() => {
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('asset:reveal', (payload: { path: string }) => {
if (payload.path) {
// Generate unique key to force re-trigger even with same path
setRevealPath(`${payload.path}?t=${Date.now()}`);
setContentDrawerOpen(true);
setOutputLogDrawerOpen(false);
}
});
return unsubscribe;
}, [messageHub]);
// Clear revealPath when drawer closes
useEffect(() => {
if (!contentDrawerOpen) {
setRevealPath(null);
}
}, [contentDrawerOpen]);
const handleSelectPanel = useCallback((panelId: string) => {
if (messageHub) {
messageHub.publish('panel:select', { panelId });
}
}, [messageHub]);
const handleContentDrawerClick = useCallback(() => {
setContentDrawerOpen(!contentDrawerOpen);
if (!contentDrawerOpen) {
setOutputLogDrawerOpen(false);
}
}, [contentDrawerOpen]);
const handleOutputLogClick = useCallback(() => {
setActiveTab('output');
setOutputLogDrawerOpen(!outputLogDrawerOpen);
if (!outputLogDrawerOpen) {
setContentDrawerOpen(false);
}
}, [outputLogDrawerOpen]);
const handleCmdClick = useCallback(() => {
setActiveTab('cmd');
handleSelectPanel('console');
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, [handleSelectPanel]);
const handleConsoleSubmit = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && consoleInput.trim()) {
const command = consoleInput.trim();
console.info(`> ${command}`);
try {
if (command.startsWith('help')) {
console.info('Available commands: help, clear, echo <message>');
} else if (command === 'clear') {
logService?.clear();
} else if (command.startsWith('echo ')) {
console.info(command.substring(5));
} else {
console.warn(`Unknown command: ${command}`);
}
} catch (error) {
console.error(`Error executing command: ${error}`);
}
setConsoleInput('');
}
}, [consoleInput, logService]);
useEffect(() => {
if (activeTab === 'cmd') {
inputRef.current?.focus();
}
}, [activeTab]);
// Handle content drawer resize
const handleContentResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizingContent(true);
startY.current = e.clientY;
startHeight.current = contentDrawerHeight;
}, [contentDrawerHeight]);
// Handle output log drawer resize
const handleOutputLogResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizingOutputLog(true);
startY.current = e.clientY;
startHeight.current = outputLogDrawerHeight;
}, [outputLogDrawerHeight]);
useEffect(() => {
if (!isResizingContent && !isResizingOutputLog) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = startY.current - e.clientY;
const newHeight = Math.max(200, Math.min(startHeight.current + delta, window.innerHeight * 0.7));
if (isResizingContent) {
setContentDrawerHeight(newHeight);
} else if (isResizingOutputLog) {
setOutputLogDrawerHeight(newHeight);
}
};
const handleMouseUp = () => {
setIsResizingContent(false);
setIsResizingOutputLog(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizingContent, isResizingOutputLog]);
// Close drawer on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (contentDrawerOpen) {
setContentDrawerOpen(false);
}
if (outputLogDrawerOpen) {
setOutputLogDrawerOpen(false);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [contentDrawerOpen, outputLogDrawerOpen]);
return (
<>
{/* Drawer Backdrop */}
{(contentDrawerOpen || outputLogDrawerOpen) && (
<div
className="drawer-backdrop"
onClick={() => {
setContentDrawerOpen(false);
setOutputLogDrawerOpen(false);
}}
/>
)}
{/* Content Drawer Panel */}
<div
className={`drawer-panel content-drawer-panel ${contentDrawerOpen ? 'open' : ''}`}
style={{ height: contentDrawerOpen ? contentDrawerHeight : 0 }}
>
<div
className="drawer-resize-handle"
onMouseDown={handleContentResizeStart}
/>
<div className="drawer-header">
<span className="drawer-title">
<FolderOpen size={14} />
Content Browser
</span>
<button
className="drawer-close"
onClick={() => setContentDrawerOpen(false)}
>
<X size={14} />
</button>
</div>
<div className="drawer-body">
<ContentBrowser
projectPath={projectPath ?? null}
locale={locale}
onOpenScene={onOpenScene}
isDrawer={true}
revealPath={revealPath}
onDockInLayout={() => {
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
setContentDrawerOpen(false);
onDockContentBrowser?.();
}}
/>
</div>
</div>
{/* Output Log Drawer Panel */}
<div
className={`drawer-panel output-log-drawer-panel ${outputLogDrawerOpen ? 'open' : ''}`}
style={{ height: outputLogDrawerOpen ? outputLogDrawerHeight : 0 }}
>
<div
className="drawer-resize-handle"
onMouseDown={handleOutputLogResizeStart}
/>
<div className="drawer-body output-log-body">
{logService && (
<OutputLogPanel
logService={logService}
locale={locale}
onClose={() => setOutputLogDrawerOpen(false)}
/>
)}
</div>
</div>
{/* Status Bar */}
<div className="status-bar">
<div className="status-bar-left">
<button
className={`status-bar-btn drawer-toggle-btn ${contentDrawerOpen ? 'active' : ''}`}
onClick={handleContentDrawerClick}
>
<FolderOpen size={14} />
<span>{t('statusBar.contentDrawer')}</span>
{contentDrawerOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
<div className="status-bar-divider" />
<button
className={`status-bar-tab ${outputLogDrawerOpen ? 'active' : ''}`}
onClick={handleOutputLogClick}
>
<FileText size={12} />
<span>{t('statusBar.outputLog')}</span>
</button>
<button
className={`status-bar-tab ${activeTab === 'cmd' ? 'active' : ''}`}
onClick={handleCmdClick}
>
<Terminal size={12} />
<span>Cmd</span>
<ChevronDown size={10} />
</button>
<div className="status-bar-console-input">
<span className="console-prompt">&gt;</span>
<input
ref={inputRef}
type="text"
placeholder={t('statusBar.consolePlaceholder')}
value={consoleInput}
onChange={(e) => setConsoleInput(e.target.value)}
onKeyDown={handleConsoleSubmit}
onFocus={() => setActiveTab('cmd')}
/>
</div>
</div>
<div className="status-bar-right">
<button className="status-bar-indicator">
<Activity size={12} />
<span>{t('statusBar.trace')}</span>
<ChevronDown size={10} />
</button>
<div className="status-bar-divider" />
<div className="status-bar-icon-group">
<button
className="status-bar-icon-btn"
title={t('statusBar.resetLayout')}
onClick={onResetLayout}
>
<LayoutGrid size={14} />
</button>
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
<Wifi size={14} />
</button>
<button className="status-bar-icon-btn" title={t('statusBar.sourceControl')}>
<GitBranch size={14} />
</button>
</div>
<div className="status-bar-divider" />
<div className="status-bar-info">
<Save size={12} />
<span>{t('statusBar.allSaved')}</span>
</div>
<div className="status-bar-info">
<span>{t('statusBar.revisionControl')}</span>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,335 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UIRegistry, MessageHub, PluginManager, CommandManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import '../styles/TitleBar.css';
interface MenuItem {
label?: string;
shortcut?: string;
icon?: string;
disabled?: boolean;
separator?: boolean;
submenu?: MenuItem[];
onClick?: () => void;
}
interface TitleBarProps {
projectName?: string;
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: PluginManager;
commandManager?: CommandManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
onSaveSceneAs?: () => void;
onOpenProject?: () => void;
onCloseProject?: () => void;
onExit?: () => void;
onOpenPluginManager?: () => void;
onOpenProfiler?: () => void;
onOpenPortManager?: () => void;
onOpenSettings?: () => void;
onToggleDevtools?: () => void;
onOpenAbout?: () => void;
onCreatePlugin?: () => void;
onReloadPlugins?: () => void;
onOpenBuildSettings?: () => void;
onOpenRenderDebug?: () => void;
}
export function TitleBar({
projectName = 'Untitled',
uiRegistry,
messageHub,
pluginManager,
commandManager,
onNewScene,
onOpenScene,
onSaveScene,
onSaveSceneAs,
onOpenProject,
onCloseProject,
onExit,
onOpenPluginManager,
onOpenProfiler: _onOpenProfiler,
onOpenPortManager,
onOpenSettings,
onToggleDevtools,
onOpenAbout,
onCreatePlugin,
onReloadPlugins,
onOpenBuildSettings,
onOpenRenderDebug
}: TitleBarProps) {
const { t } = useLocale();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
const [isMaximized, setIsMaximized] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const appWindow = getCurrentWindow();
// Update undo/redo state | 更新撤销/重做状态
const updateUndoRedoState = useCallback(() => {
if (commandManager) {
setCanUndo(commandManager.canUndo());
setCanRedo(commandManager.canRedo());
}
}, [commandManager]);
// Handle undo | 处理撤销
const handleUndo = useCallback(() => {
if (commandManager && commandManager.canUndo()) {
commandManager.undo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Handle redo | 处理重做
const handleRedo = useCallback(() => {
if (commandManager && commandManager.canRedo()) {
commandManager.redo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Update undo/redo state periodically | 定期更新撤销/重做状态
useEffect(() => {
updateUndoRedoState();
const interval = setInterval(updateUndoRedoState, 100);
return () => clearInterval(interval);
}, [updateUndoRedoState]);
const updateMenuItems = () => {
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
setPluginMenuItems(items);
}
};
useEffect(() => {
updateMenuItems();
}, [uiRegistry, pluginManager]);
useEffect(() => {
if (messageHub) {
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
updateMenuItems();
});
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
updateMenuItems();
});
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
updateMenuItems();
});
return () => {
unsubscribeInstalled();
unsubscribeEnabled();
unsubscribeDisabled();
};
}
}, [messageHub, uiRegistry, pluginManager]);
useEffect(() => {
const checkMaximized = async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
};
checkMaximized();
const unlisten = appWindow.onResized(async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
});
return () => {
unlisten.then(fn => fn());
};
}, []);
const menus: Record<string, MenuItem[]> = {
file: [
{ label: t('menu.file.newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
{ label: t('menu.file.openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
{ separator: true },
{ label: t('menu.file.saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
{ label: t('menu.file.saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
{ separator: true },
{ label: t('menu.file.buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
{ separator: true },
{ label: t('menu.file.openProject'), onClick: onOpenProject },
{ label: t('menu.file.closeProject'), onClick: onCloseProject },
{ separator: true },
{ label: t('menu.file.exit'), onClick: onExit }
],
edit: [
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: !canUndo, onClick: handleUndo },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: !canRedo, onClick: handleRedo },
{ separator: true },
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
{ label: t('menu.edit.paste'), shortcut: 'Ctrl+V', disabled: true },
{ label: t('menu.edit.delete'), shortcut: 'Delete', disabled: true },
{ separator: true },
{ label: t('menu.edit.selectAll'), shortcut: 'Ctrl+A', disabled: true }
],
window: [
...pluginMenuItems.map((item) => ({
label: item.label || '',
icon: item.icon,
disabled: item.disabled,
onClick: item.onClick
})),
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
{ label: t('menu.window.pluginManager'), onClick: onOpenPluginManager },
{ separator: true },
{ label: t('menu.window.devtools'), onClick: onToggleDevtools }
],
tools: [
{ label: t('menu.tools.createPlugin'), onClick: onCreatePlugin },
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
{ separator: true },
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
{ separator: true },
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
],
help: [
{ label: t('menu.help.documentation'), disabled: true },
{ separator: true },
{ label: t('menu.help.about'), onClick: onOpenAbout }
]
};
// 菜单键到翻译键的映射 | Map menu keys to translation keys
const menuTitleKeys: Record<string, string> = {
file: 'menu.file.title',
edit: 'menu.edit.title',
window: 'menu.window.title',
tools: 'menu.tools.title',
help: 'menu.help.title'
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleMenuClick = (menuKey: string) => {
setOpenMenu(openMenu === menuKey ? null : menuKey);
};
const handleMenuItemClick = (item: MenuItem) => {
if (!item.disabled && !item.separator && item.onClick && item.label) {
item.onClick();
setOpenMenu(null);
}
};
const handleMinimize = async () => {
await appWindow.minimize();
};
const handleMaximize = async () => {
await appWindow.toggleMaximize();
};
const handleClose = async () => {
await appWindow.close();
};
return (
<div className="titlebar">
{/* Left: Logo and Menu */}
<div className="titlebar-left">
<div className="titlebar-logo">
<span className="titlebar-logo-text">ES</span>
</div>
<div className="titlebar-menus" ref={menuRef}>
{Object.keys(menus).map((menuKey) => (
<div key={menuKey} className="titlebar-menu-item">
<button
className={`titlebar-menu-button ${openMenu === menuKey ? 'active' : ''}`}
onClick={() => handleMenuClick(menuKey)}
>
{t(menuTitleKeys[menuKey] || menuKey)}
</button>
{openMenu === menuKey && menus[menuKey] && (
<div className="titlebar-dropdown">
{menus[menuKey].map((item, index) => {
if (item.separator) {
return <div key={index} className="titlebar-dropdown-separator" />;
}
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
return (
<button
key={index}
className={`titlebar-dropdown-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => handleMenuItemClick(item)}
disabled={item.disabled}
>
<span className="titlebar-dropdown-item-content">
{IconComponent && <IconComponent size={14} />}
<span>{item.label || ''}</span>
</span>
{item.shortcut && <span className="titlebar-dropdown-shortcut">{item.shortcut}</span>}
</button>
);
})}
</div>
)}
</div>
))}
</div>
</div>
{/* Center: Draggable area */}
<div className="titlebar-center" data-tauri-drag-region />
{/* Right: Project name + Window controls */}
<div className="titlebar-right">
<span className="titlebar-project-name" data-tauri-drag-region>{projectName}</span>
<div className="titlebar-window-controls">
<button className="titlebar-button" onClick={handleMinimize} title={t('titleBar.minimize')}>
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor"/>
</svg>
</button>
<button className="titlebar-button" onClick={handleMaximize} title={isMaximized ? t('titleBar.restore') : t('titleBar.maximize')}>
{isMaximized ? (
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M2 0v2H0v8h8V8h2V0H2zm6 8H2V4h6v4z" fill="currentColor"/>
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
<rect width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1"/>
</svg>
)}
</button>
<button className="titlebar-button titlebar-button-close" onClick={handleClose} title={t('titleBar.close')}>
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
import '../styles/Toast.css';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType, duration?: number) => void;
hideToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const toast: Toast = { id, message, type, duration };
setToasts((prev) => [...prev, toast]);
if (duration > 0) {
setTimeout(() => {
hideToast(id);
}, duration);
}
}, []);
const hideToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <CheckCircle size={20} />;
case 'error':
return <XCircle size={20} />;
case 'warning':
return <AlertCircle size={20} />;
case 'info':
return <Info size={20} />;
}
};
return (
<ToastContext.Provider value={{ showToast, hideToast }}>
{children}
<div className="toast-container">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
<div className="toast-icon">
{getIcon(toast.type)}
</div>
<div className="toast-message">{toast.message}</div>
<button
className="toast-close"
onClick={() => hideToast(toast.id)}
aria-label="关闭"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,817 @@
/**
* 渲染调试面板样式 (浮动窗口)
* Render Debug Panel Styles (Floating Window)
*/
/* ==================== Floating Window ==================== */
.render-debug-window {
position: fixed;
display: flex;
flex-direction: column;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 11px;
color: #ccc;
}
.render-debug-window.dragging {
cursor: move;
user-select: none;
}
/* 独立窗口模式 | Standalone mode */
.render-debug-window.standalone {
position: relative;
border: none;
border-radius: 0;
box-shadow: none;
}
.render-debug-window.standalone .window-header {
cursor: default;
}
/* ==================== Window Header ==================== */
.render-debug-window .window-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
cursor: move;
flex-shrink: 0;
}
.render-debug-window .window-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #e0e0e0;
}
.render-debug-window .window-title svg {
color: #4a9eff;
}
.render-debug-window .paused-badge {
padding: 2px 6px;
background: #f59e0b;
color: #000;
font-size: 9px;
font-weight: 700;
border-radius: 3px;
letter-spacing: 0.5px;
}
.render-debug-window .window-controls {
display: flex;
gap: 4px;
}
.render-debug-window .window-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 4px;
color: #888;
cursor: pointer;
transition: all 0.15s;
}
.render-debug-window .window-btn:hover {
background: #3a3a3a;
color: #fff;
}
/* ==================== Toolbar ==================== */
.render-debug-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-toolbar .toolbar-left,
.render-debug-toolbar .toolbar-right {
display: flex;
align-items: center;
gap: 6px;
}
.render-debug-toolbar .toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #ccc;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.render-debug-toolbar .toolbar-btn:hover {
background: #4a4a4a;
color: #fff;
}
.render-debug-toolbar .toolbar-btn.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.render-debug-toolbar .toolbar-btn.icon-only {
padding: 4px 6px;
}
.render-debug-toolbar .toolbar-btn.recording {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
}
.render-debug-toolbar .toolbar-btn .record-dot {
display: inline-block;
width: 10px;
height: 10px;
background: #ef4444;
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.render-debug-toolbar .history-badge {
padding: 2px 6px;
background: #8b5cf6;
color: #fff;
font-size: 9px;
font-weight: 700;
border-radius: 3px;
letter-spacing: 0.5px;
margin-left: 4px;
}
.render-debug-toolbar .toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.render-debug-toolbar .toolbar-btn:disabled:hover {
background: #3a3a3a;
color: #ccc;
}
/* ==================== Timeline ==================== */
.render-debug-timeline {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: #222;
border-bottom: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-timeline .timeline-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #333;
border-radius: 3px;
cursor: pointer;
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #4a9eff;
border-radius: 50%;
cursor: grab;
transition: transform 0.1s;
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
cursor: grabbing;
transform: scale(1.1);
}
.render-debug-timeline .timeline-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4a9eff;
border: none;
border-radius: 50%;
cursor: grab;
}
.render-debug-timeline .timeline-info {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #666;
}
.render-debug-toolbar .toolbar-separator {
width: 1px;
height: 16px;
background: #3a3a3a;
}
.render-debug-toolbar .frame-counter {
font-family: 'Consolas', monospace;
font-size: 10px;
color: #888;
padding: 0 6px;
}
/* ==================== Main Layout ==================== */
.render-debug-main {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ==================== Left Panel (Event List) ==================== */
.render-debug-left {
width: 260px;
min-width: 180px;
display: flex;
flex-direction: column;
background: #222;
border-right: 1px solid #1a1a1a;
flex-shrink: 0;
}
.event-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.event-list-header .event-count {
font-weight: 400;
color: #666;
}
.event-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.event-list::-webkit-scrollbar {
width: 5px;
}
.event-list::-webkit-scrollbar-track {
background: #1a1a1a;
}
.event-list::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 2px;
}
.event-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 10px;
text-align: center;
padding: 16px;
line-height: 1.5;
}
/* Event Items */
.event-item {
display: flex;
align-items: center;
padding: 3px 6px;
cursor: pointer;
user-select: none;
font-size: 10px;
color: #bbb;
border-bottom: 1px solid #1a1a1a;
gap: 3px;
}
.event-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.event-item.selected {
background: rgba(74, 158, 255, 0.2);
border-left: 2px solid #4a9eff;
padding-left: 4px;
}
.event-item .expand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
color: #666;
flex-shrink: 0;
}
.event-item .expand-icon:not(.placeholder):hover {
color: #ccc;
}
.event-item .expand-icon.placeholder {
visibility: hidden;
}
.event-item .event-icon {
color: #666;
flex-shrink: 0;
margin-right: 3px;
}
.event-item .event-icon.sprite {
color: #4fc3f7;
}
.event-item .event-icon.particle {
color: #ffb74d;
}
.event-item .event-icon.ui {
color: #81c784;
}
.event-item .event-icon.batch {
color: #81c784;
}
.event-item .event-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item .event-draws {
font-family: 'Consolas', monospace;
font-size: 9px;
color: #666;
padding: 1px 3px;
background: #1a1a1a;
border-radius: 2px;
flex-shrink: 0;
}
/* Batch breaker item highlight */
.event-item.batch-breaker {
background: rgba(245, 158, 11, 0.08);
}
.event-item.batch-breaker:hover {
background: rgba(245, 158, 11, 0.12);
}
.event-item .event-name.batch-breaker {
color: #f59e0b;
font-weight: 500;
}
.event-item .event-icon.breaker {
color: #f59e0b;
}
/* ==================== Right Panel ==================== */
.render-debug-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* Preview Section */
.render-debug-preview {
height: 40%;
min-height: 120px;
display: flex;
flex-direction: column;
border-bottom: 1px solid #1a1a1a;
}
.preview-header {
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.preview-canvas-container {
flex: 1;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-canvas-container canvas {
width: 100%;
height: 100%;
}
/* Details Section */
.render-debug-details {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.details-header {
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.details-content {
flex: 1;
overflow-y: auto;
padding: 10px;
background: #1e1e1e;
}
.details-content::-webkit-scrollbar {
width: 5px;
}
.details-content::-webkit-scrollbar-track {
background: #1a1a1a;
}
.details-content::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 2px;
}
.details-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 10px;
}
/* Details Grid */
.details-grid {
display: flex;
flex-direction: column;
gap: 1px;
}
.details-section {
font-size: 9px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 0 3px 0;
margin-top: 6px;
border-top: 1px solid #333;
}
.details-section:first-child {
margin-top: 0;
border-top: none;
padding-top: 0;
}
.detail-row {
display: flex;
align-items: flex-start;
padding: 3px 0;
font-size: 10px;
}
.detail-row .detail-label {
width: 100px;
color: #888;
flex-shrink: 0;
}
.detail-row .detail-value {
flex: 1;
color: #ccc;
font-family: 'Consolas', monospace;
word-break: break-all;
}
.detail-row.highlight .detail-value {
color: #4fc3f7;
font-weight: 600;
}
/* Batch fix tip */
.batch-fix-tip {
padding: 8px 10px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
}
/* Batch breaker warning */
.batch-breaker-warning {
color: #f59e0b !important;
background: rgba(245, 158, 11, 0.15);
border-radius: 3px;
padding: 4px 8px !important;
margin: 0 !important;
border-top: none !important;
}
/* ==================== Stats Bar ==================== */
.render-debug-stats {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 12px;
background: #262626;
border-top: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-stats .stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #888;
}
.render-debug-stats .stat-item svg {
color: #4a9eff;
}
/* ==================== Resize Handle ==================== */
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: se-resize;
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
border-radius: 0 0 6px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
}
/* ==================== TextureSheet Preview ==================== */
.texture-sheet-preview {
margin-top: 8px;
border-radius: 4px;
overflow: hidden;
background: #1a1a1a;
border: 1px solid #333;
}
.texture-sheet-preview canvas {
display: block;
width: 100%;
height: auto;
}
/* ==================== Texture Preview ==================== */
.texture-preview-row {
display: flex;
align-items: flex-start;
padding: 3px 0;
font-size: 10px;
}
.texture-preview-row .detail-label {
width: 100px;
color: #888;
flex-shrink: 0;
}
.texture-preview-content {
flex: 1;
min-width: 0;
}
.texture-thumbnail-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.texture-thumbnail {
max-width: 100%;
max-height: 80px;
object-fit: contain;
border-radius: 3px;
border: 1px solid #333;
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
}
.texture-path {
font-family: 'Consolas', monospace;
font-size: 9px;
color: #666;
word-break: break-all;
line-height: 1.3;
}
/* ==================== Clickable Stats ==================== */
.render-debug-stats .stat-item.clickable {
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: background 0.15s;
}
.render-debug-stats .stat-item.clickable:hover {
background: #3a3a3a;
}
.render-debug-stats .stat-item.atlas-enabled {
color: #10b981;
}
.render-debug-stats .stat-item.atlas-disabled {
color: #666;
}
/* ==================== Atlas Preview Modal ==================== */
.atlas-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.atlas-preview-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atlas-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
font-weight: 500;
}
.atlas-page-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #1a1a1a;
}
.atlas-page-tab {
padding: 4px 10px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: #aaa;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.atlas-page-tab:hover {
background: #3a3a3a;
color: #ccc;
}
.atlas-page-tab.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.atlas-preview-canvas-container {
flex: 1;
min-height: 350px;
padding: 12px;
background: #1a1a1a;
}
.atlas-preview-canvas-container canvas {
width: 100%;
height: 100%;
cursor: crosshair;
}
.atlas-preview-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 14px;
background: #252525;
border-top: 1px solid #1a1a1a;
min-height: 40px;
}
.atlas-preview-info .hint {
color: #666;
font-style: italic;
}
.atlas-entry-info {
display: flex;
gap: 6px;
font-size: 10px;
}
.atlas-entry-info .label {
color: #888;
}
.atlas-entry-info .value {
color: #4a9eff;
font-family: 'Consolas', monospace;
}
.atlas-preview-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 14px;
background: #2d2d2d;
border-top: 1px solid #1a1a1a;
font-size: 10px;
color: #888;
}
.atlas-preview-stats .error {
color: #ef4444;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
/**
*
* Debug components export
*/
export { RenderDebugPanel } from './RenderDebugPanel';
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';
@@ -0,0 +1,352 @@
.asset-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.asset-picker-dialog {
width: 480px;
max-width: 90vw;
max-height: 70vh;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.asset-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
}
.asset-picker-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
.asset-picker-close {
background: transparent;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-close:hover {
background: #333;
color: #e0e0e0;
}
.asset-picker-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #333;
}
.asset-picker-search svg {
color: #666;
flex-shrink: 0;
}
.asset-picker-search input {
flex: 1;
background: transparent;
border: none;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-picker-search input::placeholder {
color: #666;
}
.asset-picker-content {
flex: 1;
overflow-y: auto;
min-height: 200px;
max-height: 400px;
}
.asset-picker-loading,
.asset-picker-empty {
padding: 32px;
text-align: center;
color: #666;
font-size: 12px;
}
.asset-picker-tree {
padding: 4px 0;
}
.asset-picker-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
}
.asset-picker-item:hover {
background: #2a2a2a;
}
.asset-picker-item.selected {
background: #0d47a1;
}
.asset-picker-item__icon {
display: flex;
align-items: center;
color: #888;
}
.asset-picker-item.selected .asset-picker-item__icon {
color: #90caf9;
}
.asset-picker-item__name {
font-size: 12px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-picker-selected {
flex: 1;
min-width: 0;
font-size: 11px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-selected .placeholder {
color: #666;
font-style: italic;
}
.asset-picker-actions {
display: flex;
gap: 8px;
margin-left: 16px;
}
.asset-picker-actions button {
padding: 6px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
}
.asset-picker-actions .btn-cancel {
background: #333;
color: #e0e0e0;
}
.asset-picker-actions .btn-cancel:hover {
background: #444;
}
.asset-picker-actions .btn-confirm {
background: #1976d2;
color: white;
}
.asset-picker-actions .btn-confirm:hover {
background: #1565c0;
}
.asset-picker-actions .btn-confirm:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
/* Asset Save Dialog specific styles */
.asset-save-filename {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-save-filename label {
font-size: 12px;
color: #888;
white-space: nowrap;
}
.asset-save-filename input {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 8px;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-save-filename input:focus {
border-color: #1976d2;
}
.asset-save-extension {
font-size: 11px;
color: #666;
white-space: nowrap;
}
/* New folder styles */
.asset-save-new-folder-btn {
padding: 8px 16px;
border-top: 1px solid #333;
}
.asset-save-new-folder-btn button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
}
.asset-save-new-folder-btn button:hover {
background: #444;
}
.asset-save-new-folder {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-save-new-folder input {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 8px;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-save-new-folder input:focus {
border-color: #1976d2;
}
.asset-save-new-folder button {
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
}
.asset-save-new-folder button:first-of-type {
background: #1976d2;
color: white;
}
.asset-save-new-folder button:first-of-type:hover {
background: #1565c0;
}
.asset-save-new-folder button:first-of-type:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
.asset-save-new-folder button:last-child {
background: #333;
color: #e0e0e0;
}
.asset-save-new-folder button:last-child:hover {
background: #444;
}
/* ==================== Managed Directory Styles ==================== */
.asset-picker-item.managed-root .asset-picker-item__icon {
color: #4fc1ff;
}
.asset-picker-item.managed-root .managed-icon {
color: #4fc1ff;
}
.asset-picker-item .managed-badge {
font-size: 9px;
padding: 1px 4px;
background: #4fc1ff22;
color: #4fc1ff;
border-radius: 3px;
margin-left: auto;
font-weight: 600;
letter-spacing: 0.5px;
}
/* Disabled items (no GUID) */
.asset-picker-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.asset-picker-item.disabled:hover {
background: transparent;
}
.asset-picker-item .no-guid-badge {
margin-left: auto;
color: #f59e0b;
display: flex;
align-items: center;
}
@@ -0,0 +1,398 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video, Database, AlertTriangle } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProjectService, AssetRegistryService, MANAGED_ASSET_DIRECTORIES } from '@esengine/editor-core';
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
import './AssetPickerDialog.css';
interface AssetPickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (path: string) => void;
title?: string;
fileExtensions?: string[]; // e.g., ['.png', '.jpg']
placeholder?: string;
}
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
/** Asset GUID (only for files with registered GUIDs) */
guid?: string;
/** Whether this is a root managed directory */
isRootManaged?: boolean;
}
export function AssetPickerDialog({
isOpen,
onClose,
onSelect,
title = 'Select Asset',
fileExtensions = [],
placeholder = 'Search assets...'
}: AssetPickerDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
// Get AssetRegistryService for GUID lookup
const assetRegistry = useMemo(() => {
return Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
}, []);
// Load project assets - ONLY from managed directories (assets, scripts, scenes)
useEffect(() => {
if (!isOpen) return;
const loadAssets = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = new TauriFileSystemService();
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject) {
const projectPath = currentProject.path;
const normalizedProjectPath = projectPath.replace(/\\/g, '/');
// 排除的目录名 | Excluded directory names
const excludedDirs = new Set([
'node_modules', '.git', '.idea', '.vscode', 'dist', 'build',
'temp', 'tmp', '.cache', 'coverage', '__pycache__'
]);
// Helper to get relative path from absolute path
const getRelativePath = (absPath: string): string => {
const normalizedAbs = absPath.replace(/\\/g, '/');
if (normalizedAbs.startsWith(normalizedProjectPath)) {
return normalizedAbs.substring(normalizedProjectPath.length + 1);
}
return absPath;
};
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
// 跳过排除的目录 | Skip excluded directories
if (entry.isDirectory && excludedDirs.has(entry.name)) {
continue;
}
// 跳过隐藏文件/目录(以.开头,除了当前目录)
// Skip hidden files/directories (starting with ., except current dir)
if (entry.name.startsWith('.') && entry.name !== '.') {
continue;
}
// Skip .meta files
if (entry.name.endsWith('.meta')) {
continue;
}
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory
};
if (entry.isDirectory) {
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
} else {
// Try to get GUID for the file
if (assetRegistry) {
const relativePath = getRelativePath(entry.path);
const guid = assetRegistry.getGuidByPath(relativePath);
if (guid) {
node.guid = guid;
}
}
}
nodes.push(node);
}
// Sort: folders first, then files, alphabetically
return nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
};
// Only load managed directories (assets, scripts, scenes)
const sep = projectPath.includes('\\') ? '\\' : '/';
const managedNodes: FileNode[] = [];
for (const dirName of MANAGED_ASSET_DIRECTORIES) {
const dirPath = `${projectPath}${sep}${dirName}`;
try {
const exists = await fileSystem.exists(dirPath);
if (exists) {
const children = await buildTree(dirPath);
managedNodes.push({
name: dirName,
path: dirPath,
isDirectory: true,
children,
isRootManaged: true
});
}
} catch {
// Directory doesn't exist, skip
}
}
setAssets(managedNodes);
// Auto-expand managed directories
setExpandedFolders(new Set(managedNodes.map(n => n.path)));
}
} catch (error) {
console.error('Failed to load assets:', error);
} finally {
setLoading(false);
}
};
loadAssets();
setSelectedPath(null);
setSearchTerm('');
}, [isOpen, assetRegistry]);
// Filter assets based on search and file extensions
const filteredAssets = useMemo(() => {
const filterNode = (node: FileNode): FileNode | null => {
// Check file extension filter
if (!node.isDirectory && fileExtensions.length > 0) {
const hasValidExtension = fileExtensions.some((ext) =>
node.name.toLowerCase().endsWith(ext.toLowerCase())
);
if (!hasValidExtension) return null;
}
// Check search term
const matchesSearch = !searchTerm ||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.isDirectory && node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
return null;
}
return matchesSearch ? node : null;
};
return assets
.map(filterNode)
.filter((n): n is FileNode => n !== null);
}, [assets, searchTerm, fileExtensions]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
// Track selected node (to check for GUID)
const [selectedNode, setSelectedNode] = useState<FileNode | null>(null);
const handleSelect = useCallback((node: FileNode) => {
if (node.isDirectory) {
toggleFolder(node.path);
} else {
// Only allow selecting files with GUID
if (node.guid) {
setSelectedPath(node.path);
setSelectedNode(node);
}
// Files without GUID cannot be selected
}
}, [toggleFolder]);
// Convert absolute path to relative path based on project root
const toRelativePath = useCallback((absolutePath: string): string => {
const projectService = Core.services.tryResolve(ProjectService);
const currentProject = projectService?.getCurrentProject();
if (currentProject) {
const projectPath = currentProject.path.replace(/\\/g, '/');
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
if (normalizedAbsolute.startsWith(projectPath)) {
// Return relative path from project root
return normalizedAbsolute.substring(projectPath.length + 1);
}
}
return absolutePath;
}, []);
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(toRelativePath(selectedPath));
onClose();
}
}, [selectedPath, onSelect, onClose, toRelativePath]);
const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory && node.guid) {
// Double-click on file with GUID selects it
onSelect(toRelativePath(node.path));
onClose();
} else if (node.isDirectory) {
// Double-click on folder toggles expansion
toggleFolder(node.path);
}
}, [onSelect, onClose, toRelativePath, toggleFolder]);
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return <Image size={14} />;
case 'mp3':
case 'wav':
case 'ogg':
return <Music size={14} />;
case 'mp4':
case 'webm':
return <Video size={14} />;
case 'json':
case 'txt':
case 'md':
return <FileText size={14} />;
default:
return <File size={14} />;
}
};
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedPath === node.path;
const hasGuid = node.isDirectory || !!node.guid;
const isDisabled = !node.isDirectory && !node.guid;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''} ${node.isRootManaged ? 'managed-root' : ''} ${isDisabled ? 'disabled' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelect(node)}
onDoubleClick={() => handleDoubleClick(node)}
title={isDisabled ? 'This file has no GUID and cannot be referenced' : undefined}
>
<span className="asset-picker-item__icon">
{node.isDirectory ? (
node.isRootManaged ? (
<Database size={14} className="managed-icon" />
) : (
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
)
) : (
getFileIcon(node.name)
)}
</span>
<span className="asset-picker-item__name">{node.name}</span>
{node.isRootManaged && (
<span className="managed-badge">GUID</span>
)}
{isDisabled && (
<span className="no-guid-badge" title="No GUID - cannot be referenced">
<AlertTriangle size={12} />
</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div className="asset-picker-children">
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">Loading assets...</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">No assets found</div>
) : (
<div className="asset-picker-tree">
{filteredAssets.map((node) => renderNode(node))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedPath ? (
<span title={selectedPath}>
{selectedPath.split(/[\\/]/).pop()}
</span>
) : (
<span className="placeholder">No asset selected</span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
Cancel
</button>
<button
className="btn-confirm"
onClick={handleConfirm}
disabled={!selectedPath}
>
Select
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Search, Folder, FolderOpen, FolderPlus } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProjectService, IFileSystemService } from '@esengine/editor-core';
import type { IFileSystem } from '@esengine/editor-core';
import './AssetPickerDialog.css';
interface AssetSaveDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (path: string) => void;
title?: string;
defaultFileName?: string;
fileExtension?: string; // e.g., '.tilemap.json'
placeholder?: string;
}
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
export function AssetSaveDialog({
isOpen,
onClose,
onSave,
title = 'Save Asset',
defaultFileName = 'new-asset',
fileExtension = '',
placeholder = 'Search folders...'
}: AssetSaveDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [fileName, setFileName] = useState(defaultFileName);
const [folders, setFolders] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [projectPath, setProjectPath] = useState('');
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// Load project folders
useEffect(() => {
if (!isOpen) return;
const loadFolders = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject && fileSystem) {
const projPath = currentProject.path;
setProjectPath(projPath);
const assetsPath = `${projPath}/assets`;
// Set default selected folder to assets
setSelectedFolder(assetsPath);
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
// Only include directories
if (entry.isDirectory) {
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: true
};
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
nodes.push(node);
}
}
// Sort alphabetically
return nodes.sort((a, b) => a.name.localeCompare(b.name));
};
const tree = await buildTree(assetsPath);
// Add root assets folder
const rootNode: FileNode = {
name: 'assets',
path: assetsPath,
isDirectory: true,
children: tree
};
setFolders([rootNode]);
setExpandedFolders(new Set([assetsPath]));
}
} catch (error) {
console.error('Failed to load folders:', error);
} finally {
setLoading(false);
}
};
loadFolders();
setFileName(defaultFileName);
setSearchTerm('');
}, [isOpen, defaultFileName]);
// Filter folders based on search
const filteredFolders = useMemo(() => {
if (!searchTerm) return folders;
const filterNode = (node: FileNode): FileNode | null => {
const matchesSearch = node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
}
return matchesSearch ? node : null;
};
return folders
.map(filterNode)
.filter((n): n is FileNode => n !== null);
}, [folders, searchTerm]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleSelectFolder = useCallback((node: FileNode) => {
setSelectedFolder(node.path);
if (!expandedFolders.has(node.path)) {
toggleFolder(node.path);
}
}, [expandedFolders, toggleFolder]);
// Convert absolute path to relative path based on project root
const toRelativePath = useCallback((absolutePath: string): string => {
if (projectPath) {
const normalizedProject = projectPath.replace(/\\/g, '/');
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
if (normalizedAbsolute.startsWith(normalizedProject)) {
return normalizedAbsolute.substring(normalizedProject.length + 1);
}
}
return absolutePath;
}, [projectPath]);
const handleSave = useCallback(() => {
if (selectedFolder && fileName) {
// Ensure file has correct extension
let finalFileName = fileName;
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
finalFileName += fileExtension;
}
const fullPath = `${selectedFolder}/${finalFileName}`.replace(/\\/g, '/');
onSave(toRelativePath(fullPath));
onClose();
}
}, [selectedFolder, fileName, fileExtension, onSave, onClose, toRelativePath]);
const handleCreateFolder = useCallback(async () => {
if (!selectedFolder || !newFolderName.trim()) return;
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
if (!fileSystem) return;
try {
const newFolderPath = `${selectedFolder}/${newFolderName.trim()}`.replace(/\\/g, '/');
await fileSystem.createDirectory(newFolderPath);
// Add new folder to tree
const addFolderToTree = (nodes: FileNode[]): FileNode[] => {
return nodes.map(node => {
if (node.path === selectedFolder) {
const newNode: FileNode = {
name: newFolderName.trim(),
path: newFolderPath,
isDirectory: true,
children: []
};
return {
...node,
children: [...(node.children || []), newNode].sort((a, b) => a.name.localeCompare(b.name))
};
}
if (node.children) {
return { ...node, children: addFolderToTree(node.children) };
}
return node;
});
};
setFolders(addFolderToTree(folders));
setSelectedFolder(newFolderPath);
setExpandedFolders(prev => new Set([...prev, selectedFolder]));
setShowNewFolderInput(false);
setNewFolderName('');
} catch (error) {
console.error('Failed to create folder:', error);
}
}, [selectedFolder, newFolderName, folders]);
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFolder === node.path;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelectFolder(node)}
onDoubleClick={() => toggleFolder(node.path)}
>
<span className="asset-picker-item__icon">
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
</span>
<span className="asset-picker-item__name">{node.name}</span>
</div>
{isExpanded && node.children && (
<div className="asset-picker-children">
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
const getDisplayPath = () => {
if (!selectedFolder) return '';
const relativePath = toRelativePath(selectedFolder);
let finalFileName = fileName;
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
finalFileName += fileExtension;
}
return `${relativePath}/${finalFileName}`;
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">Loading folders...</div>
) : filteredFolders.length === 0 ? (
<div className="asset-picker-empty">No folders found</div>
) : (
<div className="asset-picker-tree">
{filteredFolders.map((node) => renderNode(node))}
</div>
)}
</div>
{/* New folder input */}
{showNewFolderInput && (
<div className="asset-save-new-folder">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
/>
<button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
Create
</button>
<button onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}>
Cancel
</button>
</div>
)}
{/* New folder button */}
{!showNewFolderInput && selectedFolder && (
<div className="asset-save-new-folder-btn">
<button onClick={() => setShowNewFolderInput(true)}>
<FolderPlus size={14} />
New Folder
</button>
</div>
)}
<div className="asset-save-filename">
<label>File name:</label>
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Enter file name"
autoFocus
/>
{fileExtension && (
<span className="asset-save-extension">{fileExtension}</span>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedFolder ? (
<span title={getDisplayPath()}>
{getDisplayPath()}
</span>
) : (
<span className="placeholder">Select a folder</span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
Cancel
</button>
<button
className="btn-confirm"
onClick={handleSave}
disabled={!selectedFolder || !fileName}
>
Save
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,255 @@
/**
* 论坛认证样式 - GitHub Device Flow
* Forum auth styles - GitHub Device Flow
*/
.forum-auth {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
background: #2a2a2a;
}
.forum-auth-card {
width: 100%;
max-width: 360px;
padding: 28px;
background: #333;
border-radius: 6px;
border: 1px solid #444;
text-align: center;
}
.forum-auth-header {
margin-bottom: 24px;
}
.forum-auth-icon {
color: #e0e0e0;
margin-bottom: 12px;
}
.forum-auth-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #e0e0e0;
}
.forum-auth-header p {
font-size: 12px;
color: #888;
margin: 0;
}
/* 初始状态 | Idle state */
.forum-auth-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.forum-auth-instructions {
background: #2a2a2a;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #4a9eff;
text-align: left;
}
.forum-auth-instructions p {
margin: 6px 0;
font-size: 12px;
color: #999;
line-height: 1.5;
}
.forum-auth-instructions p:first-child {
margin-top: 0;
}
.forum-auth-instructions p:last-child {
margin-bottom: 0;
}
.forum-auth-github-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
font-size: 13px;
font-weight: 500;
background: #24292e;
border: 1px solid #444;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-auth-github-btn:hover {
background: #2f363d;
border-color: #555;
}
/* 等待授权 | Pending state */
.forum-auth-pending {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.forum-auth-pending-text {
font-size: 13px;
color: #999;
margin: 0;
}
.forum-auth-code-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.forum-auth-code-section label {
font-size: 11px;
color: #888;
}
.forum-auth-code-box {
display: flex;
align-items: center;
gap: 8px;
background: #2a2a2a;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid #444;
}
.forum-auth-code {
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 18px;
font-weight: bold;
color: #4a9eff;
letter-spacing: 2px;
}
.forum-auth-copy-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px 8px;
transition: transform 0.1s ease;
}
.forum-auth-copy-btn:hover {
transform: scale(1.1);
}
.forum-auth-copy-btn:active {
transform: scale(0.95);
}
.forum-auth-link-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 12px;
background: none;
border: none;
color: #4a9eff;
cursor: pointer;
transition: color 0.15s ease;
}
.forum-auth-link-btn:hover {
color: #3a8eef;
text-decoration: underline;
}
/* 授权成功 | Success state */
.forum-auth-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.forum-auth-success-icon {
color: #4ade80;
}
.forum-auth-success p {
font-size: 14px;
color: #4ade80;
margin: 0;
}
/* 授权失败 | Error state */
.forum-auth-error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.forum-auth-error-icon {
color: #f87171;
}
.forum-auth-error-state > p {
font-size: 14px;
color: #f87171;
margin: 0;
}
.forum-auth-error-detail {
font-size: 11px;
color: #888;
background: #2a2a2a;
padding: 8px 12px;
border-radius: 4px;
max-width: 100%;
word-break: break-word;
}
.forum-auth-retry-btn {
padding: 8px 20px;
font-size: 12px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ccc;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 8px;
}
.forum-auth-retry-btn:hover {
background: #444;
border-color: #555;
color: #fff;
}
/* 加载动画 | Loading animation */
.spinning {
animation: spin 1s linear infinite;
color: #4a9eff;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,159 @@
/**
* - 使 GitHub Device Flow
* Forum auth component - using GitHub Device Flow
*/
import { AlertCircle, CheckCircle, ExternalLink, Github, Loader } from 'lucide-react';
import { useState } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { useLocale } from '../../hooks/useLocale';
import { useForumAuth } from '../../hooks/useForum';
import './ForumAuth.css';
type AuthStatus = 'idle' | 'pending' | 'authorized' | 'error';
export function ForumAuth() {
const { t } = useLocale();
const { requestDeviceCode, authenticateWithDeviceFlow, signInWithGitHubToken } = useForumAuth();
const [authStatus, setAuthStatus] = useState<AuthStatus>('idle');
const [userCode, setUserCode] = useState('');
const [verificationUri, setVerificationUri] = useState('');
const [error, setError] = useState<string | null>(null);
const handleGitHubLogin = async () => {
setAuthStatus('pending');
setError(null);
try {
// 请求 Device Code | Request Device Code
const deviceCodeResp = await requestDeviceCode();
setUserCode(deviceCodeResp.user_code);
setVerificationUri(deviceCodeResp.verification_uri);
// 打开浏览器 | Open browser
await open(deviceCodeResp.verification_uri);
// 轮询等待授权 | Poll for authorization
const accessToken = await authenticateWithDeviceFlow(
deviceCodeResp.device_code,
deviceCodeResp.interval,
(status) => {
if (status === 'authorized') {
setAuthStatus('authorized');
} else if (status === 'error') {
setAuthStatus('error');
}
}
);
// 使用 token 登录 Supabase | Sign in to Supabase with token
const { error: signInError } = await signInWithGitHubToken(accessToken);
if (signInError) {
throw signInError;
}
setAuthStatus('authorized');
} catch (err) {
console.error('[ForumAuth] GitHub login failed:', err);
setAuthStatus('error');
setError(err instanceof Error ? err.message : t('forum.authFailed'));
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleRetry = () => {
setAuthStatus('idle');
setUserCode('');
setVerificationUri('');
setError(null);
};
return (
<div className="forum-auth">
<div className="forum-auth-card">
<div className="forum-auth-header">
<Github size={32} className="forum-auth-icon" />
<h2>{t('forum.communityTitle')}</h2>
<p>{t('forum.signInWithGitHub')}</p>
</div>
{/* 初始状态 | Idle state */}
{authStatus === 'idle' && (
<div className="forum-auth-content">
<div className="forum-auth-instructions">
<p>{t('forum.step1')}</p>
<p>{t('forum.step2')}</p>
<p>{t('forum.step3')}</p>
</div>
<button className="forum-auth-github-btn" onClick={handleGitHubLogin}>
<Github size={16} />
<span>{t('forum.continueWithGitHub')}</span>
</button>
</div>
)}
{/* 等待授权 | Pending state */}
{authStatus === 'pending' && (
<div className="forum-auth-pending">
<Loader size={24} className="spinning" />
<p className="forum-auth-pending-text">
{t('forum.waitingForAuth')}
</p>
{userCode && (
<div className="forum-auth-code-section">
<label>{t('forum.enterCodeOnGitHub')}</label>
<div className="forum-auth-code-box">
<span className="forum-auth-code">{userCode}</span>
<button
className="forum-auth-copy-btn"
onClick={() => copyToClipboard(userCode)}
title={t('forum.copyCode')}
>
📋
</button>
</div>
<button
className="forum-auth-link-btn"
onClick={() => open(verificationUri)}
>
<ExternalLink size={14} />
<span>{t('forum.openGitHub')}</span>
</button>
</div>
)}
</div>
)}
{/* 授权成功 | Success state */}
{authStatus === 'authorized' && (
<div className="forum-auth-success">
<CheckCircle size={32} className="forum-auth-success-icon" />
<p>{t('forum.authSuccess')}</p>
</div>
)}
{/* 授权失败 | Error state */}
{authStatus === 'error' && (
<div className="forum-auth-error-state">
<AlertCircle size={32} className="forum-auth-error-icon" />
<p>{t('forum.authFailed')}</p>
{error && <p className="forum-auth-error-detail">{error}</p>}
<button className="forum-auth-retry-btn" onClick={handleRetry}>
{t('forum.tryAgain')}
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,603 @@
/**
* 论坛创建帖子样式
* Forum create post styles
*/
.forum-create-post {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 12px;
}
/* 容器布局 | Container layout */
.forum-create-container {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
/* 主编辑区 | Main editor area */
.forum-create-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #2d2d2d;
border-radius: 6px;
border: 1px solid #3a3a3a;
overflow: hidden;
}
.forum-create-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
background: #333;
}
.forum-create-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.forum-create-selected-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: rgba(74, 158, 255, 0.15);
border-radius: 12px;
color: #4a9eff;
}
/* 表单 | Form */
.forum-create-form {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px;
gap: 16px;
overflow-y: auto;
}
.forum-create-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.forum-create-field label {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 分类选择 | Category selection */
.forum-create-categories {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.forum-create-category {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 14px;
min-width: 80px;
background: #363636;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-create-category:hover {
background: #404040;
border-color: #555;
transform: translateY(-1px);
}
.forum-create-category.selected {
background: rgba(74, 158, 255, 0.15);
border-color: #4a9eff;
}
.forum-create-category-emoji {
font-size: 18px;
line-height: 1;
}
.forum-create-category-name {
font-size: 10px;
font-weight: 500;
color: #ccc;
text-align: center;
}
.forum-create-category.selected .forum-create-category-name {
color: #4a9eff;
}
.forum-create-category-desc {
font-size: 9px;
color: #666;
text-align: center;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 标题输入 | Title input */
.forum-create-title-input {
display: flex;
align-items: center;
gap: 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding-right: 10px;
transition: border-color 0.15s ease;
}
.forum-create-title-input:focus-within {
border-color: #4a9eff;
}
.forum-create-title-input input {
flex: 1;
padding: 10px 12px;
font-size: 13px;
background: transparent;
border: none;
color: #e0e0e0;
outline: none;
}
.forum-create-title-input input::placeholder {
color: #555;
}
.forum-create-count {
font-size: 10px;
color: #666;
flex-shrink: 0;
}
/* 编辑器字段 | Editor field */
.forum-create-editor-field {
flex: 1;
display: flex;
flex-direction: column;
min-height: 300px;
}
/* 编辑器头部 | Editor header */
.forum-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: #363636;
border: 1px solid #3a3a3a;
border-radius: 4px 4px 0 0;
}
/* 编辑器选项卡 | Editor tabs */
.forum-editor-tabs {
display: flex;
gap: 2px;
}
.forum-editor-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
font-size: 11px;
font-weight: 500;
background: transparent;
border: none;
border-radius: 3px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-editor-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: #ccc;
}
.forum-editor-tab.active {
background: #4a9eff;
color: white;
}
/* 编辑器工具栏 | Editor toolbar */
.forum-editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
}
.forum-editor-tool {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 3px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-editor-tool:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0;
}
.forum-editor-help {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #666;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-editor-help:hover {
background: rgba(255, 255, 255, 0.05);
color: #4a9eff;
}
/* 编辑器内容区 | Editor content */
.forum-editor-content {
flex: 1;
display: flex;
flex-direction: column;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-top: none;
border-radius: 0 0 4px 4px;
min-height: 200px;
}
.forum-editor-textarea {
flex: 1;
width: 100%;
padding: 12px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
line-height: 1.6;
background: transparent;
border: none;
color: #e0e0e0;
resize: none;
outline: none;
}
.forum-editor-textarea::placeholder {
color: #555;
}
/* 编辑器内容拖拽状态 | Editor content drag state */
.forum-editor-content {
position: relative;
}
.forum-editor-content.dragging {
border-color: #4a9eff;
background: rgba(74, 158, 255, 0.05);
}
/* 上传覆盖层 | Upload overlay */
.forum-editor-upload-overlay,
.forum-editor-drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(26, 26, 26, 0.95);
z-index: 10;
border-radius: 0 0 4px 4px;
}
.forum-editor-upload-overlay span,
.forum-editor-drag-overlay span {
font-size: 13px;
color: #4a9eff;
font-weight: 500;
}
.forum-editor-upload-overlay svg,
.forum-editor-drag-overlay svg {
color: #4a9eff;
}
.forum-editor-drag-overlay {
border: 2px dashed #4a9eff;
background: rgba(74, 158, 255, 0.1);
}
/* 旋转动画 | Spin animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}
/* 预览区 | Preview area */
.forum-editor-preview {
flex: 1;
padding: 12px;
overflow-y: auto;
color: #ddd;
font-size: 13px;
line-height: 1.6;
}
.forum-editor-preview-empty {
color: #666;
font-style: italic;
}
/* Markdown 渲染样式 | Markdown render styles */
.forum-editor-preview h1,
.forum-editor-preview h2,
.forum-editor-preview h3,
.forum-editor-preview h4 {
color: #e0e0e0;
margin: 16px 0 8px;
}
.forum-editor-preview h1 { font-size: 20px; }
.forum-editor-preview h2 { font-size: 17px; }
.forum-editor-preview h3 { font-size: 14px; }
.forum-editor-preview p {
margin: 0 0 12px;
}
.forum-editor-preview a {
color: #4a9eff;
text-decoration: none;
}
.forum-editor-preview a:hover {
text-decoration: underline;
}
.forum-editor-preview code {
padding: 2px 6px;
font-size: 11px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
background: #2a2a2a;
border-radius: 3px;
color: #f8d97c;
}
.forum-editor-preview pre {
margin: 12px 0;
padding: 12px;
background: #1e1e1e;
border-radius: 4px;
overflow-x: auto;
}
.forum-editor-preview pre code {
padding: 0;
background: none;
color: #ddd;
}
.forum-editor-preview blockquote {
margin: 12px 0;
padding: 8px 12px;
border-left: 3px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #aaa;
}
.forum-editor-preview ul,
.forum-editor-preview ol {
margin: 8px 0;
padding-left: 24px;
}
.forum-editor-preview li {
margin: 4px 0;
}
.forum-editor-preview img {
max-width: 100%;
border-radius: 4px;
}
.forum-editor-preview hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 16px 0;
}
.forum-editor-preview table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.forum-editor-preview th,
.forum-editor-preview td {
padding: 8px;
border: 1px solid #3a3a3a;
text-align: left;
}
.forum-editor-preview th {
background: #2a2a2a;
font-weight: 600;
}
/* 错误提示 | Error message */
.forum-create-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
font-size: 11px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 4px;
color: #f87171;
}
/* 操作按钮 | Action buttons */
.forum-create-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #3a3a3a;
}
.forum-btn-submit {
min-width: 140px;
}
/* 侧边栏 | Sidebar */
.forum-create-sidebar {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.forum-create-tips,
.forum-create-markdown-guide {
background: #2d2d2d;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 12px;
}
.forum-create-tips h3,
.forum-create-markdown-guide h3 {
margin: 0 0 10px 0;
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.forum-create-tips ul {
margin: 0;
padding: 0;
list-style: none;
}
.forum-create-tips li {
position: relative;
padding: 4px 0 4px 14px;
font-size: 11px;
color: #888;
line-height: 1.5;
}
.forum-create-tips li::before {
content: '•';
position: absolute;
left: 0;
color: #4a9eff;
}
/* Markdown 指南 | Markdown guide */
.forum-create-markdown-examples {
display: flex;
flex-direction: column;
gap: 6px;
}
.markdown-example {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.markdown-example code {
padding: 2px 6px;
background: #1a1a1a;
border-radius: 3px;
color: #f8d97c;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: 10px;
}
.markdown-example code.inline {
background: #2a2a2a;
color: #4a9eff;
}
.markdown-example span {
color: #666;
}
.markdown-example strong {
color: #e0e0e0;
}
.markdown-example em {
color: #e0e0e0;
}
.markdown-example a {
color: #4a9eff;
text-decoration: none;
}
/* 响应式 | Responsive */
@media (max-width: 800px) {
.forum-create-container {
flex-direction: column;
}
.forum-create-sidebar {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.forum-create-tips,
.forum-create-markdown-guide {
flex: 1;
min-width: 200px;
}
}
@@ -0,0 +1,456 @@
/**
* - GitHub Discussions
* Forum create post component - GitHub Discussions
*/
import { useState, useRef, useCallback } from 'react';
import {
ArrowLeft, Send, AlertCircle, Eye, Edit3,
Bold, Italic, Code, Link, List, Image, Quote, HelpCircle,
Upload, Loader2
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useLocale } from '../../hooks/useLocale';
import { getForumService } from '../../services/forum';
import type { Category } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumCreatePost.css';
interface ForumCreatePostProps {
categories: Category[];
onBack: () => void;
onCreated: () => void;
}
type EditorTab = 'write' | 'preview';
export function ForumCreatePost({ categories, onBack, onCreated }: ForumCreatePostProps) {
const { t } = useLocale();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [categoryId, setCategoryId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const forumService = getForumService();
/**
*
* Handle image upload
*/
const handleImageUpload = useCallback(async (file: File) => {
if (uploading) return;
setUploading(true);
setUploadProgress(0);
setError(null);
try {
const imageUrl = await forumService.uploadImage(file, (progress) => {
setUploadProgress(progress);
});
// 插入 Markdown 图片语法 | Insert Markdown image syntax
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const imageMarkdown = `![${file.name}](${imageUrl})`;
const newBody = body.substring(0, start) + imageMarkdown + body.substring(end);
setBody(newBody);
// 恢复光标位置 | Restore cursor position
setTimeout(() => {
textarea.focus();
const newPos = start + imageMarkdown.length;
textarea.setSelectionRange(newPos, newPos);
}, 0);
} else {
// 如果没有 textarea,直接追加到末尾 | Append to end if no textarea
setBody(prev => prev + `\n![${file.name}](${imageUrl})`);
}
} catch (err) {
console.error('[ForumCreatePost] Upload failed:', err);
setError(err instanceof Error ? err.message : t('forum.failedToUploadImage'));
} finally {
setUploading(false);
setUploadProgress(0);
}
}, [body, forumService, t, uploading]);
/**
*
* Handle drag events
*/
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFile = files.find(f => f.type.startsWith('image/'));
if (imageFile) {
handleImageUpload(imageFile);
}
}, [handleImageUpload]);
/**
*
* Handle paste event
*/
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const imageItem = items.find(item => item.type.startsWith('image/'));
if (imageItem) {
e.preventDefault();
const file = imageItem.getAsFile();
if (file) {
handleImageUpload(file);
}
}
}, [handleImageUpload]);
/**
*
* Handle file selection
*/
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
// 清空 input 以便重复选择同一文件 | Clear input to allow selecting same file again
e.target.value = '';
}, [handleImageUpload]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// 验证 | Validation
if (!title.trim()) {
setError(t('forum.enterTitle'));
return;
}
if (!body.trim()) {
setError(t('forum.enterContent'));
return;
}
if (!categoryId) {
setError(t('forum.selectCategoryError'));
return;
}
setSubmitting(true);
try {
const post = await forumService.createPost({
title: title.trim(),
body: body.trim(),
categoryId
});
if (post) {
onCreated();
} else {
setError(t('forum.failedToCreateDiscussion'));
}
} catch (err) {
console.error('[ForumCreatePost] Error:', err);
setError(err instanceof Error ? err.message : t('forum.anErrorOccurred'));
} finally {
setSubmitting(false);
}
};
// 插入 Markdown 语法 | Insert Markdown syntax
const insertMarkdown = (prefix: string, suffix: string = '', placeholder: string = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = body.substring(start, end) || placeholder;
const newBody = body.substring(0, start) + prefix + selectedText + suffix + body.substring(end);
setBody(newBody);
// 恢复光标位置 | Restore cursor position
setTimeout(() => {
textarea.focus();
const newCursorPos = start + prefix.length + selectedText.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
const toolbarButtons = [
{ icon: <Bold size={14} />, action: () => insertMarkdown('**', '**', 'bold'), title: t('forum.bold') },
{ icon: <Italic size={14} />, action: () => insertMarkdown('*', '*', 'italic'), title: t('forum.italic') },
{ icon: <Code size={14} />, action: () => insertMarkdown('`', '`', 'code'), title: t('forum.inlineCode') },
{ icon: <Link size={14} />, action: () => insertMarkdown('[', '](url)', 'link text'), title: t('forum.link') },
{ icon: <List size={14} />, action: () => insertMarkdown('\n- ', '', 'list item'), title: t('forum.list') },
{ icon: <Quote size={14} />, action: () => insertMarkdown('\n> ', '', 'quote'), title: t('forum.quote') },
{ icon: <Upload size={14} />, action: () => fileInputRef.current?.click(), title: t('forum.uploadImage') },
];
const selectedCategory = categories.find(c => c.id === categoryId);
return (
<div className="forum-create-post">
{/* 返回按钮 | Back button */}
<button className="forum-back-btn" onClick={onBack}>
<ArrowLeft size={18} />
<span>{t('forum.backToList')}</span>
</button>
<div className="forum-create-container">
{/* 左侧:编辑区 | Left: Editor */}
<div className="forum-create-main">
<div className="forum-create-header">
<h2>{t('forum.startDiscussion')}</h2>
{selectedCategory && (
<span className="forum-create-selected-category">
{parseEmoji(selectedCategory.emoji)} {selectedCategory.name}
</span>
)}
</div>
<form className="forum-create-form" onSubmit={handleSubmit}>
{/* 分类选择 | Category selection */}
<div className="forum-create-field">
<label>{t('forum.selectCategory')}</label>
<div className="forum-create-categories">
{categories.map(cat => (
<button
key={cat.id}
type="button"
className={`forum-create-category ${categoryId === cat.id ? 'selected' : ''}`}
onClick={() => setCategoryId(cat.id)}
>
<span className="forum-create-category-emoji">{parseEmoji(cat.emoji)}</span>
<span className="forum-create-category-name">{cat.name}</span>
{cat.description && (
<span className="forum-create-category-desc">{cat.description}</span>
)}
</button>
))}
</div>
</div>
{/* 标题 | Title */}
<div className="forum-create-field">
<label>{t('forum.title')}</label>
<div className="forum-create-title-input">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('forum.enterDescriptiveTitle')}
maxLength={200}
/>
<span className="forum-create-count">{title.length}/200</span>
</div>
</div>
{/* 编辑器 | Editor */}
<div className="forum-create-field forum-create-editor-field">
<div className="forum-editor-header">
<div className="forum-editor-tabs">
<button
type="button"
className={`forum-editor-tab ${activeTab === 'write' ? 'active' : ''}`}
onClick={() => setActiveTab('write')}
>
<Edit3 size={14} />
<span>{t('forum.write')}</span>
</button>
<button
type="button"
className={`forum-editor-tab ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => setActiveTab('preview')}
>
<Eye size={14} />
<span>{t('forum.preview')}</span>
</button>
</div>
{activeTab === 'write' && (
<div className="forum-editor-toolbar">
{toolbarButtons.map((btn, idx) => (
<button
key={idx}
type="button"
className="forum-editor-tool"
onClick={btn.action}
title={btn.title}
>
{btn.icon}
</button>
))}
<a
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
target="_blank"
rel="noopener noreferrer"
className="forum-editor-help"
title={t('forum.markdownHelp')}
>
<HelpCircle size={14} />
</a>
</div>
)}
</div>
<div
className={`forum-editor-content ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 隐藏的文件输入 | Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{/* 上传进度提示 | Upload progress indicator */}
{uploading && (
<div className="forum-editor-upload-overlay">
<Loader2 size={24} className="spin" />
<span>{t('forum.uploading')} {uploadProgress}%</span>
</div>
)}
{/* 拖拽提示 | Drag hint */}
{isDragging && !uploading && (
<div className="forum-editor-drag-overlay">
<Upload size={32} />
<span>{t('forum.dropImageHere')}</span>
</div>
)}
{activeTab === 'write' ? (
<textarea
ref={textareaRef}
className="forum-editor-textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
onPaste={handlePaste}
placeholder={t('forum.editorPlaceholder')}
/>
) : (
<div className="forum-editor-preview">
{body ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{body}
</ReactMarkdown>
) : (
<p className="forum-editor-preview-empty">
{t('forum.nothingToPreview')}
</p>
)}
</div>
)}
</div>
</div>
{/* 错误提示 | Error message */}
{error && (
<div className="forum-create-error">
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
{/* 提交按钮 | Submit button */}
<div className="forum-create-actions">
<button
type="button"
className="forum-btn"
onClick={onBack}
disabled={submitting}
>
{t('forum.cancel')}
</button>
<button
type="submit"
className="forum-btn forum-btn-primary forum-btn-submit"
disabled={submitting || !title.trim() || !body.trim() || !categoryId}
>
<Send size={16} />
<span>
{submitting ? t('forum.creating') : t('forum.createDiscussion')}
</span>
</button>
</div>
</form>
</div>
{/* 右侧:提示 | Right: Tips */}
<div className="forum-create-sidebar">
<div className="forum-create-tips">
<h3>{t('forum.tips')}</h3>
<ul>
<li>{t('forum.tip1')}</li>
<li>{t('forum.tip2')}</li>
<li>{t('forum.tip3')}</li>
<li>{t('forum.tip4')}</li>
<li>{t('forum.tip5')}</li>
</ul>
</div>
<div className="forum-create-markdown-guide">
<h3>{t('forum.markdownGuide')}</h3>
<div className="forum-create-markdown-examples">
<div className="markdown-example">
<code>**bold**</code>
<span></span>
<strong>bold</strong>
</div>
<div className="markdown-example">
<code>*italic*</code>
<span></span>
<em>italic</em>
</div>
<div className="markdown-example">
<code>`code`</code>
<span></span>
<code className="inline">code</code>
</div>
<div className="markdown-example">
<code>[link](url)</code>
<span></span>
<a href="#">link</a>
</div>
<div className="markdown-example">
<code>- item</code>
<span></span>
<span> item</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
/**
* 论坛面板样式
* Forum panel styles
*/
.forum-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #2a2a2a;
color: #e0e0e0;
}
.forum-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #1a1a1a;
background: #333;
}
.forum-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.forum-title {
font-size: 12px;
font-weight: 600;
}
.forum-header-right {
display: flex;
align-items: center;
gap: 10px;
position: relative;
}
.forum-user {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s ease;
}
.forum-user:hover {
background: rgba(255, 255, 255, 0.1);
}
.forum-user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.forum-user-avatar-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: white;
}
.forum-user-name {
font-size: 12px;
color: #999;
}
/* 个人资料下拉面板 | Profile dropdown panel */
.forum-profile-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
z-index: 1000;
min-width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.forum-content {
flex: 1;
overflow: auto;
}
.forum-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #888;
}
/* 通用按钮样式 | Common button styles */
.forum-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ccc;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-btn:hover:not(:disabled) {
background: #444;
border-color: #555;
color: #fff;
}
.forum-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.forum-btn-primary {
background: #4a9eff;
border-color: #4a9eff;
color: white;
}
.forum-btn-primary:hover:not(:disabled) {
background: #3a8eef;
border-color: #3a8eef;
}
/* 返回按钮 | Back button */
.forum-back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 10px 12px;
font-size: 11px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
transition: color 0.15s ease;
}
.forum-back-btn:hover {
color: #ccc;
}
/* 旋转动画 | Spin animation */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,180 @@
/**
* - GitHub Discussions
* Forum panel main component - GitHub Discussions
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { MessageSquare, RefreshCw } from 'lucide-react';
import { useLocale } from '../../hooks/useLocale';
import { useForumAuth, useCategories, usePosts } from '../../hooks/useForum';
import { ForumAuth } from './ForumAuth';
import { ForumPostList } from './ForumPostList';
import { ForumPostDetail } from './ForumPostDetail';
import { ForumCreatePost } from './ForumCreatePost';
import { ForumProfile } from './ForumProfile';
import type { PostListParams, ForumUser } from '../../services/forum';
import './ForumPanel.css';
type ForumView = 'list' | 'detail' | 'create';
/**
* | Authenticated forum content component
* hooks
*/
function ForumContent({ user }: { user: ForumUser }) {
const { t } = useLocale();
const { categories, refetch: refetchCategories } = useCategories();
const [view, setView] = useState<ForumView>('list');
const [selectedPostNumber, setSelectedPostNumber] = useState<number | null>(null);
const [listParams, setListParams] = useState<PostListParams>({ first: 20 });
const [showProfile, setShowProfile] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
const { data: posts, loading, totalCount, pageInfo, refetch, loadMore } = usePosts(listParams);
// 点击外部关闭个人资料面板 | Close profile panel when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setShowProfile(false);
}
};
if (showProfile) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showProfile]);
const handleViewPost = useCallback((postNumber: number) => {
setSelectedPostNumber(postNumber);
setView('detail');
}, []);
const handleBack = useCallback(() => {
setView('list');
setSelectedPostNumber(null);
}, []);
const handleCreatePost = useCallback(() => {
setView('create');
}, []);
const handlePostCreated = useCallback(() => {
setView('list');
refetch();
}, [refetch]);
const handleCategoryChange = useCallback((categoryId: string | undefined) => {
setListParams(prev => ({ ...prev, categoryId }));
}, []);
const handleSearch = useCallback((search: string) => {
setListParams(prev => ({ ...prev, search }));
}, []);
return (
<>
{/* 顶部栏 | Header */}
<div className="forum-header">
<div className="forum-header-left">
<MessageSquare size={18} />
<span className="forum-title">
{t('forum.community')}
</span>
</div>
<div className="forum-header-right">
<div
className="forum-user"
onClick={() => setShowProfile(!showProfile)}
title={t('forum.clickToViewProfile')}
>
<img
src={user.avatarUrl}
alt={user.login}
className="forum-user-avatar"
/>
<span className="forum-user-name">
{user.login}
</span>
</div>
{/* 个人资料下拉面板 | Profile dropdown panel */}
{showProfile && (
<div className="forum-profile-dropdown" ref={profileRef}>
<ForumProfile onClose={() => setShowProfile(false)} />
</div>
)}
</div>
</div>
{/* 内容区 | Content */}
<div className="forum-content">
{view === 'list' && (
<ForumPostList
posts={posts}
categories={categories}
loading={loading}
totalCount={totalCount}
hasNextPage={pageInfo.hasNextPage}
params={listParams}
onViewPost={handleViewPost}
onCreatePost={handleCreatePost}
onCategoryChange={handleCategoryChange}
onSearch={handleSearch}
onRefresh={refetch}
onLoadMore={loadMore}
/>
)}
{view === 'detail' && selectedPostNumber && (
<ForumPostDetail
postNumber={selectedPostNumber}
currentUserId={user.id}
onBack={handleBack}
/>
)}
{view === 'create' && (
<ForumCreatePost
categories={categories}
onBack={handleBack}
onCreated={handlePostCreated}
/>
)}
</div>
</>
);
}
export function ForumPanel() {
const { t } = useLocale();
const { authState } = useForumAuth();
// 加载状态 | Loading state
if (authState.status === 'loading') {
return (
<div className="forum-panel">
<div className="forum-loading">
<RefreshCw className="spin" size={24} />
<span>{t('forum.loading')}</span>
</div>
</div>
);
}
// 未登录状态 | Unauthenticated state
if (authState.status === 'unauthenticated') {
return (
<div className="forum-panel">
<ForumAuth />
</div>
);
}
// 已登录状态 - 渲染内容组件 | Authenticated state - render content component
return (
<div className="forum-panel">
<ForumContent user={authState.user} />
</div>
);
}
@@ -0,0 +1,560 @@
/**
* 论坛帖子详情样式
* Forum post detail styles
*/
.forum-post-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.forum-detail-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #888;
}
/* 文章区 | Article section */
.forum-detail-article {
padding: 0 12px 20px;
}
.forum-detail-header {
margin-bottom: 16px;
}
.forum-detail-category-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.forum-detail-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(74, 158, 255, 0.1);
color: #4a9eff;
}
.forum-detail-answered {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.forum-detail-external {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #888;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: auto;
}
.forum-detail-external:hover {
background: #444;
color: #e0e0e0;
border-color: #555;
}
.forum-detail-title {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
line-height: 1.4;
}
.forum-detail-meta {
display: flex;
align-items: center;
gap: 12px;
}
.forum-detail-author {
display: flex;
align-items: center;
gap: 6px;
}
.forum-detail-author img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.forum-detail-author-placeholder {
width: 28px;
height: 28px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.forum-detail-author span {
font-size: 12px;
color: #e0e0e0;
font-weight: 500;
}
.forum-detail-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
/* 内容区 | Content section */
.forum-detail-content {
font-size: 13px;
line-height: 1.7;
color: #bbb;
}
.forum-detail-content p {
margin: 0 0 12px 0;
}
.forum-detail-content p:last-child {
margin-bottom: 0;
}
/* Markdown 样式 | Markdown styles */
.forum-detail-content h1,
.forum-detail-content h2,
.forum-detail-content h3,
.forum-detail-content h4 {
color: #e0e0e0;
margin: 20px 0 10px 0;
font-weight: 600;
}
.forum-detail-content h1 { font-size: 20px; }
.forum-detail-content h2 { font-size: 17px; }
.forum-detail-content h3 { font-size: 15px; }
.forum-detail-content h4 { font-size: 14px; }
.forum-detail-content a {
color: #4a9eff;
text-decoration: none;
}
.forum-detail-content a:hover {
text-decoration: underline;
}
.forum-detail-content code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: #e06c75;
}
.forum-detail-content pre {
background: #1a1a1a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
border: 1px solid #333;
}
.forum-detail-content pre code {
background: none;
padding: 0;
color: #abb2bf;
font-size: 12px;
line-height: 1.5;
}
.forum-detail-content blockquote {
margin: 12px 0;
padding: 10px 16px;
border-left: 3px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #999;
}
.forum-detail-content ul,
.forum-detail-content ol {
margin: 12px 0;
padding-left: 24px;
}
.forum-detail-content li {
margin: 6px 0;
}
.forum-detail-content img {
max-width: 100%;
border-radius: 6px;
margin: 12px 0;
}
.forum-detail-content hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 20px 0;
}
.forum-detail-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.forum-detail-content th,
.forum-detail-content td {
padding: 8px 12px;
border: 1px solid #3a3a3a;
text-align: left;
}
.forum-detail-content th {
background: #333;
color: #e0e0e0;
font-weight: 600;
}
.forum-detail-content tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* 底部统计 | Footer stats */
.forum-detail-footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid #1a1a1a;
}
.forum-detail-stats {
display: flex;
align-items: center;
gap: 16px;
}
.forum-detail-stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #666;
}
.forum-detail-stat.interactive {
padding: 5px 10px;
margin: -5px -10px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
background: none;
border: none;
}
.forum-detail-stat.interactive:hover {
background: rgba(255, 255, 255, 0.05);
color: #888;
}
.forum-detail-stat.interactive.liked {
color: #ef4444;
}
.forum-detail-stat.interactive.liked svg {
fill: #ef4444;
}
/* 回复区 | Replies section */
.forum-replies-section {
padding: 0 12px 20px;
border-top: 1px solid #1a1a1a;
}
.forum-replies-title {
display: flex;
align-items: center;
gap: 6px;
margin: 16px 0 12px;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
/* 回复表单 | Reply form */
.forum-reply-form {
margin-bottom: 16px;
}
.forum-reply-form.nested {
margin-top: 10px;
margin-left: 20px;
}
.forum-reply-form textarea {
width: 100%;
padding: 10px;
font-size: 12px;
font-family: inherit;
line-height: 1.5;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 3px;
color: #ddd;
resize: vertical;
min-height: 70px;
}
.forum-reply-form textarea:hover {
border-color: #4a4a4a;
}
.forum-reply-form textarea:focus {
outline: none;
border-color: #4a9eff;
}
.forum-reply-form textarea::placeholder {
color: #555;
}
.forum-reply-form-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 8px;
}
/* 回复列表 | Reply list */
.forum-replies-list {
display: flex;
flex-direction: column;
}
.forum-replies-loading {
display: flex;
justify-content: center;
padding: 16px;
color: #666;
}
.forum-replies-empty {
padding: 24px;
text-align: center;
color: #666;
}
.forum-replies-empty p {
margin: 0;
font-size: 12px;
}
/* 单条回复 | Single reply */
.forum-reply {
padding: 12px 0;
border-bottom: 1px solid #1a1a1a;
}
.forum-reply:last-child {
border-bottom: none;
}
.forum-reply-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.forum-reply-author {
display: flex;
align-items: center;
gap: 6px;
}
.forum-reply-author img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.forum-reply-author-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.forum-reply-author-name {
font-size: 12px;
font-weight: 500;
color: #e0e0e0;
}
.forum-reply-answer-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border-radius: 12px;
}
.forum-reply-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.forum-reply-content {
font-size: 12px;
line-height: 1.6;
color: #bbb;
}
/* 回复内容 Markdown 样式 | Reply content Markdown styles */
.forum-reply-content p {
margin: 0 0 8px 0;
}
.forum-reply-content p:last-child {
margin-bottom: 0;
}
.forum-reply-content a {
color: #4a9eff;
text-decoration: none;
}
.forum-reply-content a:hover {
text-decoration: underline;
}
.forum-reply-content code {
background: #1a1a1a;
padding: 1px 4px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #e06c75;
}
.forum-reply-content pre {
background: #1a1a1a;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid #333;
}
.forum-reply-content pre code {
background: none;
padding: 0;
color: #abb2bf;
}
.forum-reply-content blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 2px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #999;
}
.forum-reply-content ul,
.forum-reply-content ol {
margin: 8px 0;
padding-left: 20px;
}
.forum-reply-content li {
margin: 4px 0;
}
.forum-reply-content img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
.forum-reply-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.forum-reply-action {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
font-size: 11px;
background: none;
border: none;
color: #666;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-reply-action:hover {
background: rgba(255, 255, 255, 0.05);
color: #888;
}
.forum-reply-action.liked {
color: #ef4444;
}
.forum-reply-action.liked svg {
fill: #ef4444;
}
.forum-reply-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
@@ -0,0 +1,267 @@
/**
* - GitHub Discussions
* Forum post detail component - GitHub Discussions
*/
import { useState } from 'react';
import {
ArrowLeft, ThumbsUp, MessageCircle, Clock,
Send, RefreshCw, CornerDownRight, ExternalLink, CheckCircle
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { useLocale } from '../../hooks/useLocale';
import { usePost, useReplies } from '../../hooks/useForum';
import { getForumService } from '../../services/forum';
import type { Reply } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumPostDetail.css';
interface ForumPostDetailProps {
postNumber: number;
currentUserId: string;
onBack: () => void;
}
export function ForumPostDetail({ postNumber, currentUserId, onBack }: ForumPostDetailProps) {
const { t } = useLocale();
const { post, loading: postLoading, toggleUpvote, refetch: refetchPost } = usePost(postNumber);
const { replies, loading: repliesLoading, createReply, refetch: refetchReplies } = useReplies(postNumber);
const [replyContent, setReplyContent] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const forumService = getForumService();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || submitting || !post) return;
setSubmitting(true);
try {
await createReply(post.id, replyContent, replyingTo || undefined);
setReplyContent('');
setReplyingTo(null);
refetchPost();
} finally {
setSubmitting(false);
}
};
const handleToggleReplyUpvote = async (replyId: string, hasUpvoted: boolean) => {
await forumService.toggleReplyUpvote(replyId, hasUpvoted);
refetchReplies();
};
const openInGitHub = async (url: string) => {
await open(url);
};
const renderReply = (reply: Reply, depth: number = 0) => {
return (
<div key={reply.id} className="forum-reply" style={{ marginLeft: depth * 24 }}>
<div className="forum-reply-header">
<div className="forum-reply-author">
<img src={reply.author.avatarUrl} alt={reply.author.login} />
<span className="forum-reply-author-name">
@{reply.author.login}
</span>
{reply.isAnswer && (
<span className="forum-reply-answer-badge">
<CheckCircle size={12} />
{t('forum.answer')}
</span>
)}
</div>
<span className="forum-reply-time">
<Clock size={12} />
{formatDate(reply.createdAt)}
</span>
</div>
<div
className="forum-reply-content"
dangerouslySetInnerHTML={{ __html: reply.bodyHTML }}
/>
<div className="forum-reply-actions">
<button
className={`forum-reply-action ${reply.viewerHasUpvoted ? 'liked' : ''}`}
onClick={() => handleToggleReplyUpvote(reply.id, reply.viewerHasUpvoted)}
>
<ThumbsUp size={14} />
<span>{reply.upvoteCount}</span>
</button>
<button
className="forum-reply-action"
onClick={() => setReplyingTo(replyingTo === reply.id ? null : reply.id)}
>
<CornerDownRight size={14} />
<span>{t('forum.reply')}</span>
</button>
</div>
{replyingTo === reply.id && post && (
<form className="forum-reply-form nested" onSubmit={handleSubmitReply}>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={t('forum.replyTo', { login: reply.author.login })}
rows={2}
/>
<div className="forum-reply-form-actions">
<button
type="button"
className="forum-btn"
onClick={() => { setReplyingTo(null); setReplyContent(''); }}
>
{t('forum.cancel')}
</button>
<button
type="submit"
className="forum-btn forum-btn-primary"
disabled={!replyContent.trim() || submitting}
>
<Send size={14} />
<span>{t('forum.reply')}</span>
</button>
</div>
</form>
)}
{/* 嵌套回复 | Nested replies */}
{reply.replies?.nodes.map(child => renderReply(child, depth + 1))}
</div>
);
};
if (postLoading || !post) {
return (
<div className="forum-post-detail">
<div className="forum-detail-loading">
<RefreshCw className="spin" size={24} />
<span>{t('forum.loading')}</span>
</div>
</div>
);
}
return (
<div className="forum-post-detail">
{/* 返回按钮 | Back button */}
<button className="forum-back-btn" onClick={onBack}>
<ArrowLeft size={18} />
<span>{t('forum.backToList')}</span>
</button>
{/* 帖子内容 | Post content */}
<article className="forum-detail-article">
<header className="forum-detail-header">
<div className="forum-detail-category-row">
<span className="forum-detail-category">
{parseEmoji(post.category.emoji)} {post.category.name}
</span>
{post.answerChosenAt && (
<span className="forum-detail-answered">
<CheckCircle size={14} />
{t('forum.answered')}
</span>
)}
<button
className="forum-detail-external"
onClick={() => openInGitHub(post.url)}
title={t('forum.openInGitHub')}
>
<ExternalLink size={14} />
<span>GitHub</span>
</button>
</div>
<h1 className="forum-detail-title">{post.title}</h1>
<div className="forum-detail-meta">
<div className="forum-detail-author">
<img src={post.author.avatarUrl} alt={post.author.login} />
<span>@{post.author.login}</span>
</div>
<span className="forum-detail-time">
<Clock size={14} />
{formatDate(post.createdAt)}
</span>
</div>
</header>
<div
className="forum-detail-content"
dangerouslySetInnerHTML={{ __html: post.bodyHTML }}
/>
<footer className="forum-detail-footer">
<div className="forum-detail-stats">
<button
className={`forum-detail-stat interactive ${post.viewerHasUpvoted ? 'liked' : ''}`}
onClick={toggleUpvote}
>
<ThumbsUp size={16} />
<span>{post.upvoteCount}</span>
</button>
<div className="forum-detail-stat">
<MessageCircle size={16} />
<span>{post.comments.totalCount}</span>
</div>
</div>
</footer>
</article>
{/* 回复区 | Replies section */}
<section className="forum-replies-section">
<h2 className="forum-replies-title">
<MessageCircle size={18} />
<span>
{t('forum.comments')}
{post.comments.totalCount > 0 && ` (${post.comments.totalCount})`}
</span>
</h2>
{/* 回复输入框 | Reply input */}
{replyingTo === null && (
<form className="forum-reply-form" onSubmit={handleSubmitReply}>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={t('forum.writeComment')}
rows={3}
/>
<div className="forum-reply-form-actions">
<button
type="submit"
className="forum-btn forum-btn-primary"
disabled={!replyContent.trim() || submitting}
>
<Send size={14} />
<span>{submitting ? t('forum.posting') : t('forum.postComment')}</span>
</button>
</div>
</form>
)}
{/* 回复列表 | Reply list */}
<div className="forum-replies-list">
{repliesLoading ? (
<div className="forum-replies-loading">
<RefreshCw className="spin" size={20} />
</div>
) : replies.length === 0 ? (
<div className="forum-replies-empty">
<p>{t('forum.noCommentsYet')}</p>
</div>
) : (
replies.map(reply => renderReply(reply))
)}
</div>
</section>
</div>
);
}
@@ -0,0 +1,590 @@
/**
* 论坛帖子列表样式
* Forum post list styles
*/
.forum-post-list {
display: flex;
flex-direction: column;
height: 100%;
}
/* 欢迎横幅 | Welcome banner */
.forum-welcome-banner {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-bottom: 1px solid #3a4a5a;
padding: 16px;
}
.forum-welcome-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.forum-welcome-text h2 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
}
.forum-welcome-text p {
margin: 0;
font-size: 11px;
color: #9ca3af;
}
.forum-welcome-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.forum-btn-github {
background: #24292e;
border-color: #444;
color: #e0e0e0;
}
.forum-btn-github:hover:not(:disabled) {
background: #2f363d;
border-color: #555;
}
/* 分类卡片 | Category cards */
.forum-category-cards {
display: flex;
gap: 8px;
padding: 12px;
overflow-x: auto;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
}
.forum-category-cards::-webkit-scrollbar {
height: 4px;
}
.forum-category-cards::-webkit-scrollbar-track {
background: #2a2a2a;
}
.forum-category-cards::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 2px;
}
.forum-category-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 16px;
min-width: 80px;
background: #363636;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-category-card:hover {
background: #404040;
border-color: #4a9eff;
transform: translateY(-2px);
}
.forum-category-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(74, 158, 255, 0.15);
border-radius: 50%;
color: #4a9eff;
}
.forum-category-card-emoji {
font-size: 16px;
line-height: 1;
}
.forum-category-card-name {
font-size: 10px;
color: #999;
text-align: center;
white-space: nowrap;
}
/* 工具栏 | Toolbar */
.forum-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid #1a1a1a;
background: #333;
}
.forum-toolbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.forum-search {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
min-width: 180px;
}
.forum-search svg {
color: #666;
flex-shrink: 0;
}
.forum-search input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 11px;
color: #e0e0e0;
}
.forum-search input::placeholder {
color: #666;
}
/* 过滤器 | Filters */
.forum-filters {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid #1a1a1a;
background: #2d2d2d;
}
.forum-filter-group {
display: flex;
align-items: center;
gap: 4px;
}
.forum-filter-group svg {
color: #666;
}
.forum-filter-group select {
padding: 3px 6px;
font-size: 11px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
color: #ddd;
cursor: pointer;
}
.forum-filter-group select:focus {
outline: none;
border-color: #4a9eff;
}
.forum-post-count {
margin-left: auto;
font-size: 11px;
color: #666;
}
/* 帖子统计栏 | Stats bar */
.forum-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2a2a2a;
border-bottom: 1px solid #1a1a1a;
}
.forum-stats-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #888;
}
.forum-stats-left svg {
color: #4a9eff;
}
.forum-stats-clear {
background: none;
border: none;
font-size: 11px;
color: #4a9eff;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-stats-clear:hover {
background: rgba(74, 158, 255, 0.1);
}
/* 下拉选择框 | Select dropdown */
.forum-select {
padding: 4px 8px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #ddd;
cursor: pointer;
min-width: 120px;
}
.forum-select:focus {
outline: none;
border-color: #4a9eff;
}
/* 帖子列表 | Post list */
.forum-posts {
flex: 1;
overflow-y: auto;
position: relative;
}
.forum-posts.loading {
pointer-events: none;
}
.forum-posts.loading > *:not(.forum-posts-overlay) {
opacity: 0.5;
transition: opacity 0.15s ease;
}
/* 加载覆盖层 | Loading overlay */
.forum-posts-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(42, 42, 42, 0.7);
z-index: 10;
backdrop-filter: blur(2px);
}
.forum-posts-overlay svg {
color: #4a9eff;
}
.forum-posts-loading,
.forum-posts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 12px;
color: #888;
}
.forum-posts-empty svg {
opacity: 0.3;
color: #444;
}
.forum-posts-empty p {
margin: 0;
font-size: 12px;
}
/* 帖子项 | Post item */
.forum-post-item {
display: flex;
gap: 12px;
padding: 14px 12px;
border-bottom: 1px solid #1a1a1a;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.forum-post-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.forum-post-item.hot {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 50%);
border-left: 2px solid #ef4444;
}
.forum-post-item.hot:hover {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, rgba(255, 255, 255, 0.03) 50%);
}
.forum-post-avatar {
position: relative;
flex-shrink: 0;
}
.forum-post-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #3a3a3a;
}
.forum-post-avatar-badge {
position: absolute;
bottom: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #2a2a2a;
}
.forum-post-avatar-badge.hot {
background: #ef4444;
color: white;
}
.forum-post-content {
flex: 1;
min-width: 0;
}
.forum-post-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}
.forum-post-badges {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.forum-post-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.forum-post-badge.new {
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
}
.forum-post-badge.hot {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.forum-post-badge.pinned {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.forum-post-badge.locked {
background: rgba(107, 114, 128, 0.15);
color: #9ca3af;
}
.forum-post-external {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: none;
border: none;
color: #555;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
margin-left: auto;
}
.forum-post-external:hover {
background: rgba(255, 255, 255, 0.1);
color: #4a9eff;
}
.forum-post-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
background: rgba(74, 158, 255, 0.1);
color: #4a9eff;
}
.forum-post-title {
flex: 1;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forum-post-item:hover .forum-post-title {
color: #4a9eff;
}
.forum-post-excerpt {
margin: 0 0 8px 0;
font-size: 11px;
color: #888;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.forum-post-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.forum-post-author {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #888;
}
.forum-post-author-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
}
.forum-post-author-placeholder {
width: 18px;
height: 18px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
color: white;
}
.forum-post-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
/* 帖子统计 | Post stats */
.forum-post-stats {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.forum-post-stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
}
.forum-post-stat.active {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.forum-post-stat.active svg {
fill: #ef4444;
}
.forum-post-answered {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #10b981;
background: rgba(16, 185, 129, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
/* 加载更多 | Load more */
.forum-load-more {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px solid #1a1a1a;
}
.forum-load-more .forum-btn {
min-width: 120px;
}
/* 分页 | Pagination */
.forum-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 12px;
border-top: 1px solid #1a1a1a;
background: #2d2d2d;
}
.forum-pagination-info {
font-size: 11px;
color: #888;
}
@@ -0,0 +1,337 @@
/**
* - GitHub Discussions
* Post list component - GitHub Discussions
*/
import { useState } from 'react';
import {
Plus, RefreshCw, Search, MessageCircle, ThumbsUp,
ExternalLink, CheckCircle, Flame, Clock, TrendingUp,
Lightbulb, HelpCircle, Megaphone, BarChart3, Github
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { useLocale } from '../../hooks/useLocale';
import type { Post, Category, PostListParams } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumPostList.css';
interface ForumPostListProps {
posts: Post[];
categories: Category[];
loading: boolean;
totalCount: number;
hasNextPage: boolean;
params: PostListParams;
onViewPost: (postNumber: number) => void;
onCreatePost: () => void;
onCategoryChange: (categoryId: string | undefined) => void;
onSearch: (search: string) => void;
onRefresh: () => void;
onLoadMore: () => void;
}
/**
* | Get category icon
*/
function getCategoryIcon(name: string) {
const lowerName = name.toLowerCase();
if (lowerName.includes('idea') || lowerName.includes('建议')) return <Lightbulb size={14} />;
if (lowerName.includes('q&a') || lowerName.includes('问答')) return <HelpCircle size={14} />;
if (lowerName.includes('show') || lowerName.includes('展示')) return <Megaphone size={14} />;
if (lowerName.includes('poll') || lowerName.includes('投票')) return <BarChart3 size={14} />;
return <MessageCircle size={14} />;
}
export function ForumPostList({
posts,
categories,
loading,
totalCount,
hasNextPage,
params,
onViewPost,
onCreatePost,
onCategoryChange,
onSearch,
onRefresh,
onLoadMore
}: ForumPostListProps) {
const { t } = useLocale();
const [searchInput, setSearchInput] = useState(params.search || '');
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchInput);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const mins = Math.floor(diff / (1000 * 60));
if (mins < 1) return t('forum.justNow');
return t('forum.minutesAgo', { count: mins });
}
return t('forum.hoursAgo', { count: hours });
}
if (days === 1) return t('forum.yesterday');
if (days < 7) return t('forum.daysAgo', { count: days });
return date.toLocaleDateString();
};
const openInGitHub = async (url: string, e: React.MouseEvent) => {
e.stopPropagation();
await open(url);
};
// 检查帖子是否是热门(高点赞或评论)| Check if post is hot
const isHotPost = (post: Post) => post.upvoteCount >= 5 || post.comments.totalCount >= 3;
// 检查帖子是否是新帖(24小时内)| Check if post is recent
const isRecentPost = (post: Post) => {
const diff = Date.now() - new Date(post.createdAt).getTime();
return diff < 24 * 60 * 60 * 1000;
};
const openGitHubDiscussions = async () => {
await open('https://github.com/esengine/esengine/discussions');
};
return (
<div className="forum-post-list">
{/* 欢迎横幅 | Welcome banner */}
{!params.categoryId && !params.search && (
<div className="forum-welcome-banner">
<div className="forum-welcome-content">
<div className="forum-welcome-text">
<h2>{t('forum.communityTitle')}</h2>
<p>{t('forum.askQuestionsShareIdeas')}</p>
</div>
<div className="forum-welcome-actions">
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
<Plus size={14} />
<span>{t('forum.newDiscussion')}</span>
</button>
<button className="forum-btn forum-btn-github" onClick={openGitHubDiscussions}>
<Github size={14} />
<span>{t('forum.viewOnGitHub')}</span>
</button>
</div>
</div>
</div>
)}
{/* 分类卡片 | Category cards */}
{!params.categoryId && !params.search && categories.length > 0 && (
<div className="forum-category-cards">
{categories.map(cat => (
<button
key={cat.id}
className="forum-category-card"
onClick={() => onCategoryChange(cat.id)}
>
<span className="forum-category-card-icon">
{getCategoryIcon(cat.name)}
</span>
<span className="forum-category-card-emoji">{parseEmoji(cat.emoji)}</span>
<span className="forum-category-card-name">{cat.name}</span>
</button>
))}
</div>
)}
{/* 工具栏 | Toolbar */}
<div className="forum-toolbar">
<div className="forum-toolbar-left">
<select
className="forum-select"
value={params.categoryId || ''}
onChange={(e) => onCategoryChange(e.target.value || undefined)}
>
<option value="">{t('forum.allCategories')}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{parseEmoji(cat.emoji)} {cat.name}
</option>
))}
</select>
<form onSubmit={handleSearchSubmit} className="forum-search">
<Search size={14} />
<input
type="text"
placeholder={t('forum.searchDiscussions')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</form>
</div>
<div className="forum-toolbar-right">
<button
className="forum-btn"
onClick={onRefresh}
disabled={loading}
title={t('forum.refresh')}
>
<RefreshCw size={14} className={loading ? 'spin' : ''} />
</button>
<button
className="forum-btn forum-btn-primary"
onClick={onCreatePost}
>
<Plus size={14} />
<span>{t('forum.new')}</span>
</button>
</div>
</div>
{/* 帖子统计 | Post stats */}
<div className="forum-stats">
<div className="forum-stats-left">
<TrendingUp size={14} />
<span>{totalCount} {t('forum.discussions')}</span>
</div>
{params.categoryId && (
<button
className="forum-stats-clear"
onClick={() => onCategoryChange(undefined)}
>
{t('forum.clearFilter')}
</button>
)}
</div>
{/* 帖子列表 | Post list */}
<div className={`forum-posts ${loading ? 'loading' : ''}`}>
{/* 加载覆盖层 | Loading overlay */}
{loading && posts.length > 0 && (
<div className="forum-posts-overlay">
<RefreshCw size={20} className="spin" />
</div>
)}
{loading && posts.length === 0 ? (
<div className="forum-posts-loading">
<RefreshCw size={16} className="spin" />
<span>{t('forum.loading')}</span>
</div>
) : posts.length === 0 ? (
<div className="forum-posts-empty">
<MessageCircle size={32} />
<p>{t('forum.noDiscussionsYet')}</p>
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
<Plus size={14} />
<span>{t('forum.startADiscussion')}</span>
</button>
</div>
) : (
<>
{posts.map(post => (
<div
key={post.id}
className={`forum-post-item ${isHotPost(post) ? 'hot' : ''}`}
onClick={() => onViewPost(post.number)}
>
<div className="forum-post-avatar">
<img
src={post.author.avatarUrl}
alt={post.author.login}
/>
{isHotPost(post) && (
<span className="forum-post-avatar-badge hot">
<Flame size={10} />
</span>
)}
</div>
<div className="forum-post-content">
<div className="forum-post-header">
<div className="forum-post-badges">
{isRecentPost(post) && (
<span className="forum-post-badge new">
<Clock size={10} />
{t('forum.newBadge')}
</span>
)}
{isHotPost(post) && (
<span className="forum-post-badge hot">
<Flame size={10} />
{t('forum.hotBadge')}
</span>
)}
</div>
<h3 className="forum-post-title">{post.title}</h3>
<button
className="forum-post-external"
onClick={(e) => openInGitHub(post.url, e)}
title={t('forum.openInGitHub')}
>
<ExternalLink size={12} />
</button>
</div>
<div className="forum-post-meta">
<span className="forum-post-category">
{parseEmoji(post.category.emoji)} {post.category.name}
</span>
<span className="forum-post-author">
<img
src={post.author.avatarUrl}
alt={post.author.login}
className="forum-post-author-avatar"
/>
@{post.author.login}
</span>
<span className="forum-post-time">
<Clock size={11} />
{formatDate(post.createdAt)}
</span>
</div>
<div className="forum-post-stats">
<span className={`forum-post-stat ${post.viewerHasUpvoted ? 'active' : ''}`}>
<ThumbsUp size={12} />
{post.upvoteCount}
</span>
<span className="forum-post-stat">
<MessageCircle size={12} />
{post.comments.totalCount}
</span>
{post.answerChosenAt && (
<span className="forum-post-answered">
<CheckCircle size={12} />
{t('forum.answered')}
</span>
)}
</div>
</div>
</div>
))}
{/* 加载更多 | Load more */}
{hasNextPage && (
<div className="forum-load-more">
<button
className="forum-btn"
onClick={onLoadMore}
disabled={loading}
>
{loading ? (
<>
<RefreshCw size={14} className="spin" />
<span>{t('forum.loading')}</span>
</>
) : (
<span>{t('forum.loadMore')}</span>
)}
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,97 @@
/**
* 用户资料样式 - GitHub
* User profile styles - GitHub
*/
.forum-profile {
padding: 16px;
background: #2a2a2a;
border-radius: 6px;
border: 1px solid #3a3a3a;
}
.forum-profile-header {
display: flex;
gap: 12px;
}
.forum-profile-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.forum-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.forum-profile-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.forum-profile-name {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.forum-profile-github-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #888;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s ease;
}
.forum-profile-github-link:hover {
color: #4a9eff;
}
.forum-profile-divider {
height: 1px;
background: #3a3a3a;
margin: 12px 0;
}
.forum-profile-actions {
display: flex;
justify-content: flex-end;
}
.forum-profile-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.forum-profile-btn.logout {
background: transparent;
border-color: #4a4a4a;
color: #888;
}
.forum-profile-btn.logout:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #f87171;
color: #f87171;
}
@@ -0,0 +1,65 @@
/**
* - GitHub
* User profile component - GitHub
*/
import { Github, LogOut, ExternalLink } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { useLocale } from '../../hooks/useLocale';
import { useForumAuth } from '../../hooks/useForum';
import './ForumProfile.css';
interface ForumProfileProps {
onClose?: () => void;
}
export function ForumProfile({ onClose }: ForumProfileProps) {
const { t } = useLocale();
const { authState, signOut } = useForumAuth();
const user = authState.status === 'authenticated' ? authState.user : null;
const handleSignOut = async () => {
await signOut();
onClose?.();
};
const openGitHubProfile = async () => {
if (user) {
await open(`https://github.com/${user.login}`);
}
};
if (!user) {
return null;
}
return (
<div className="forum-profile">
<div className="forum-profile-header">
<div className="forum-profile-avatar">
<img src={user.avatarUrl} alt={user.login} />
</div>
<div className="forum-profile-info">
<h3 className="forum-profile-name">@{user.login}</h3>
<button
className="forum-profile-github-link"
onClick={openGitHubProfile}
>
<Github size={12} />
<span>{t('forum.viewGitHubProfile')}</span>
<ExternalLink size={10} />
</button>
</div>
</div>
<div className="forum-profile-divider" />
<div className="forum-profile-actions">
<button className="forum-profile-btn logout" onClick={handleSignOut}>
<LogOut size={14} />
<span>{t('forum.signOut')}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
/**
*
* Forum components exports
*/
export { ForumPanel } from './ForumPanel';
export { ForumAuth } from './ForumAuth';
export { ForumPostList } from './ForumPostList';
export { ForumPostDetail } from './ForumPostDetail';
export { ForumCreatePost } from './ForumCreatePost';
@@ -0,0 +1,138 @@
/**
* | Forum utility functions
*/
/**
* GitHub emoji | GitHub emoji shortcode mapping
*/
const EMOJI_MAP: Record<string, string> = {
':speech_balloon:': '💬',
':bulb:': '💡',
':pray:': '🙏',
':raised_hands:': '🙌',
':ballot_box:': '🗳️',
':rocket:': '🚀',
':bug:': '🐛',
':sparkles:': '✨',
':memo:': '📝',
':question:': '❓',
':fire:': '🔥',
':star:': '⭐',
':heart:': '❤️',
':thumbsup:': '👍',
':warning:': '⚠️',
':book:': '📖',
':wrench:': '🔧',
':gear:': '⚙️',
':zap:': '⚡',
':art:': '🎨',
':package:': '📦',
':lock:': '🔒',
':tada:': '🎉',
':wave:': '👋',
':eyes:': '👀',
':thinking:': '🤔',
':100:': '💯',
':clap:': '👏',
':hammer_and_wrench:': '🛠️',
':world_map:': '🗺️',
':video_game:': '🎮',
':computer:': '💻',
':pencil:': '✏️',
':pencil2:': '✏️',
':notebook:': '📓',
':clipboard:': '📋',
':pushpin:': '📌',
':loudspeaker:': '📢',
':mega:': '📣',
':bell:': '🔔',
':email:': '📧',
':mailbox:': '📫',
':inbox_tray:': '📥',
':outbox_tray:': '📤',
':file_folder:': '📁',
':open_file_folder:': '📂',
':card_index:': '📇',
':chart_with_upwards_trend:': '📈',
':chart_with_downwards_trend:': '📉',
':bar_chart:': '📊',
':date:': '📅',
':calendar:': '📆',
':card_index_dividers:': '🗂️',
':triangular_ruler:': '📐',
':straight_ruler:': '📏',
':scissors:': '✂️',
':link:': '🔗',
':paperclip:': '📎',
':hourglass:': '⌛',
':watch:': '⌚',
':alarm_clock:': '⏰',
':stopwatch:': '⏱️',
':timer_clock:': '⏲️',
':telephone:': '☎️',
':telephone_receiver:': '📞',
':pager:': '📟',
':fax:': '📠',
':battery:': '🔋',
':electric_plug:': '🔌',
':desktop_computer:': '🖥️',
':printer:': '🖨️',
':keyboard:': '⌨️',
':computer_mouse:': '🖱️',
':trackball:': '🖲️',
':minidisc:': '💽',
':floppy_disk:': '💾',
':cd:': '💿',
':dvd:': '📀',
':abacus:': '🧮',
':movie_camera:': '🎥',
':film_strip:': '🎞️',
':film_projector:': '📽️',
':clapper:': '🎬',
':tv:': '📺',
':camera:': '📷',
':camera_flash:': '📸',
':video_camera:': '📹',
':mag:': '🔍',
':mag_right:': '🔎',
':candle:': '🕯️',
':bulb_lightbulb:': '💡',
':flashlight:': '🔦',
':izakaya_lantern:': '🏮',
':diya_lamp:': '🪔',
':notebook_with_decorative_cover:': '📔',
':closed_book:': '📕',
':green_book:': '📗',
':blue_book:': '📘',
':orange_book:': '📙',
':books:': '📚',
':ledger:': '📒',
':page_with_curl:': '📃',
':scroll:': '📜',
':page_facing_up:': '📄',
':newspaper:': '📰',
':rolled_up_newspaper:': '🗞️',
':bookmark_tabs:': '📑',
':bookmark:': '🔖',
':label:': '🏷️',
':moneybag:': '💰',
':coin:': '🪙',
':yen:': '💴',
':dollar:': '💵',
':euro:': '💶',
':pound:': '💷',
':money_with_wings:': '💸',
':credit_card:': '💳',
':receipt:': '🧾',
':chart:': '💹',
};
/**
* GitHub emoji Unicode | Convert GitHub emoji shortcode to Unicode
*/
export function parseEmoji(emojiCode: string | undefined | null): string {
if (!emojiCode) return '💬';
// 如果已经是 emoji,直接返回 | If already emoji, return directly
if (!emojiCode.startsWith(':')) return emojiCode;
return EMOJI_MAP[emojiCode] || emojiCode.replace(/:/g, '');
}
@@ -0,0 +1,501 @@
/**
* ComponentPropertyEditor -
* ComponentPropertyEditor - Component property editor
*
* 使
* Renders component properties using new controls
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Component, Core, Entity, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
import { PropertyMetadataService, MessageHub, PrefabService, FileActionRegistry, AssetRegistryService } from '@esengine/editor-core';
import { Lock } from 'lucide-react';
import {
PropertyRow,
NumberInput,
StringInput,
BooleanInput,
VectorInput,
EnumInput,
ColorInput,
AssetInput,
EntityRefInput,
ArrayInput
} from './controls';
// ==================== 类型定义 | Type Definitions ====================
interface PropertyMetadata {
type: string;
label?: string;
min?: number;
max?: number;
step?: number;
readOnly?: boolean;
placeholder?: string;
options?: Array<{ label: string; value: string | number } | string | number>;
controls?: Array<{ component: string; property: string }>;
category?: string;
assetType?: string;
extensions?: string[];
itemType?: { type: string; extensions?: string[]; assetType?: string };
minLength?: number;
maxLength?: number;
reorderable?: boolean;
actions?: Array<{ id: string; label: string; icon?: string; tooltip?: string }>;
}
export interface ComponentPropertyEditorProps {
/** 组件实例 | Component instance */
component: Component;
/** 所属实体 | Owner entity */
entity?: Entity;
/** 版本号 | Version number */
version?: number;
/** 属性变更回调 | Property change callback */
onChange?: (propertyName: string, value: any) => void;
/** 动作回调 | Action callback */
onAction?: (actionId: string, propertyName: string, component: Component) => void;
}
// ==================== 主组件 | Main Component ====================
export const ComponentPropertyEditor: React.FC<ComponentPropertyEditorProps> = ({
component,
entity,
version,
onChange,
onAction
}) => {
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
// 服务 | Services
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
// 预制体实例组件 | Prefab instance component
const prefabInstanceComp = useMemo(() => {
return entity?.getComponent(PrefabInstanceComponent) ?? null;
}, [entity, version]);
// 检查属性是否被覆盖 | Check if property is overridden
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
if (!prefabInstanceComp) return false;
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
}, [prefabInstanceComp, componentTypeName]);
// 加载属性元数据 | Load property metadata
useEffect(() => {
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
if (!propertyMetadataService) return;
const metadata = propertyMetadataService.getEditableProperties(component);
setProperties(metadata as Record<string, PropertyMetadata>);
}, [component]);
// 扫描控制字段 | Scan controlled fields
useEffect(() => {
if (!entity) return;
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
if (!propertyMetadataService) return;
const componentName = getComponentInstanceTypeName(component);
const controlled = new Map<string, string>();
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent) as Record<string, PropertyMetadata>;
const otherComponentName = getComponentInstanceTypeName(otherComponent);
for (const [, propMeta] of Object.entries(otherMetadata)) {
if (propMeta.controls) {
for (const control of propMeta.controls) {
if (control.component === componentName ||
control.component === componentName.replace('Component', '')) {
controlled.set(control.property, otherComponentName.replace('Component', ''));
}
}
}
}
}
setControlledFields(controlled);
}, [component, entity, version]);
// 关闭右键菜单 | Close context menu
useEffect(() => {
const handleClick = () => setContextMenu(null);
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
// 获取属性值 | Get property value
const getValue = useCallback((propertyName: string) => {
return (component as any)[propertyName];
}, [component, version]);
// 处理属性变更 | Handle property change
const handleChange = useCallback((propertyName: string, value: any) => {
(component as any)[propertyName] = value;
if (onChange) {
onChange(propertyName, value);
}
const messageHub = Core.services.resolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
}, [component, onChange]);
// 处理动作 | Handle action
const handleAction = useCallback((actionId: string, propertyName: string) => {
if (onAction) {
onAction(actionId, propertyName, component);
}
}, [onAction, component]);
// 还原属性 | Revert property
const handleRevertProperty = useCallback(async () => {
if (!contextMenu || !prefabService || !entity) return;
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
setContextMenu(null);
}, [contextMenu, prefabService, entity, componentTypeName]);
// 处理右键菜单 | Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
if (!isPropertyOverridden(propertyName)) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
}, [isPropertyOverridden]);
// 获取控制者 | Get controlled by
const getControlledBy = (propertyName: string): string | undefined => {
return controlledFields.get(propertyName);
};
// ==================== 渲染属性 | Render Property ====================
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
const value = getValue(propertyName);
const label = metadata.label || propertyName;
const readonly = metadata.readOnly || !!getControlledBy(propertyName);
const controlledBy = getControlledBy(propertyName);
// 标签后缀(如果被控制)| Label suffix (if controlled)
const labelElement = controlledBy ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{label}
<span title={`Controlled by ${controlledBy}`}>
<Lock size={10} style={{ color: 'var(--inspector-text-secondary)' }} />
</span>
</span>
) : label;
const labelTitle = label;
switch (metadata.type) {
case 'number':
case 'integer':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle} draggable>
<NumberInput
value={value ?? 0}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
min={metadata.min}
max={metadata.max}
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
integer={metadata.type === 'integer'}
/>
</PropertyRow>
);
case 'string':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<StringInput
value={value ?? ''}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
placeholder={metadata.placeholder}
/>
</PropertyRow>
);
case 'boolean':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<BooleanInput
value={value ?? false}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
/>
</PropertyRow>
);
case 'color': {
let colorValue = value ?? '#ffffff';
const wasNumber = typeof colorValue === 'number';
if (wasNumber) {
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
}
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<ColorInput
value={colorValue}
onChange={(v) => {
if (wasNumber && typeof v === 'string') {
handleChange(propertyName, parseInt(v.slice(1), 16));
} else {
handleChange(propertyName, v);
}
}}
readonly={readonly}
/>
</PropertyRow>
);
}
case 'vector2':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<VectorInput
value={value ?? { x: 0, y: 0 }}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
dimensions={2}
/>
</PropertyRow>
);
case 'vector3':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<VectorInput
value={value ?? { x: 0, y: 0, z: 0 }}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
dimensions={3}
/>
</PropertyRow>
);
case 'enum': {
const options = (metadata.options || []).map(opt =>
typeof opt === 'object' ? opt : { label: String(opt), value: opt }
);
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<EnumInput
value={value}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
options={options}
/>
</PropertyRow>
);
}
case 'asset': {
const handleNavigate = (path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
};
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
const getCreationMapping = () => {
if (!fileActionRegistry || !metadata.extensions) return null;
for (const ext of metadata.extensions) {
const mapping = (fileActionRegistry as any).getAssetCreationMapping?.(ext);
if (mapping) return mapping;
}
return null;
};
const creationMapping = getCreationMapping();
// 解析资产值 | Resolve asset value
// 检查值是否为 GUID(UUID 格式)并尝试解析为路径
// Check if value is a GUID (UUID format) and try to resolve to path
const resolveAssetValue = () => {
if (!value) return null;
const strValue = String(value);
// GUID 格式检查:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// UUID format check
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strValue);
if (isGuid) {
// 尝试从 AssetRegistryService 获取路径
// Try to get path from AssetRegistryService
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
if (assetRegistry) {
const assetMeta = assetRegistry.getAsset(strValue);
if (assetMeta) {
return {
id: strValue,
path: assetMeta.path,
type: assetMeta.type
};
}
}
// 如果无法解析,仍然显示 GUID
// If cannot resolve, still show GUID
return { id: strValue, path: strValue };
}
// 不是 GUID,假设是路径
// Not a GUID, assume it's a path
return { id: strValue, path: strValue };
};
const assetValue = resolveAssetValue();
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<AssetInput
value={assetValue}
onChange={(v) => {
if (v === null) {
handleChange(propertyName, '');
} else if (typeof v === 'string') {
handleChange(propertyName, v);
} else {
// 存储路径而不是 GUID
// Store path instead of GUID
handleChange(propertyName, v.path || v.id || '');
}
}}
readonly={readonly}
extensions={metadata.extensions}
onPickAsset={() => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:pick', {
extensions: metadata.extensions,
onSelect: (path: string) => handleChange(propertyName, path)
});
}
}}
onOpenAsset={(asset) => {
if (asset.path) handleNavigate(asset.path);
}}
onLocateAsset={(asset) => {
if (asset.path) handleNavigate(asset.path);
}}
/>
</PropertyRow>
);
}
case 'entityRef':
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<EntityRefInput
value={value ?? null}
onChange={(v) => {
const id = typeof v === 'object' && v !== null ? v.id : v;
handleChange(propertyName, id);
}}
readonly={readonly}
resolveEntityName={(id) => {
if (!entity) return undefined;
const scene = entity.scene;
if (!scene) return undefined;
const targetEntity = (scene as any).getEntityById?.(Number(id));
return targetEntity?.name;
}}
onLocateEntity={(id) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('hierarchy:select', { entityId: Number(id) });
}
}}
/>
</PropertyRow>
);
case 'array': {
return (
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
<ArrayInput
value={value ?? []}
onChange={(v) => handleChange(propertyName, v)}
readonly={readonly}
minItems={metadata.minLength}
maxItems={metadata.maxLength}
sortable={metadata.reorderable ?? true}
/>
</PropertyRow>
);
}
default:
return null;
}
};
// ==================== 渲染 | Render ====================
return (
<div className="component-property-editor">
{Object.entries(properties).map(([propertyName, metadata]) => {
const overridden = isPropertyOverridden(propertyName);
return (
<div
key={propertyName}
className={overridden ? 'property-overridden' : ''}
onContextMenu={(e) => handleContextMenu(e, propertyName)}
style={overridden ? { borderLeft: '2px solid var(--inspector-accent)' } : undefined}
>
{renderProperty(propertyName, metadata)}
</div>
);
})}
{/* Context Menu */}
{contextMenu && (
<div
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
background: 'var(--inspector-bg-section)',
border: '1px solid var(--inspector-border-light)',
borderRadius: '4px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
zIndex: 1000,
overflow: 'hidden'
}}
>
<button
onClick={handleRevertProperty}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
width: '100%',
background: 'transparent',
border: 'none',
color: 'var(--inspector-text-primary)',
cursor: 'pointer',
fontSize: '12px'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--inspector-bg-hover)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<span></span>
<span>Revert to Prefab</span>
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,695 @@
/**
* EntityInspectorPanel -
* EntityInspectorPanel - Entity inspector panel
*
* 使 Inspector
* Entity inspector using new Inspector architecture
*/
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import {
ChevronDown,
ChevronRight,
Plus,
X,
Box,
Search,
Lock,
Unlock,
Settings
} from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import {
Entity,
Component,
Core,
getComponentDependencies,
getComponentTypeName,
getComponentInstanceTypeName,
isComponentInstanceHiddenInInspector,
PrefabInstanceComponent
} from '@esengine/ecs-framework';
import {
MessageHub,
CommandManager,
EditorComponentRegistry,
ComponentActionRegistry,
ComponentInspectorRegistry,
PrefabService,
PropertyMetadataService
} from '@esengine/editor-core';
import { NotificationService } from '../../services/NotificationService';
import {
RemoveComponentCommand,
UpdateComponentCommand,
AddComponentCommand
} from '../../application/commands/component';
import { PropertySearch, CategoryTabs } from './header';
import { PropertySection } from './sections';
import { ComponentPropertyEditor } from './ComponentPropertyEditor';
import { CategoryConfig } from './types';
import './styles/inspector.css';
// ==================== 类型定义 | Type Definitions ====================
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
interface ComponentInfo {
name: string;
type?: new () => Component;
category?: string;
description?: string;
icon?: string;
}
export interface EntityInspectorPanelProps {
/** 目标实体 | Target entity */
entity: Entity;
/** 消息中心 | Message hub */
messageHub: MessageHub;
/** 命令管理器 | Command manager */
commandManager: CommandManager;
/** 组件版本号 | Component version */
componentVersion: number;
/** 是否锁定 | Is locked */
isLocked?: boolean;
/** 锁定变更回调 | Lock change callback */
onLockChange?: (locked: boolean) => void;
}
// ==================== 常量 | Constants ====================
const CATEGORY_MAP: Record<string, CategoryFilter> = {
'components.category.core': 'general',
'components.category.rendering': 'rendering',
'components.category.physics': 'physics',
'components.category.audio': 'audio',
'components.category.ui': 'rendering',
'components.category.ui.core': 'rendering',
'components.category.ui.widgets': 'rendering',
'components.category.other': 'other',
};
const CATEGORY_TABS: CategoryConfig[] = [
{ id: 'general', label: 'General' },
{ id: 'transform', label: 'Transform' },
{ id: 'rendering', label: 'Rendering' },
{ id: 'physics', label: 'Physics' },
{ id: 'audio', label: 'Audio' },
{ id: 'other', label: 'Other' },
{ id: 'all', label: 'All' }
];
const CATEGORY_LABELS: Record<string, string> = {
'components.category.core': '核心',
'components.category.rendering': '渲染',
'components.category.physics': '物理',
'components.category.audio': '音频',
'components.category.ui': 'UI',
'components.category.ui.core': 'UI 核心',
'components.category.ui.widgets': 'UI 控件',
'components.category.other': '其他',
};
// ==================== 主组件 | Main Component ====================
export const EntityInspectorPanel: React.FC<EntityInspectorPanelProps> = ({
entity,
messageHub,
commandManager,
componentVersion,
isLocked = false,
onLockChange
}) => {
// ==================== 状态 | State ====================
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [localVersion, setLocalVersion] = useState(0);
// 折叠状态(持久化)| Collapsed state (persisted)
const [collapsedComponents, setCollapsedComponents] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem('inspector-collapsed-components');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 组件添加菜单 | Component add menu
const [showAddMenu, setShowAddMenu] = useState(false);
const [addMenuSearch, setAddMenuSearch] = useState('');
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState(-1);
const addButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// ==================== 服务 | Services ====================
const componentRegistry = Core.services.resolve(EditorComponentRegistry);
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
// ==================== 计算属性 | Computed Properties ====================
const isPrefabInstance = useMemo(() => {
return entity.hasComponent(PrefabInstanceComponent);
}, [entity, componentVersion]);
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
const componentInfo = componentRegistry?.getComponent(componentName);
if (componentInfo?.category) {
return CATEGORY_MAP[componentInfo.category] || 'general';
}
return 'general';
}, [componentRegistry]);
// 计算当前实体拥有的分类 | Compute categories present in current entity
const availableCategories = useMemo((): CategoryConfig[] => {
const categorySet = new Set<CategoryFilter>();
entity.components.forEach((component: Component) => {
if (isComponentInstanceHiddenInInspector(component)) return;
const componentName = getComponentInstanceTypeName(component);
const category = getComponentCategory(componentName);
categorySet.add(category);
});
// 只显示实际存在的分类 + All | Only show categories that exist + All
const categories: CategoryConfig[] = [];
// 按固定顺序添加存在的分类 | Add existing categories in fixed order
const orderedCategories: { id: CategoryFilter; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'transform', label: 'Transform' },
{ id: 'rendering', label: 'Rendering' },
{ id: 'physics', label: 'Physics' },
{ id: 'audio', label: 'Audio' },
{ id: 'other', label: 'Other' },
];
for (const cat of orderedCategories) {
if (categorySet.has(cat.id)) {
categories.push(cat);
}
}
// 如果有多个分类,添加 All 选项 | If multiple categories, add All option
if (categories.length > 1) {
categories.push({ id: 'all', label: 'All' });
}
return categories;
}, [entity.components, getComponentCategory, componentVersion]);
// 过滤组件列表 | Filter component list
const filteredComponents = useMemo(() => {
return entity.components.filter((component: Component) => {
if (isComponentInstanceHiddenInInspector(component)) {
return false;
}
const componentName = getComponentInstanceTypeName(component);
if (categoryFilter !== 'all') {
const category = getComponentCategory(componentName);
if (category !== categoryFilter) {
return false;
}
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
if (!componentName.toLowerCase().includes(query)) {
return false;
}
}
return true;
});
}, [entity.components, categoryFilter, searchQuery, getComponentCategory, componentVersion]);
// 添加菜单组件分组 | Add menu component grouping
const groupedComponents = useMemo(() => {
const query = addMenuSearch.toLowerCase().trim();
const filtered = query
? availableComponents.filter(c =>
c.name.toLowerCase().includes(query) ||
(c.description && c.description.toLowerCase().includes(query))
)
: availableComponents;
const grouped = new Map<string, ComponentInfo[]>();
filtered.forEach((info) => {
const cat = info.category || 'components.category.other';
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push(info);
});
return grouped;
}, [availableComponents, addMenuSearch]);
// 扁平化列表(用于键盘导航)| Flat list (for keyboard navigation)
const flatComponents = useMemo(() => {
const result: ComponentInfo[] = [];
for (const [category, components] of groupedComponents.entries()) {
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
if (!isCollapsed) {
result.push(...components);
}
}
return result;
}, [groupedComponents, collapsedCategories, addMenuSearch]);
// ==================== 副作用 | Effects ====================
// 保存折叠状态 | Save collapsed state
useEffect(() => {
try {
localStorage.setItem(
'inspector-collapsed-components',
JSON.stringify([...collapsedComponents])
);
} catch {
// Ignore
}
}, [collapsedComponents]);
// 打开添加菜单时聚焦搜索 | Focus search when opening add menu
useEffect(() => {
if (showAddMenu) {
setAddMenuSearch('');
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}, [showAddMenu]);
// 重置选中索引 | Reset selected index
useEffect(() => {
setSelectedIndex(addMenuSearch ? 0 : -1);
}, [addMenuSearch]);
// 当前分类不可用时重置 | Reset when current category is not available
useEffect(() => {
if (availableCategories.length <= 1) {
// 只有一个或没有分类时,使用 all
setCategoryFilter('all');
} else if (categoryFilter !== 'all' && !availableCategories.some(c => c.id === categoryFilter)) {
// 当前分类不在可用列表中,重置为 all
setCategoryFilter('all');
}
}, [availableCategories, categoryFilter]);
// ==================== 事件处理 | Event Handlers ====================
const toggleComponentExpanded = useCallback((componentName: string) => {
setCollapsedComponents(prev => {
const newSet = new Set(prev);
if (newSet.has(componentName)) {
newSet.delete(componentName);
} else {
newSet.add(componentName);
}
return newSet;
});
}, []);
const handleAddComponent = useCallback((ComponentClass: new () => Component) => {
const command = new AddComponentCommand(messageHub, entity, ComponentClass);
commandManager.execute(command);
setShowAddMenu(false);
}, [messageHub, entity, commandManager]);
const handleRemoveComponent = useCallback((component: Component) => {
const componentName = getComponentTypeName(component.constructor as any);
// 检查依赖 | Check dependencies
const dependentComponents: string[] = [];
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const dependencies = getComponentDependencies(otherComponent.constructor as any);
const otherName = getComponentTypeName(otherComponent.constructor as any);
if (dependencies && dependencies.includes(componentName)) {
dependentComponents.push(otherName);
}
}
if (dependentComponents.length > 0) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.warning(
'无法删除组件',
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
);
}
return;
}
const command = new RemoveComponentCommand(messageHub, entity, component);
commandManager.execute(command);
}, [messageHub, entity, commandManager]);
const handlePropertyChange = useCallback((component: Component, propertyName: string, value: unknown) => {
const command = new UpdateComponentCommand(
messageHub,
entity,
component,
propertyName,
value
);
commandManager.execute(command);
}, [messageHub, entity, commandManager]);
const handlePropertyAction = useCallback(async (actionId: string, _propertyName: string, component: Component) => {
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
const sprite = component as unknown as { texture: string; width: number; height: number };
if (!sprite.texture) return;
try {
const { convertFileSrc } = await import('@tauri-apps/api/core');
const assetUrl = convertFileSrc(sprite.texture);
const img = new Image();
img.onload = () => {
handlePropertyChange(component, 'width', img.naturalWidth);
handlePropertyChange(component, 'height', img.naturalHeight);
setLocalVersion(v => v + 1);
};
img.src = assetUrl;
} catch (error) {
console.error('Error getting texture size:', error);
}
}
}, [handlePropertyChange]);
const handleAddMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, flatComponents.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault();
const selected = flatComponents[selectedIndex];
if (selected?.type) {
handleAddComponent(selected.type);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowAddMenu(false);
}
}, [flatComponents, selectedIndex, handleAddComponent]);
const toggleCategory = useCallback((category: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev);
if (next.has(category)) next.delete(category);
else next.add(category);
return next;
});
}, []);
// ==================== 渲染 | Render ====================
return (
<div className="inspector-panel">
{/* Header */}
<div className="inspector-header">
<div className="inspector-header-info">
<button
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? '解锁检视器' : '锁定检视器'}
>
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<span className="inspector-header-icon">
<Settings size={14} />
</span>
<span className="inspector-header-name">
{entity.name || `Entity #${entity.id}`}
</span>
</div>
<span style={{ fontSize: '11px', color: 'var(--inspector-text-secondary)' }}>
1 object
</span>
</div>
{/* Search */}
<PropertySearch
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search components..."
/>
{/* Category Tabs - 只有多个分类时显示 | Only show when multiple categories */}
{availableCategories.length > 1 && (
<CategoryTabs
categories={availableCategories}
current={categoryFilter}
onChange={(cat) => setCategoryFilter(cat as CategoryFilter)}
/>
)}
{/* Content */}
<div className="inspector-panel-content">
{/* Add Component Section Header */}
<div className="inspector-section">
<div
className="inspector-section-header"
style={{ justifyContent: 'space-between' }}
>
<span className="inspector-section-title"></span>
<button
ref={addButtonRef}
className="inspector-header-add-btn"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus size={12} />
</button>
</div>
</div>
{/* Component List */}
{filteredComponents.length === 0 ? (
<div className="inspector-empty">
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
</div>
) : (
filteredComponents.map((component: Component) => {
const componentName = getComponentInstanceTypeName(component);
const isExpanded = !collapsedComponents.has(componentName);
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
return (
<div key={`${componentName}-${entity.components.indexOf(component)}`} className="inspector-section">
<div
className="inspector-section-header"
onClick={() => toggleComponentExpanded(componentName)}
>
<span className={`inspector-section-arrow ${isExpanded ? 'expanded' : ''}`}>
<ChevronRight size={14} />
</span>
<span style={{ marginRight: '6px', color: 'var(--inspector-text-secondary)' }}>
{IconComponent ? <IconComponent size={14} /> : <Box size={14} />}
</span>
<span className="inspector-section-title">{componentName}</span>
<button
className="inspector-section-remove"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(component);
}}
title="移除组件"
style={{
marginLeft: 'auto',
background: 'transparent',
border: 'none',
color: 'var(--inspector-text-secondary)',
cursor: 'pointer',
padding: '2px',
display: 'flex',
alignItems: 'center'
}}
>
<X size={14} />
</button>
</div>
{isExpanded && (
<div className="inspector-section-content expanded">
{componentInspectorRegistry?.hasInspector(component) ? (
componentInspectorRegistry.render({
component,
entity,
version: componentVersion + localVersion,
onChange: (propName: string, value: unknown) =>
handlePropertyChange(component, propName, value),
onAction: handlePropertyAction
})
) : (
<ComponentPropertyEditor
component={component}
entity={entity}
version={componentVersion + localVersion}
onChange={(propName, value) =>
handlePropertyChange(component, propName, value)
}
onAction={handlePropertyAction}
/>
)}
{/* Append inspectors */}
{componentInspectorRegistry?.renderAppendInspectors({
component,
entity,
version: componentVersion + localVersion,
onChange: (propName: string, value: unknown) =>
handlePropertyChange(component, propName, value),
onAction: handlePropertyAction
})}
{/* Component actions */}
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
const ActionIcon = typeof action.icon === 'string'
? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon]
: null;
return (
<button
key={action.id}
className="inspector-header-add-btn"
style={{ width: '100%', marginTop: '8px', justifyContent: 'center' }}
onClick={() => action.execute(component, entity)}
>
{ActionIcon ? <ActionIcon size={14} /> : action.icon}
{action.label}
</button>
);
})}
</div>
)}
</div>
);
})
)}
</div>
{/* Add Component Menu */}
{showAddMenu && (
<>
<div
className="inspector-dropdown-overlay"
onClick={() => setShowAddMenu(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 99
}}
/>
<div
className="inspector-dropdown-menu"
style={{
position: 'fixed',
top: addButtonRef.current?.getBoundingClientRect().bottom ?? 0 + 4,
right: window.innerWidth - (addButtonRef.current?.getBoundingClientRect().right ?? 0),
width: '280px',
maxHeight: '400px',
zIndex: 100
}}
>
{/* Search */}
<div className="inspector-search" style={{ borderBottom: '1px solid var(--inspector-border)' }}>
<Search size={14} className="inspector-search-icon" />
<input
ref={searchInputRef}
type="text"
className="inspector-search-input"
placeholder="搜索组件..."
value={addMenuSearch}
onChange={(e) => setAddMenuSearch(e.target.value)}
onKeyDown={handleAddMenuKeyDown}
/>
</div>
{/* Component List */}
<div style={{ overflowY: 'auto', maxHeight: '350px' }}>
{groupedComponents.size === 0 ? (
<div className="inspector-empty">
{addMenuSearch ? '未找到匹配的组件' : '没有可用组件'}
</div>
) : (
(() => {
let globalIndex = 0;
return Array.from(groupedComponents.entries()).map(([category, components]) => {
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
const label = CATEGORY_LABELS[category] || category;
const startIndex = globalIndex;
if (!isCollapsed) {
globalIndex += components.length;
}
return (
<div key={category}>
<div
className="inspector-dropdown-item"
onClick={() => toggleCategory(category)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontWeight: 500,
background: 'var(--inspector-bg-section)'
}}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span>{label}</span>
<span style={{
marginLeft: 'auto',
fontSize: '10px',
color: 'var(--inspector-text-secondary)'
}}>
{components.length}
</span>
</div>
{!isCollapsed && components.map((info, idx) => {
const IconComp = info.icon && (LucideIcons as any)[info.icon];
const itemIndex = startIndex + idx;
const isSelected = itemIndex === selectedIndex;
return (
<div
key={info.name}
className={`inspector-dropdown-item ${isSelected ? 'selected' : ''}`}
onClick={() => info.type && handleAddComponent(info.type)}
onMouseEnter={() => setSelectedIndex(itemIndex)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
paddingLeft: '24px'
}}
>
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
<span>{info.name}</span>
</div>
);
})}
</div>
);
});
})()
)}
</div>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,339 @@
/**
* InspectorPanel -
* InspectorPanel - Property panel main component
*/
import React, { useCallback, useMemo, useState } from 'react';
import { PropertySection } from './sections';
import {
PropertyRow,
NumberInput,
StringInput,
BooleanInput,
VectorInput,
EnumInput,
ColorInput,
AssetInput,
EntityRefInput,
ArrayInput
} from './controls';
import {
InspectorHeader,
PropertySearch,
CategoryTabs
} from './header';
import {
InspectorPanelProps,
SectionConfig,
PropertyConfig,
PropertyType,
CategoryConfig
} from './types';
import './styles/inspector.css';
/**
*
* Render property control
*/
const renderControl = (
type: PropertyType,
value: any,
onChange: (value: any) => void,
readonly: boolean,
metadata?: Record<string, any>
): React.ReactNode => {
switch (type) {
case 'number':
return (
<NumberInput
value={value ?? 0}
onChange={onChange}
readonly={readonly}
min={metadata?.min}
max={metadata?.max}
step={metadata?.step}
integer={metadata?.integer}
/>
);
case 'string':
return (
<StringInput
value={value ?? ''}
onChange={onChange}
readonly={readonly}
placeholder={metadata?.placeholder}
/>
);
case 'boolean':
return (
<BooleanInput
value={value ?? false}
onChange={onChange}
readonly={readonly}
/>
);
case 'vector2':
return (
<VectorInput
value={value ?? { x: 0, y: 0 }}
onChange={onChange}
readonly={readonly}
dimensions={2}
/>
);
case 'vector3':
return (
<VectorInput
value={value ?? { x: 0, y: 0, z: 0 }}
onChange={onChange}
readonly={readonly}
dimensions={3}
/>
);
case 'vector4':
return (
<VectorInput
value={value ?? { x: 0, y: 0, z: 0, w: 0 }}
onChange={onChange}
readonly={readonly}
dimensions={4}
/>
);
case 'enum':
return (
<EnumInput
value={value}
onChange={onChange}
readonly={readonly}
options={metadata?.options ?? []}
placeholder={metadata?.placeholder}
/>
);
case 'color':
return (
<ColorInput
value={value ?? { r: 0, g: 0, b: 0, a: 1 }}
onChange={onChange}
readonly={readonly}
showAlpha={metadata?.showAlpha}
/>
);
case 'asset':
return (
<AssetInput
value={value}
onChange={onChange}
readonly={readonly}
assetTypes={metadata?.assetTypes}
extensions={metadata?.extensions}
onPickAsset={metadata?.onPickAsset}
onOpenAsset={metadata?.onOpenAsset}
/>
);
case 'entityRef':
return (
<EntityRefInput
value={value}
onChange={onChange}
readonly={readonly}
resolveEntityName={metadata?.resolveEntityName}
onSelectEntity={metadata?.onSelectEntity}
onLocateEntity={metadata?.onLocateEntity}
/>
);
case 'array':
return (
<ArrayInput
value={value ?? []}
onChange={onChange}
readonly={readonly}
renderElement={metadata?.renderElement}
createNewElement={metadata?.createNewElement}
minItems={metadata?.minItems}
maxItems={metadata?.maxItems}
sortable={metadata?.sortable}
collapsedTitle={metadata?.collapsedTitle}
/>
);
// TODO: 后续实现 | To be implemented
case 'object':
return <span style={{ color: '#666', fontSize: '10px' }}>[{type}]</span>;
default:
return <span style={{ color: '#666', fontSize: '10px' }}>[unknown]</span>;
}
};
/**
*
* Default category configuration
*/
const DEFAULT_CATEGORIES: CategoryConfig[] = [
{ id: 'all', label: 'All' }
];
export const InspectorPanel: React.FC<InspectorPanelProps> = ({
targetName,
sections,
categories,
currentCategory: controlledCategory,
onCategoryChange,
getValue,
onChange,
readonly = false,
searchQuery: controlledSearch,
onSearchChange
}) => {
// 内部状态(非受控模式)| Internal state (uncontrolled mode)
const [internalSearch, setInternalSearch] = useState('');
const [internalCategory, setInternalCategory] = useState('all');
// 支持受控/非受控模式 | Support controlled/uncontrolled mode
const searchQuery = controlledSearch ?? internalSearch;
const currentCategory = controlledCategory ?? internalCategory;
const handleSearchChange = useCallback((value: string) => {
if (onSearchChange) {
onSearchChange(value);
} else {
setInternalSearch(value);
}
}, [onSearchChange]);
const handleCategoryChange = useCallback((category: string) => {
if (onCategoryChange) {
onCategoryChange(category);
} else {
setInternalCategory(category);
}
}, [onCategoryChange]);
// 使用提供的分类或默认分类 | Use provided categories or default
const effectiveCategories = useMemo(() => {
if (categories && categories.length > 0) {
return categories;
}
return DEFAULT_CATEGORIES;
}, [categories]);
// 是否显示分类标签 | Whether to show category tabs
const showCategoryTabs = effectiveCategories.length > 1;
/**
* +
* Filter properties (search + category)
*/
const filterProperty = useCallback((prop: PropertyConfig): boolean => {
// 分类过滤 | Category filter
if (currentCategory !== 'all' && prop.category && prop.category !== currentCategory) {
return false;
}
// 搜索过滤 | Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
prop.name.toLowerCase().includes(query) ||
prop.label.toLowerCase().includes(query)
);
}
return true;
}, [searchQuery, currentCategory]);
/**
* sections
* Filtered sections
*/
const filteredSections = useMemo(() => {
return sections
.map(section => ({
...section,
properties: section.properties.filter(filterProperty)
}))
.filter(section => section.properties.length > 0);
}, [sections, filterProperty]);
/**
* Section
* Render section
*/
const renderSection = useCallback((section: SectionConfig, depth: number = 0) => {
return (
<PropertySection
key={section.id}
title={section.title}
defaultExpanded={section.defaultExpanded ?? true}
depth={depth}
>
{/* 属性列表 | Property list */}
{section.properties.map(prop => (
<PropertyRow
key={prop.name}
label={prop.label}
depth={depth}
draggable={prop.type === 'number'}
>
{renderControl(
prop.type,
getValue(prop.name),
(value) => onChange(prop.name, value),
readonly,
prop.metadata
)}
</PropertyRow>
))}
{/* 子 Section | Sub sections */}
{section.subsections?.map(sub => renderSection(sub, depth + 1))}
</PropertySection>
);
}, [getValue, onChange, readonly]);
return (
<div className="inspector-panel">
{/* 头部 | Header */}
{targetName && (
<InspectorHeader name={targetName} />
)}
{/* 搜索栏 | Search bar */}
<PropertySearch
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search properties..."
/>
{/* 分类标签 | Category tabs */}
{showCategoryTabs && (
<CategoryTabs
categories={effectiveCategories}
current={currentCategory}
onChange={handleCategoryChange}
/>
)}
{/* 属性内容 | Property content */}
<div className="inspector-panel-content">
{filteredSections.length > 0 ? (
filteredSections.map(section => renderSection(section))
) : (
<div className="inspector-empty">
{searchQuery ? 'No matching properties' : 'No properties'}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,228 @@
/**
* ArrayInput -
* ArrayInput - Array editor control
*/
import React, { useCallback, useState } from 'react';
import { Plus, Trash2, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
import { PropertyControlProps } from '../types';
export interface ArrayInputProps<T = any> extends PropertyControlProps<T[]> {
/** 元素渲染器 | Element renderer */
renderElement?: (
element: T,
index: number,
onChange: (value: T) => void,
onRemove: () => void
) => React.ReactNode;
/** 创建新元素 | Create new element */
createNewElement?: () => T;
/** 最小元素数 | Minimum element count */
minItems?: number;
/** 最大元素数 | Maximum element count */
maxItems?: number;
/** 是否可排序 | Sortable */
sortable?: boolean;
/** 折叠标题 | Collapsed title */
collapsedTitle?: (items: T[]) => string;
}
export function ArrayInput<T = any>({
value = [],
onChange,
readonly = false,
renderElement,
createNewElement,
minItems = 0,
maxItems,
sortable = false,
collapsedTitle
}: ArrayInputProps<T>): React.ReactElement {
const [expanded, setExpanded] = useState(true);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const items = value ?? [];
const canAdd = !maxItems || items.length < maxItems;
const canRemove = items.length > minItems;
// 展开/折叠 | Expand/Collapse
const toggleExpanded = useCallback(() => {
setExpanded(prev => !prev);
}, []);
// 添加元素 | Add element
const handleAdd = useCallback(() => {
if (!canAdd || readonly) return;
const newElement = createNewElement ? createNewElement() : (null as T);
onChange([...items, newElement]);
}, [items, onChange, canAdd, readonly, createNewElement]);
// 移除元素 | Remove element
const handleRemove = useCallback((index: number) => {
if (!canRemove || readonly) return;
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
}, [items, onChange, canRemove, readonly]);
// 更新元素 | Update element
const handleElementChange = useCallback((index: number, newValue: T) => {
if (readonly) return;
const newItems = [...items];
newItems[index] = newValue;
onChange(newItems);
}, [items, onChange, readonly]);
// ========== 拖拽排序 | Drag Sort ==========
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
if (!sortable || readonly) return;
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}, [sortable, readonly]);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
if (!sortable || readonly || dragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, [sortable, readonly, dragIndex]);
const handleDragLeave = useCallback(() => {
setDragOverIndex(null);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (!sortable || readonly || dragIndex === null || dragIndex === targetIndex) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const newItems = [...items];
const [removed] = newItems.splice(dragIndex, 1);
if (removed !== undefined) {
newItems.splice(targetIndex, 0, removed);
}
onChange(newItems);
setDragIndex(null);
setDragOverIndex(null);
}, [items, onChange, sortable, readonly, dragIndex]);
const handleDragEnd = useCallback(() => {
setDragIndex(null);
setDragOverIndex(null);
}, []);
// 获取折叠标题 | Get collapsed title
const getTitle = (): string => {
if (collapsedTitle) {
return collapsedTitle(items);
}
return `${items.length} item${items.length !== 1 ? 's' : ''}`;
};
// 默认元素渲染 | Default element renderer
const defaultRenderElement = (element: T, index: number) => (
<div className="inspector-array-element-default">
{String(element)}
</div>
);
return (
<div className="inspector-array-input">
{/* 头部 | Header */}
<div className="inspector-array-header" onClick={toggleExpanded}>
<span className="inspector-array-arrow">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="inspector-array-title">{getTitle()}</span>
{/* 添加按钮 | Add button */}
{canAdd && !readonly && (
<button
type="button"
className="inspector-array-add"
onClick={(e) => {
e.stopPropagation();
handleAdd();
}}
title="Add element"
>
<Plus size={12} />
</button>
)}
</div>
{/* 元素列表 | Element list */}
{expanded && (
<div className="inspector-array-elements">
{items.map((element, index) => (
<div
key={index}
className={`inspector-array-element ${dragOverIndex === index ? 'drag-over' : ''} ${dragIndex === index ? 'dragging' : ''}`}
draggable={sortable && !readonly}
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
{/* 拖拽手柄 | Drag handle */}
{sortable && !readonly && (
<div className="inspector-array-handle">
<GripVertical size={12} />
</div>
)}
{/* 索引 | Index */}
<span className="inspector-array-index">{index}</span>
{/* 内容 | Content */}
<div className="inspector-array-content">
{renderElement
? renderElement(
element,
index,
(val) => handleElementChange(index, val),
() => handleRemove(index)
)
: defaultRenderElement(element, index)
}
</div>
{/* 删除按钮 | Remove button */}
{canRemove && !readonly && (
<button
type="button"
className="inspector-array-remove"
onClick={() => handleRemove(index)}
title="Remove"
>
<Trash2 size={12} />
</button>
)}
</div>
))}
{/* 空状态 | Empty state */}
{items.length === 0 && (
<div className="inspector-array-empty">
No items
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,380 @@
/**
* AssetInput -
* AssetInput - Asset reference picker control
*
* | Features:
* - | Thumbnail preview
* - | Dropdown selection
* - | Drag and drop support
* - | Action buttons (browse, copy, locate, clear)
*/
import React, { useCallback, useState, useRef, useEffect } from 'react';
import { ChevronDown, FolderOpen, Copy, Navigation, X, FileImage, Image, Music, Film, FileText, Box } from 'lucide-react';
import { PropertyControlProps } from '../types';
export interface AssetReference {
/** 资产 ID | Asset ID */
id: string;
/** 资产路径 | Asset path */
path?: string;
/** 资产类型 | Asset type */
type?: string;
/** 缩略图 URL | Thumbnail URL */
thumbnail?: string;
}
export interface AssetInputProps extends PropertyControlProps<AssetReference | string | null> {
/** 允许的资产类型 | Allowed asset types */
assetTypes?: string[];
/** 允许的文件扩展名 | Allowed file extensions */
extensions?: string[];
/** 打开资产选择器回调 | Open asset picker callback */
onPickAsset?: () => void;
/** 打开资产回调 | Open asset callback */
onOpenAsset?: (asset: AssetReference) => void;
/** 定位资产回调 | Locate asset callback */
onLocateAsset?: (asset: AssetReference) => void;
/** 复制路径回调 | Copy path callback */
onCopyPath?: (path: string) => void;
/** 获取缩略图 URL | Get thumbnail URL */
getThumbnail?: (asset: AssetReference) => string | undefined;
/** 最近使用的资产 | Recently used assets */
recentAssets?: AssetReference[];
/** 显示缩略图 | Show thumbnail */
showThumbnail?: boolean;
}
/**
*
* Get asset display name
*/
const getAssetDisplayName = (value: AssetReference | string | null): string => {
if (!value) return '';
if (typeof value === 'string') {
// 从路径中提取文件名 | Extract filename from path
const parts = value.split('/');
return parts[parts.length - 1] ?? value;
}
if (value.path) {
const parts = value.path.split('/');
return parts[parts.length - 1] ?? value.id;
}
return value.id;
};
/**
*
* Get asset path
*/
const getAssetPath = (value: AssetReference | string | null): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return value.path || value.id;
};
/**
*
* Get icon by extension
*/
const getAssetIcon = (value: AssetReference | string | null) => {
const path = getAssetPath(value).toLowerCase();
if (path.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/)) return Image;
if (path.match(/\.(mp3|wav|ogg|flac|aac)$/)) return Music;
if (path.match(/\.(mp4|webm|avi|mov|mkv)$/)) return Film;
if (path.match(/\.(txt|json|xml|yaml|yml|md)$/)) return FileText;
if (path.match(/\.(fbx|obj|gltf|glb|dae)$/)) return Box;
return FileImage;
};
export const AssetInput: React.FC<AssetInputProps> = ({
value,
onChange,
readonly = false,
extensions,
onPickAsset,
onOpenAsset,
onLocateAsset,
onCopyPath,
getThumbnail,
recentAssets = [],
showThumbnail = true
}) => {
const [isDragOver, setIsDragOver] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const displayName = getAssetDisplayName(value);
const assetPath = getAssetPath(value);
const hasValue = !!value;
const IconComponent = getAssetIcon(value);
// 获取缩略图 | Get thumbnail
const thumbnailUrl = value && getThumbnail
? getThumbnail(typeof value === 'string' ? { id: value, path: value } : value)
: undefined;
// 关闭下拉菜单 | Close dropdown
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showDropdown]);
// 清除值 | Clear value
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (!readonly) {
onChange(null);
}
}, [onChange, readonly]);
// 打开选择器 | Open picker
const handleBrowse = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (!readonly && onPickAsset) {
onPickAsset();
}
setShowDropdown(false);
}, [readonly, onPickAsset]);
// 定位资产 | Locate asset
const handleLocate = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (value && onLocateAsset) {
const asset: AssetReference = typeof value === 'string'
? { id: value, path: value }
: value;
onLocateAsset(asset);
}
}, [value, onLocateAsset]);
// 复制路径 | Copy path
const handleCopy = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (assetPath) {
if (onCopyPath) {
onCopyPath(assetPath);
} else {
navigator.clipboard.writeText(assetPath);
}
}
}, [assetPath, onCopyPath]);
// 双击打开资产 | Double click to open asset
const handleDoubleClick = useCallback(() => {
if (value && onOpenAsset) {
const asset: AssetReference = typeof value === 'string'
? { id: value, path: value }
: value;
onOpenAsset(asset);
}
}, [value, onOpenAsset]);
// 切换下拉菜单 | Toggle dropdown
const handleToggleDropdown = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (!readonly) {
setShowDropdown(!showDropdown);
}
}, [readonly, showDropdown]);
// 选择资产 | Select asset
const handleSelectAsset = useCallback((asset: AssetReference) => {
onChange(asset);
setShowDropdown(false);
}, [onChange]);
// 拖放处理 | Drag and drop handling
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly) {
setIsDragOver(true);
}
}, [readonly]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (readonly) return;
const assetId = e.dataTransfer.getData('asset-id');
const assetPath = e.dataTransfer.getData('asset-path');
const assetType = e.dataTransfer.getData('asset-type');
if (assetId || assetPath) {
// 检查扩展名匹配 | Check extension match
if (extensions && assetPath) {
const ext = assetPath.split('.').pop()?.toLowerCase();
if (ext && !extensions.some(e => e.toLowerCase() === ext || e.toLowerCase() === `.${ext}`)) {
console.warn(`Extension "${ext}" not allowed. Allowed: ${extensions.join(', ')}`);
return;
}
}
onChange({
id: assetId || assetPath,
path: assetPath || undefined,
type: assetType || undefined
});
}
}, [onChange, readonly, extensions]);
return (
<div
ref={containerRef}
className={`inspector-asset-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 缩略图 | Thumbnail */}
{showThumbnail && (
<div className="inspector-asset-thumbnail" onDoubleClick={handleDoubleClick}>
{thumbnailUrl ? (
<img src={thumbnailUrl} alt="" />
) : (
<IconComponent size={16} />
)}
</div>
)}
{/* 值显示和下拉按钮 | Value display and dropdown button */}
<div className="inspector-asset-main" onClick={handleToggleDropdown}>
<div
className="inspector-asset-value"
onDoubleClick={handleDoubleClick}
title={assetPath || 'None'}
>
{displayName || <span className="inspector-asset-placeholder">None</span>}
</div>
{!readonly && (
<ChevronDown size={12} className={`inspector-asset-arrow ${showDropdown ? 'open' : ''}`} />
)}
</div>
{/* 操作按钮 | Action buttons */}
<div className="inspector-asset-actions">
{/* 定位按钮 | Locate button */}
{hasValue && onLocateAsset && (
<button
type="button"
className="inspector-asset-btn"
onClick={handleLocate}
title="Locate in Content Browser"
>
<Navigation size={11} />
</button>
)}
{/* 复制按钮 | Copy button */}
{hasValue && (
<button
type="button"
className="inspector-asset-btn"
onClick={handleCopy}
title="Copy Path"
>
<Copy size={11} />
</button>
)}
{/* 浏览按钮 | Browse button */}
{onPickAsset && !readonly && (
<button
type="button"
className="inspector-asset-btn"
onClick={handleBrowse}
title="Browse"
>
<FolderOpen size={11} />
</button>
)}
{/* 清除按钮 | Clear button */}
{hasValue && !readonly && (
<button
type="button"
className="inspector-asset-btn inspector-asset-clear"
onClick={handleClear}
title="Clear"
>
<X size={11} />
</button>
)}
</div>
{/* 下拉菜单 | Dropdown menu */}
{showDropdown && (
<div ref={dropdownRef} className="inspector-asset-dropdown">
{/* 浏览选项 | Browse option */}
{onPickAsset && (
<div className="inspector-asset-dropdown-item" onClick={handleBrowse}>
<FolderOpen size={14} />
<span>Browse...</span>
</div>
)}
{/* 清除选项 | Clear option */}
{hasValue && (
<div className="inspector-asset-dropdown-item" onClick={handleClear}>
<X size={14} />
<span>Clear</span>
</div>
)}
{/* 分割线 | Divider */}
{recentAssets.length > 0 && (
<>
<div className="inspector-asset-dropdown-divider" />
<div className="inspector-asset-dropdown-label">Recent</div>
</>
)}
{/* 最近使用 | Recent assets */}
{recentAssets.map((asset, index) => (
<div
key={asset.id || index}
className="inspector-asset-dropdown-item"
onClick={() => handleSelectAsset(asset)}
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="inspector-asset-dropdown-thumb" />
) : (
<FileImage size={14} />
)}
<span>{getAssetDisplayName(asset)}</span>
</div>
))}
{/* 空状态 | Empty state */}
{!onPickAsset && !hasValue && recentAssets.length === 0 && (
<div className="inspector-asset-dropdown-empty">No assets available</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,43 @@
/**
* BooleanInput -
* BooleanInput - Checkbox control
*/
import React, { useCallback } from 'react';
import { Check } from 'lucide-react';
import { PropertyControlProps } from '../types';
export interface BooleanInputProps extends PropertyControlProps<boolean> {}
export const BooleanInput: React.FC<BooleanInputProps> = ({
value,
onChange,
readonly = false
}) => {
const handleClick = useCallback(() => {
if (!readonly) {
onChange(!value);
}
}, [value, onChange, readonly]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!readonly && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onChange(!value);
}
}, [value, onChange, readonly]);
return (
<div
className={`inspector-checkbox ${value ? 'checked' : ''}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={readonly ? -1 : 0}
role="checkbox"
aria-checked={value}
aria-disabled={readonly}
>
<Check size={12} className="inspector-checkbox-icon" />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More