编辑器核心框架
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -2610,6 +2610,10 @@
|
|||||||
"resolved": "packages/math",
|
"resolved": "packages/math",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@esengine/editor-core": {
|
||||||
|
"resolved": "packages/editor-core",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@esengine/network-client": {
|
"node_modules/@esengine/network-client": {
|
||||||
"resolved": "packages/network-client",
|
"resolved": "packages/network-client",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -15556,6 +15560,23 @@
|
|||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/editor-core": {
|
||||||
|
"name": "@esengine/editor-core",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/ecs-framework": "file:../core",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^20.19.17",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/math": {
|
"packages/math": {
|
||||||
"name": "@esengine/ecs-framework-math",
|
"name": "@esengine/ecs-framework-math",
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
|
|||||||
41
packages/editor-core/jest.config.cjs
Normal file
41
packages/editor-core/jest.config.cjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/tests'],
|
||||||
|
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '\\.performance\\.test\\.ts$', '/tests/performance/'],
|
||||||
|
collectCoverage: false,
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/index.ts',
|
||||||
|
'!src/**/index.ts',
|
||||||
|
'!**/*.d.ts',
|
||||||
|
'!src/**/*.test.ts',
|
||||||
|
'!src/**/*.spec.ts'
|
||||||
|
],
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
verbose: true,
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
|
tsconfig: 'tsconfig.json',
|
||||||
|
useESM: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^@esengine/ecs-framework$': '<rootDir>/../core/src/index.ts',
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||||
|
testTimeout: 0,
|
||||||
|
// 清除模块缓存
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
// 忽略某些模块
|
||||||
|
modulePathIgnorePatterns: [
|
||||||
|
'<rootDir>/bin/',
|
||||||
|
'<rootDir>/dist/',
|
||||||
|
'<rootDir>/node_modules/'
|
||||||
|
]
|
||||||
|
};
|
||||||
58
packages/editor-core/package.json
Normal file
58
packages/editor-core/package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/editor-core",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "ECS Framework Editor Core - Plugin-based editor framework",
|
||||||
|
"main": "bin/index.js",
|
||||||
|
"types": "bin/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./bin/index.d.ts",
|
||||||
|
"import": "./bin/index.js",
|
||||||
|
"development": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/**/*"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"ecs",
|
||||||
|
"editor",
|
||||||
|
"plugin",
|
||||||
|
"tauri",
|
||||||
|
"game-engine",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
|
||||||
|
"build:ts": "tsc",
|
||||||
|
"prebuild": "npm run clean",
|
||||||
|
"build": "npm run build:ts",
|
||||||
|
"build:watch": "tsc --watch",
|
||||||
|
"rebuild": "npm run clean && npm run build",
|
||||||
|
"test": "jest --config jest.config.cjs",
|
||||||
|
"test:watch": "jest --watch --config jest.config.cjs",
|
||||||
|
"test:coverage": "jest --coverage --config jest.config.cjs"
|
||||||
|
},
|
||||||
|
"author": "yhh",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^20.19.17",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/ecs-framework": "file:../core",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/esengine/ecs-framework.git",
|
||||||
|
"directory": "packages/editor-core"
|
||||||
|
}
|
||||||
|
}
|
||||||
315
packages/editor-core/src/Plugins/EditorPluginManager.ts
Normal file
315
packages/editor-core/src/Plugins/EditorPluginManager.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { PluginManager } from '@esengine/ecs-framework';
|
||||||
|
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||||
|
import { Injectable } from '@esengine/ecs-framework';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import type { IEditorPlugin, IEditorPluginMetadata } from './IEditorPlugin';
|
||||||
|
import { EditorPluginCategory } from './IEditorPlugin';
|
||||||
|
import { UIRegistry } from '../Services/UIRegistry';
|
||||||
|
import { MessageHub } from '../Services/MessageHub';
|
||||||
|
import { SerializerRegistry } from '../Services/SerializerRegistry';
|
||||||
|
|
||||||
|
const logger = createLogger('EditorPluginManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器插件管理器
|
||||||
|
*
|
||||||
|
* 扩展运行时插件管理器,提供编辑器特定的插件管理功能。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EditorPluginManager extends PluginManager {
|
||||||
|
private editorPlugins: Map<string, IEditorPlugin> = new Map();
|
||||||
|
private pluginMetadata: Map<string, IEditorPluginMetadata> = new Map();
|
||||||
|
private uiRegistry: UIRegistry | null = null;
|
||||||
|
private messageHub: MessageHub | null = null;
|
||||||
|
private serializerRegistry: SerializerRegistry | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化编辑器插件管理器
|
||||||
|
*/
|
||||||
|
public override initialize(core: Core, services: ServiceContainer): void {
|
||||||
|
super.initialize(core, services);
|
||||||
|
|
||||||
|
this.uiRegistry = services.resolve(UIRegistry);
|
||||||
|
this.messageHub = services.resolve(MessageHub);
|
||||||
|
this.serializerRegistry = services.resolve(SerializerRegistry);
|
||||||
|
|
||||||
|
logger.info('EditorPluginManager initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装编辑器插件
|
||||||
|
*/
|
||||||
|
public async installEditor(plugin: IEditorPlugin): Promise<void> {
|
||||||
|
if (!this.uiRegistry || !this.messageHub || !this.serializerRegistry) {
|
||||||
|
throw new Error('EditorPluginManager not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Installing editor plugin: ${plugin.name} (${plugin.displayName})`);
|
||||||
|
|
||||||
|
await super.install(plugin);
|
||||||
|
|
||||||
|
this.editorPlugins.set(plugin.name, plugin);
|
||||||
|
|
||||||
|
const metadata: IEditorPluginMetadata = {
|
||||||
|
name: plugin.name,
|
||||||
|
displayName: plugin.displayName,
|
||||||
|
version: plugin.version,
|
||||||
|
category: plugin.category,
|
||||||
|
description: plugin.description,
|
||||||
|
icon: plugin.icon,
|
||||||
|
enabled: true,
|
||||||
|
installedAt: Date.now()
|
||||||
|
};
|
||||||
|
this.pluginMetadata.set(plugin.name, metadata);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (plugin.registerMenuItems) {
|
||||||
|
const menuItems = plugin.registerMenuItems();
|
||||||
|
this.uiRegistry.registerMenus(menuItems);
|
||||||
|
logger.debug(`Registered ${menuItems.length} menu items for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.registerToolbar) {
|
||||||
|
const toolbarItems = plugin.registerToolbar();
|
||||||
|
this.uiRegistry.registerToolbarItems(toolbarItems);
|
||||||
|
logger.debug(`Registered ${toolbarItems.length} toolbar items for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.registerPanels) {
|
||||||
|
const panels = plugin.registerPanels();
|
||||||
|
this.uiRegistry.registerPanels(panels);
|
||||||
|
logger.debug(`Registered ${panels.length} panels for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.getSerializers) {
|
||||||
|
const serializers = plugin.getSerializers();
|
||||||
|
this.serializerRegistry.registerMultiple(plugin.name, serializers);
|
||||||
|
logger.debug(`Registered ${serializers.length} serializers for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.onEditorReady) {
|
||||||
|
await plugin.onEditorReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.messageHub.publish('plugin:installed', {
|
||||||
|
name: plugin.name,
|
||||||
|
displayName: plugin.displayName,
|
||||||
|
category: plugin.category
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Editor plugin ${plugin.name} installed successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to install editor plugin ${plugin.name}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载编辑器插件
|
||||||
|
*/
|
||||||
|
public async uninstallEditor(name: string): Promise<void> {
|
||||||
|
const plugin = this.editorPlugins.get(name);
|
||||||
|
if (!plugin) {
|
||||||
|
throw new Error(`Editor plugin ${name} is not installed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Uninstalling editor plugin: ${name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (plugin.registerMenuItems) {
|
||||||
|
const menuItems = plugin.registerMenuItems();
|
||||||
|
for (const item of menuItems) {
|
||||||
|
this.uiRegistry?.unregisterMenu(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.registerToolbar) {
|
||||||
|
const toolbarItems = plugin.registerToolbar();
|
||||||
|
for (const item of toolbarItems) {
|
||||||
|
this.uiRegistry?.unregisterToolbarItem(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.registerPanels) {
|
||||||
|
const panels = plugin.registerPanels();
|
||||||
|
for (const panel of panels) {
|
||||||
|
this.uiRegistry?.unregisterPanel(panel.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serializerRegistry?.unregisterAll(name);
|
||||||
|
|
||||||
|
await super.uninstall(name);
|
||||||
|
|
||||||
|
this.editorPlugins.delete(name);
|
||||||
|
this.pluginMetadata.delete(name);
|
||||||
|
|
||||||
|
await this.messageHub?.publish('plugin:uninstalled', { name });
|
||||||
|
|
||||||
|
logger.info(`Editor plugin ${name} uninstalled successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to uninstall editor plugin ${name}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取编辑器插件
|
||||||
|
*/
|
||||||
|
public getEditorPlugin(name: string): IEditorPlugin | undefined {
|
||||||
|
return this.editorPlugins.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有编辑器插件
|
||||||
|
*/
|
||||||
|
public getAllEditorPlugins(): IEditorPlugin[] {
|
||||||
|
return Array.from(this.editorPlugins.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件元数据
|
||||||
|
*/
|
||||||
|
public getPluginMetadata(name: string): IEditorPluginMetadata | undefined {
|
||||||
|
return this.pluginMetadata.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有插件元数据
|
||||||
|
*/
|
||||||
|
public getAllPluginMetadata(): IEditorPluginMetadata[] {
|
||||||
|
return Array.from(this.pluginMetadata.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按类别获取插件
|
||||||
|
*/
|
||||||
|
public getPluginsByCategory(category: EditorPluginCategory): IEditorPlugin[] {
|
||||||
|
return this.getAllEditorPlugins().filter(plugin => plugin.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用插件
|
||||||
|
*/
|
||||||
|
public async enablePlugin(name: string): Promise<void> {
|
||||||
|
const metadata = this.pluginMetadata.get(name);
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(`Plugin ${name} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.enabled) {
|
||||||
|
logger.warn(`Plugin ${name} is already enabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.enabled = true;
|
||||||
|
await this.messageHub?.publish('plugin:enabled', { name });
|
||||||
|
logger.info(`Plugin ${name} enabled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用插件
|
||||||
|
*/
|
||||||
|
public async disablePlugin(name: string): Promise<void> {
|
||||||
|
const metadata = this.pluginMetadata.get(name);
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(`Plugin ${name} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.enabled) {
|
||||||
|
logger.warn(`Plugin ${name} is already disabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.enabled = false;
|
||||||
|
await this.messageHub?.publish('plugin:disabled', { name });
|
||||||
|
logger.info(`Plugin ${name} disabled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目打开通知
|
||||||
|
*/
|
||||||
|
public async notifyProjectOpen(projectPath: string): Promise<void> {
|
||||||
|
logger.info(`Notifying plugins of project open: ${projectPath}`);
|
||||||
|
|
||||||
|
for (const plugin of this.editorPlugins.values()) {
|
||||||
|
if (plugin.onProjectOpen) {
|
||||||
|
try {
|
||||||
|
await plugin.onProjectOpen(projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in ${plugin.name}.onProjectOpen:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.messageHub?.publish('project:opened', { path: projectPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目关闭通知
|
||||||
|
*/
|
||||||
|
public async notifyProjectClose(): Promise<void> {
|
||||||
|
logger.info('Notifying plugins of project close');
|
||||||
|
|
||||||
|
for (const plugin of this.editorPlugins.values()) {
|
||||||
|
if (plugin.onProjectClose) {
|
||||||
|
try {
|
||||||
|
await plugin.onProjectClose();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in ${plugin.name}.onProjectClose:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.messageHub?.publish('project:closed', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件保存前通知
|
||||||
|
*/
|
||||||
|
public async notifyBeforeSave(filePath: string, data: any): Promise<void> {
|
||||||
|
for (const plugin of this.editorPlugins.values()) {
|
||||||
|
if (plugin.onBeforeSave) {
|
||||||
|
try {
|
||||||
|
await plugin.onBeforeSave(filePath, data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in ${plugin.name}.onBeforeSave:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.messageHub?.publish('file:beforeSave', { path: filePath, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件保存后通知
|
||||||
|
*/
|
||||||
|
public async notifyAfterSave(filePath: string): Promise<void> {
|
||||||
|
for (const plugin of this.editorPlugins.values()) {
|
||||||
|
if (plugin.onAfterSave) {
|
||||||
|
try {
|
||||||
|
await plugin.onAfterSave(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in ${plugin.name}.onAfterSave:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.messageHub?.publish('file:afterSave', { path: filePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
public override dispose(): void {
|
||||||
|
super.dispose();
|
||||||
|
|
||||||
|
this.editorPlugins.clear();
|
||||||
|
this.pluginMetadata.clear();
|
||||||
|
this.uiRegistry = null;
|
||||||
|
this.messageHub = null;
|
||||||
|
this.serializerRegistry = null;
|
||||||
|
|
||||||
|
logger.info('EditorPluginManager disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
169
packages/editor-core/src/Plugins/IEditorPlugin.ts
Normal file
169
packages/editor-core/src/Plugins/IEditorPlugin.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { IPlugin } from '@esengine/ecs-framework';
|
||||||
|
import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器插件类别
|
||||||
|
*/
|
||||||
|
export enum EditorPluginCategory {
|
||||||
|
/**
|
||||||
|
* 工具插件
|
||||||
|
*/
|
||||||
|
Tool = 'tool',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口插件
|
||||||
|
*/
|
||||||
|
Window = 'window',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检视器插件
|
||||||
|
*/
|
||||||
|
Inspector = 'inspector',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统插件
|
||||||
|
*/
|
||||||
|
System = 'system',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入导出插件
|
||||||
|
*/
|
||||||
|
ImportExport = 'import-export'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化器接口
|
||||||
|
*/
|
||||||
|
export interface ISerializer<T = any> {
|
||||||
|
/**
|
||||||
|
* 序列化为二进制数据
|
||||||
|
*/
|
||||||
|
serialize(data: T): Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从二进制数据反序列化
|
||||||
|
*/
|
||||||
|
deserialize(data: Uint8Array): T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取序列化器支持的数据类型
|
||||||
|
*/
|
||||||
|
getSupportedType(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器插件接口
|
||||||
|
*
|
||||||
|
* 扩展了运行时插件接口,添加了编辑器特定的功能。
|
||||||
|
*/
|
||||||
|
export interface IEditorPlugin extends IPlugin {
|
||||||
|
/**
|
||||||
|
* 插件显示名称
|
||||||
|
*/
|
||||||
|
readonly displayName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件类别
|
||||||
|
*/
|
||||||
|
readonly category: EditorPluginCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件描述
|
||||||
|
*/
|
||||||
|
readonly description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件图标
|
||||||
|
*/
|
||||||
|
readonly icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册菜单项
|
||||||
|
*/
|
||||||
|
registerMenuItems?(): MenuItem[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工具栏项
|
||||||
|
*/
|
||||||
|
registerToolbar?(): ToolbarItem[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册面板
|
||||||
|
*/
|
||||||
|
registerPanels?(): PanelDescriptor[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供序列化器
|
||||||
|
*/
|
||||||
|
getSerializers?(): ISerializer[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器就绪回调
|
||||||
|
*/
|
||||||
|
onEditorReady?(): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目打开回调
|
||||||
|
*/
|
||||||
|
onProjectOpen?(projectPath: string): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目关闭回调
|
||||||
|
*/
|
||||||
|
onProjectClose?(): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件保存前回调
|
||||||
|
*/
|
||||||
|
onBeforeSave?(filePath: string, data: any): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件保存后回调
|
||||||
|
*/
|
||||||
|
onAfterSave?(filePath: string): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器插件元数据
|
||||||
|
*/
|
||||||
|
export interface IEditorPluginMetadata {
|
||||||
|
/**
|
||||||
|
* 插件名称
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示名称
|
||||||
|
*/
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本
|
||||||
|
*/
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类别
|
||||||
|
*/
|
||||||
|
category: EditorPluginCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 描述
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已启用
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装时间戳
|
||||||
|
*/
|
||||||
|
installedAt?: number;
|
||||||
|
}
|
||||||
217
packages/editor-core/src/Services/MessageHub.ts
Normal file
217
packages/editor-core/src/Services/MessageHub.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import type { IService } from '@esengine/ecs-framework';
|
||||||
|
import { Injectable } from '@esengine/ecs-framework';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
const logger = createLogger('MessageHub');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息处理器类型
|
||||||
|
*/
|
||||||
|
export type MessageHandler<T = any> = (data: T) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息订阅
|
||||||
|
*/
|
||||||
|
interface MessageSubscription {
|
||||||
|
topic: string;
|
||||||
|
handler: MessageHandler;
|
||||||
|
once: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息总线
|
||||||
|
*
|
||||||
|
* 提供插件间的事件通信机制,支持订阅/发布模式。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MessageHub implements IService {
|
||||||
|
private subscriptions: Map<string, MessageSubscription[]> = new Map();
|
||||||
|
private subscriptionId: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅消息
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @param handler - 消息处理器
|
||||||
|
* @returns 取消订阅的函数
|
||||||
|
*/
|
||||||
|
public subscribe<T = any>(topic: string, handler: MessageHandler<T>): () => void {
|
||||||
|
return this.addSubscription(topic, handler, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅一次性消息
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @param handler - 消息处理器
|
||||||
|
* @returns 取消订阅的函数
|
||||||
|
*/
|
||||||
|
public subscribeOnce<T = any>(topic: string, handler: MessageHandler<T>): () => void {
|
||||||
|
return this.addSubscription(topic, handler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加订阅
|
||||||
|
*/
|
||||||
|
private addSubscription(topic: string, handler: MessageHandler, once: boolean): () => void {
|
||||||
|
const subscription: MessageSubscription = {
|
||||||
|
topic,
|
||||||
|
handler,
|
||||||
|
once
|
||||||
|
};
|
||||||
|
|
||||||
|
let subs = this.subscriptions.get(topic);
|
||||||
|
if (!subs) {
|
||||||
|
subs = [];
|
||||||
|
this.subscriptions.set(topic, subs);
|
||||||
|
}
|
||||||
|
subs.push(subscription);
|
||||||
|
|
||||||
|
const subId = ++this.subscriptionId;
|
||||||
|
logger.debug(`Subscribed to topic: ${topic} (id: ${subId}, once: ${once})`);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.unsubscribe(topic, subscription);
|
||||||
|
logger.debug(`Unsubscribed from topic: ${topic} (id: ${subId})`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅
|
||||||
|
*/
|
||||||
|
private unsubscribe(topic: string, subscription: MessageSubscription): void {
|
||||||
|
const subs = this.subscriptions.get(topic);
|
||||||
|
if (!subs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = subs.indexOf(subscription);
|
||||||
|
if (index !== -1) {
|
||||||
|
subs.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length === 0) {
|
||||||
|
this.subscriptions.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布消息
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @param data - 消息数据
|
||||||
|
*/
|
||||||
|
public async publish<T = any>(topic: string, data?: T): Promise<void> {
|
||||||
|
const subs = this.subscriptions.get(topic);
|
||||||
|
if (!subs || subs.length === 0) {
|
||||||
|
logger.debug(`No subscribers for topic: ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Publishing message to topic: ${topic} (${subs.length} subscribers)`);
|
||||||
|
|
||||||
|
const onceSubscriptions: MessageSubscription[] = [];
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await sub.handler(data);
|
||||||
|
if (sub.once) {
|
||||||
|
onceSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in message handler for topic ${topic}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of onceSubscriptions) {
|
||||||
|
this.unsubscribe(topic, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步发布消息
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @param data - 消息数据
|
||||||
|
*/
|
||||||
|
public publishSync<T = any>(topic: string, data?: T): void {
|
||||||
|
const subs = this.subscriptions.get(topic);
|
||||||
|
if (!subs || subs.length === 0) {
|
||||||
|
logger.debug(`No subscribers for topic: ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Publishing sync message to topic: ${topic} (${subs.length} subscribers)`);
|
||||||
|
|
||||||
|
const onceSubscriptions: MessageSubscription[] = [];
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
const result = sub.handler(data);
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
logger.warn(`Async handler used with publishSync for topic: ${topic}`);
|
||||||
|
}
|
||||||
|
if (sub.once) {
|
||||||
|
onceSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in message handler for topic ${topic}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of onceSubscriptions) {
|
||||||
|
this.unsubscribe(topic, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消所有指定主题的订阅
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
*/
|
||||||
|
public unsubscribeAll(topic: string): void {
|
||||||
|
const deleted = this.subscriptions.delete(topic);
|
||||||
|
if (deleted) {
|
||||||
|
logger.debug(`Unsubscribed all from topic: ${topic}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查主题是否有订阅者
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @returns 是否有订阅者
|
||||||
|
*/
|
||||||
|
public hasSubscribers(topic: string): boolean {
|
||||||
|
const subs = this.subscriptions.get(topic);
|
||||||
|
return subs !== undefined && subs.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有主题
|
||||||
|
*
|
||||||
|
* @returns 主题列表
|
||||||
|
*/
|
||||||
|
public getTopics(): string[] {
|
||||||
|
return Array.from(this.subscriptions.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主题的订阅者数量
|
||||||
|
*
|
||||||
|
* @param topic - 消息主题
|
||||||
|
* @returns 订阅者数量
|
||||||
|
*/
|
||||||
|
public getSubscriberCount(topic: string): number {
|
||||||
|
const subs = this.subscriptions.get(topic);
|
||||||
|
return subs ? subs.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
public dispose(): void {
|
||||||
|
this.subscriptions.clear();
|
||||||
|
logger.info('MessageHub disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
181
packages/editor-core/src/Services/SerializerRegistry.ts
Normal file
181
packages/editor-core/src/Services/SerializerRegistry.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { IService } from '@esengine/ecs-framework';
|
||||||
|
import { Injectable } from '@esengine/ecs-framework';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import type { ISerializer } from '../Plugins/IEditorPlugin';
|
||||||
|
|
||||||
|
const logger = createLogger('SerializerRegistry');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化器注册表
|
||||||
|
*
|
||||||
|
* 管理所有数据序列化器的注册和查询。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SerializerRegistry implements IService {
|
||||||
|
private serializers: Map<string, ISerializer> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册序列化器
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param serializer - 序列化器实例
|
||||||
|
*/
|
||||||
|
public register(pluginName: string, serializer: ISerializer): void {
|
||||||
|
const type = serializer.getSupportedType();
|
||||||
|
const key = `${pluginName}:${type}`;
|
||||||
|
|
||||||
|
if (this.serializers.has(key)) {
|
||||||
|
logger.warn(`Serializer for ${key} is already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serializers.set(key, serializer);
|
||||||
|
logger.info(`Registered serializer: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册序列化器
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param serializers - 序列化器实例数组
|
||||||
|
*/
|
||||||
|
public registerMultiple(pluginName: string, serializers: ISerializer[]): void {
|
||||||
|
for (const serializer of serializers) {
|
||||||
|
this.register(pluginName, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销序列化器
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @returns 是否成功注销
|
||||||
|
*/
|
||||||
|
public unregister(pluginName: string, type: string): boolean {
|
||||||
|
const key = `${pluginName}:${type}`;
|
||||||
|
const result = this.serializers.delete(key);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
logger.info(`Unregistered serializer: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销插件的所有序列化器
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
*/
|
||||||
|
public unregisterAll(pluginName: string): void {
|
||||||
|
const prefix = `${pluginName}:`;
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const key of this.serializers.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToDelete) {
|
||||||
|
this.serializers.delete(key);
|
||||||
|
logger.info(`Unregistered serializer: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取序列化器
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @returns 序列化器实例,如果未找到则返回 undefined
|
||||||
|
*/
|
||||||
|
public get(pluginName: string, type: string): ISerializer | undefined {
|
||||||
|
const key = `${pluginName}:${type}`;
|
||||||
|
return this.serializers.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找支持指定类型的序列化器
|
||||||
|
*
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @returns 序列化器实例数组
|
||||||
|
*/
|
||||||
|
public findByType(type: string): ISerializer[] {
|
||||||
|
const result: ISerializer[] = [];
|
||||||
|
|
||||||
|
for (const [key, serializer] of this.serializers) {
|
||||||
|
if (key.endsWith(`:${type}`)) {
|
||||||
|
result.push(serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有序列化器
|
||||||
|
*
|
||||||
|
* @returns 序列化器映射表
|
||||||
|
*/
|
||||||
|
public getAll(): Map<string, ISerializer> {
|
||||||
|
return new Map(this.serializers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查序列化器是否已注册
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @returns 是否已注册
|
||||||
|
*/
|
||||||
|
public has(pluginName: string, type: string): boolean {
|
||||||
|
const key = `${pluginName}:${type}`;
|
||||||
|
return this.serializers.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化数据
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @param data - 要序列化的数据
|
||||||
|
* @returns 二进制数据
|
||||||
|
* @throws 如果序列化器未注册
|
||||||
|
*/
|
||||||
|
public serialize<T = any>(pluginName: string, type: string, data: T): Uint8Array {
|
||||||
|
const serializer = this.get(pluginName, type);
|
||||||
|
if (!serializer) {
|
||||||
|
throw new Error(`Serializer not found: ${pluginName}:${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializer.serialize(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化数据
|
||||||
|
*
|
||||||
|
* @param pluginName - 插件名称
|
||||||
|
* @param type - 数据类型
|
||||||
|
* @param data - 二进制数据
|
||||||
|
* @returns 反序列化后的数据
|
||||||
|
* @throws 如果序列化器未注册
|
||||||
|
*/
|
||||||
|
public deserialize<T = any>(pluginName: string, type: string, data: Uint8Array): T {
|
||||||
|
const serializer = this.get(pluginName, type);
|
||||||
|
if (!serializer) {
|
||||||
|
throw new Error(`Serializer not found: ${pluginName}:${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializer.deserialize(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
public dispose(): void {
|
||||||
|
this.serializers.clear();
|
||||||
|
logger.info('SerializerRegistry disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
202
packages/editor-core/src/Services/UIRegistry.ts
Normal file
202
packages/editor-core/src/Services/UIRegistry.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import type { IService } from '@esengine/ecs-framework';
|
||||||
|
import { Injectable } from '@esengine/ecs-framework';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes';
|
||||||
|
|
||||||
|
const logger = createLogger('UIRegistry');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 注册表
|
||||||
|
*
|
||||||
|
* 管理所有编辑器 UI 扩展点的注册和查询。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UIRegistry implements IService {
|
||||||
|
private menus: Map<string, MenuItem> = new Map();
|
||||||
|
private toolbarItems: Map<string, ToolbarItem> = new Map();
|
||||||
|
private panels: Map<string, PanelDescriptor> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册菜单项
|
||||||
|
*/
|
||||||
|
public registerMenu(item: MenuItem): void {
|
||||||
|
if (this.menus.has(item.id)) {
|
||||||
|
logger.warn(`Menu item ${item.id} is already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.menus.set(item.id, item);
|
||||||
|
logger.debug(`Registered menu item: ${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册菜单项
|
||||||
|
*/
|
||||||
|
public registerMenus(items: MenuItem[]): void {
|
||||||
|
for (const item of items) {
|
||||||
|
this.registerMenu(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销菜单项
|
||||||
|
*/
|
||||||
|
public unregisterMenu(id: string): boolean {
|
||||||
|
const result = this.menus.delete(id);
|
||||||
|
if (result) {
|
||||||
|
logger.debug(`Unregistered menu item: ${id}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取菜单项
|
||||||
|
*/
|
||||||
|
public getMenu(id: string): MenuItem | undefined {
|
||||||
|
return this.menus.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有菜单项
|
||||||
|
*/
|
||||||
|
public getAllMenus(): MenuItem[] {
|
||||||
|
return Array.from(this.menus.values()).sort((a, b) => {
|
||||||
|
return (a.order ?? 0) - (b.order ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定父菜单的子菜单
|
||||||
|
*/
|
||||||
|
public getChildMenus(parentId: string): MenuItem[] {
|
||||||
|
return this.getAllMenus()
|
||||||
|
.filter(item => item.parentId === parentId)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工具栏项
|
||||||
|
*/
|
||||||
|
public registerToolbarItem(item: ToolbarItem): void {
|
||||||
|
if (this.toolbarItems.has(item.id)) {
|
||||||
|
logger.warn(`Toolbar item ${item.id} is already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toolbarItems.set(item.id, item);
|
||||||
|
logger.debug(`Registered toolbar item: ${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册工具栏项
|
||||||
|
*/
|
||||||
|
public registerToolbarItems(items: ToolbarItem[]): void {
|
||||||
|
for (const item of items) {
|
||||||
|
this.registerToolbarItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销工具栏项
|
||||||
|
*/
|
||||||
|
public unregisterToolbarItem(id: string): boolean {
|
||||||
|
const result = this.toolbarItems.delete(id);
|
||||||
|
if (result) {
|
||||||
|
logger.debug(`Unregistered toolbar item: ${id}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工具栏项
|
||||||
|
*/
|
||||||
|
public getToolbarItem(id: string): ToolbarItem | undefined {
|
||||||
|
return this.toolbarItems.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有工具栏项
|
||||||
|
*/
|
||||||
|
public getAllToolbarItems(): ToolbarItem[] {
|
||||||
|
return Array.from(this.toolbarItems.values()).sort((a, b) => {
|
||||||
|
return (a.order ?? 0) - (b.order ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定组的工具栏项
|
||||||
|
*/
|
||||||
|
public getToolbarItemsByGroup(groupId: string): ToolbarItem[] {
|
||||||
|
return this.getAllToolbarItems()
|
||||||
|
.filter(item => item.groupId === groupId)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册面板
|
||||||
|
*/
|
||||||
|
public registerPanel(panel: PanelDescriptor): void {
|
||||||
|
if (this.panels.has(panel.id)) {
|
||||||
|
logger.warn(`Panel ${panel.id} is already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panels.set(panel.id, panel);
|
||||||
|
logger.debug(`Registered panel: ${panel.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册面板
|
||||||
|
*/
|
||||||
|
public registerPanels(panels: PanelDescriptor[]): void {
|
||||||
|
for (const panel of panels) {
|
||||||
|
this.registerPanel(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销面板
|
||||||
|
*/
|
||||||
|
public unregisterPanel(id: string): boolean {
|
||||||
|
const result = this.panels.delete(id);
|
||||||
|
if (result) {
|
||||||
|
logger.debug(`Unregistered panel: ${id}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取面板
|
||||||
|
*/
|
||||||
|
public getPanel(id: string): PanelDescriptor | undefined {
|
||||||
|
return this.panels.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有面板
|
||||||
|
*/
|
||||||
|
public getAllPanels(): PanelDescriptor[] {
|
||||||
|
return Array.from(this.panels.values()).sort((a, b) => {
|
||||||
|
return (a.order ?? 0) - (b.order ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定位置的面板
|
||||||
|
*/
|
||||||
|
public getPanelsByPosition(position: string): PanelDescriptor[] {
|
||||||
|
return this.getAllPanels()
|
||||||
|
.filter(panel => panel.position === position)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
public dispose(): void {
|
||||||
|
this.menus.clear();
|
||||||
|
this.toolbarItems.clear();
|
||||||
|
this.panels.clear();
|
||||||
|
logger.info('UIRegistry disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
160
packages/editor-core/src/Types/UITypes.ts
Normal file
160
packages/editor-core/src/Types/UITypes.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 菜单项配置
|
||||||
|
*/
|
||||||
|
export interface MenuItem {
|
||||||
|
/**
|
||||||
|
* 菜单项唯一标识
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示文本
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父菜单ID,用于构建层级菜单
|
||||||
|
*/
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击回调
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘快捷键
|
||||||
|
*/
|
||||||
|
shortcut?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否禁用
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分隔符
|
||||||
|
*/
|
||||||
|
separator?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序权重
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具栏项配置
|
||||||
|
*/
|
||||||
|
export interface ToolbarItem {
|
||||||
|
/**
|
||||||
|
* 工具栏项唯一标识
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示文本
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具栏组ID
|
||||||
|
*/
|
||||||
|
groupId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击回调
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否禁用
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序权重
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板位置
|
||||||
|
*/
|
||||||
|
export enum PanelPosition {
|
||||||
|
Left = 'left',
|
||||||
|
Right = 'right',
|
||||||
|
Bottom = 'bottom',
|
||||||
|
Center = 'center'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板描述符
|
||||||
|
*/
|
||||||
|
export interface PanelDescriptor {
|
||||||
|
/**
|
||||||
|
* 面板唯一标识
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示标题
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板位置
|
||||||
|
*/
|
||||||
|
position: PanelPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染组件或HTML
|
||||||
|
*/
|
||||||
|
component?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认宽度/高度(像素)
|
||||||
|
*/
|
||||||
|
defaultSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可调整大小
|
||||||
|
*/
|
||||||
|
resizable?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可关闭
|
||||||
|
*/
|
||||||
|
closable?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序权重
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 扩展点类型
|
||||||
|
*/
|
||||||
|
export enum UIExtensionType {
|
||||||
|
Menu = 'menu',
|
||||||
|
Toolbar = 'toolbar',
|
||||||
|
Panel = 'panel',
|
||||||
|
Inspector = 'inspector',
|
||||||
|
StatusBar = 'statusbar'
|
||||||
|
}
|
||||||
14
packages/editor-core/src/index.ts
Normal file
14
packages/editor-core/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* ECS Framework Editor Core
|
||||||
|
*
|
||||||
|
* Plugin-based editor framework for ECS Framework
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './Plugins/IEditorPlugin';
|
||||||
|
export * from './Plugins/EditorPluginManager';
|
||||||
|
|
||||||
|
export * from './Services/UIRegistry';
|
||||||
|
export * from './Services/MessageHub';
|
||||||
|
export * from './Services/SerializerRegistry';
|
||||||
|
|
||||||
|
export * from './Types/UITypes';
|
||||||
268
packages/editor-core/tests/EditorPluginManager.test.ts
Normal file
268
packages/editor-core/tests/EditorPluginManager.test.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
EditorPluginManager,
|
||||||
|
UIRegistry,
|
||||||
|
MessageHub,
|
||||||
|
SerializerRegistry,
|
||||||
|
EditorPluginCategory,
|
||||||
|
PanelPosition
|
||||||
|
} from '../src';
|
||||||
|
import type { IEditorPlugin, ISerializer } from '../src';
|
||||||
|
|
||||||
|
class TestSerializer implements ISerializer<string> {
|
||||||
|
serialize(data: string): Uint8Array {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return encoder.encode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deserialize(data: Uint8Array): string {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedType(): string {
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestEditorPlugin implements IEditorPlugin {
|
||||||
|
readonly name = 'test-editor-plugin';
|
||||||
|
readonly version = '1.0.0';
|
||||||
|
readonly displayName = 'Test Editor Plugin';
|
||||||
|
readonly category = EditorPluginCategory.Tool;
|
||||||
|
readonly description = 'A test plugin for editor';
|
||||||
|
|
||||||
|
async install(): Promise<void> {
|
||||||
|
// 测试安装
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
// 测试卸载
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMenuItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'test-menu',
|
||||||
|
label: 'Test Menu',
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerToolbar() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'test-toolbar',
|
||||||
|
label: 'Test Toolbar',
|
||||||
|
groupId: 'test-group',
|
||||||
|
onClick: () => {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPanels() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'test-panel',
|
||||||
|
title: 'Test Panel',
|
||||||
|
position: PanelPosition.Left
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getSerializers() {
|
||||||
|
return [new TestSerializer()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EditorPluginManager', () => {
|
||||||
|
let core: Core;
|
||||||
|
let pluginManager: EditorPluginManager;
|
||||||
|
let uiRegistry: UIRegistry;
|
||||||
|
let messageHub: MessageHub;
|
||||||
|
let serializerRegistry: SerializerRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
core = Core.create({ debug: false });
|
||||||
|
|
||||||
|
uiRegistry = new UIRegistry();
|
||||||
|
messageHub = new MessageHub();
|
||||||
|
serializerRegistry = new SerializerRegistry();
|
||||||
|
|
||||||
|
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||||
|
Core.services.registerInstance(MessageHub, messageHub);
|
||||||
|
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||||
|
|
||||||
|
pluginManager = new EditorPluginManager();
|
||||||
|
pluginManager.initialize(core, Core.services);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
pluginManager.dispose();
|
||||||
|
Core.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('基本功能', () => {
|
||||||
|
it('应该能够安装编辑器插件', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
expect(pluginManager.isInstalled(plugin.name)).toBe(true);
|
||||||
|
expect(pluginManager.getEditorPlugin(plugin.name)).toBe(plugin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够卸载编辑器插件', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
await pluginManager.uninstallEditor(plugin.name);
|
||||||
|
|
||||||
|
expect(pluginManager.isInstalled(plugin.name)).toBe(false);
|
||||||
|
expect(pluginManager.getEditorPlugin(plugin.name)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够获取插件元数据', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const metadata = pluginManager.getPluginMetadata(plugin.name);
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
expect(metadata?.name).toBe(plugin.name);
|
||||||
|
expect(metadata?.displayName).toBe(plugin.displayName);
|
||||||
|
expect(metadata?.category).toBe(plugin.category);
|
||||||
|
expect(metadata?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UI 注册', () => {
|
||||||
|
it('应该注册菜单项', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const menu = uiRegistry.getMenu('test-menu');
|
||||||
|
expect(menu).toBeDefined();
|
||||||
|
expect(menu?.label).toBe('Test Menu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该注册工具栏项', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const toolbar = uiRegistry.getToolbarItem('test-toolbar');
|
||||||
|
expect(toolbar).toBeDefined();
|
||||||
|
expect(toolbar?.label).toBe('Test Toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该注册面板', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const panel = uiRegistry.getPanel('test-panel');
|
||||||
|
expect(panel).toBeDefined();
|
||||||
|
expect(panel?.title).toBe('Test Panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('卸载插件时应该注销 UI 元素', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
await pluginManager.uninstallEditor(plugin.name);
|
||||||
|
|
||||||
|
expect(uiRegistry.getMenu('test-menu')).toBeUndefined();
|
||||||
|
expect(uiRegistry.getToolbarItem('test-toolbar')).toBeUndefined();
|
||||||
|
expect(uiRegistry.getPanel('test-panel')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('序列化器注册', () => {
|
||||||
|
it('应该注册序列化器', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
expect(serializerRegistry.has(plugin.name, 'string')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够使用序列化器', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const testData = 'Hello World';
|
||||||
|
const serialized = serializerRegistry.serialize(plugin.name, 'string', testData);
|
||||||
|
const deserialized = serializerRegistry.deserialize(plugin.name, 'string', serialized);
|
||||||
|
|
||||||
|
expect(deserialized).toBe(testData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('卸载插件时应该注销序列化器', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
await pluginManager.uninstallEditor(plugin.name);
|
||||||
|
|
||||||
|
expect(serializerRegistry.has(plugin.name, 'string')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('事件通知', () => {
|
||||||
|
it('应该发送插件安装事件', async () => {
|
||||||
|
let eventReceived = false;
|
||||||
|
|
||||||
|
messageHub.subscribe('plugin:installed', (data: any) => {
|
||||||
|
eventReceived = true;
|
||||||
|
expect(data.name).toBe('test-editor-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
expect(eventReceived).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该发送项目打开事件', async () => {
|
||||||
|
let eventReceived = false;
|
||||||
|
|
||||||
|
messageHub.subscribe('project:opened', (data: any) => {
|
||||||
|
eventReceived = true;
|
||||||
|
expect(data.path).toBe('/test/project');
|
||||||
|
});
|
||||||
|
|
||||||
|
await pluginManager.notifyProjectOpen('/test/project');
|
||||||
|
|
||||||
|
expect(eventReceived).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该发送项目关闭事件', async () => {
|
||||||
|
let eventReceived = false;
|
||||||
|
|
||||||
|
messageHub.subscribe('project:closed', () => {
|
||||||
|
eventReceived = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await pluginManager.notifyProjectClose();
|
||||||
|
|
||||||
|
expect(eventReceived).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('插件管理', () => {
|
||||||
|
it('应该能够按类别获取插件', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
const plugins = pluginManager.getPluginsByCategory(EditorPluginCategory.Tool);
|
||||||
|
expect(plugins.length).toBe(1);
|
||||||
|
expect(plugins[0]).toBe(plugin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够启用/禁用插件', async () => {
|
||||||
|
const plugin = new TestEditorPlugin();
|
||||||
|
await pluginManager.installEditor(plugin);
|
||||||
|
|
||||||
|
await pluginManager.disablePlugin(plugin.name);
|
||||||
|
let metadata = pluginManager.getPluginMetadata(plugin.name);
|
||||||
|
expect(metadata?.enabled).toBe(false);
|
||||||
|
|
||||||
|
await pluginManager.enablePlugin(plugin.name);
|
||||||
|
metadata = pluginManager.getPluginMetadata(plugin.name);
|
||||||
|
expect(metadata?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/editor-core/tsconfig.json
Normal file
51
packages/editor-core/tsconfig.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"outDir": "./bin",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"composite": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"noUncheckedIndexedAccess": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"isolatedModules": false,
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"bin",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../core"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user