Feature/runtime cdn and plugin loader (#240)
* feat(ui): 完善 UI 布局系统和编辑器可视化工具 * refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统 * fix: 修复 CodeQL 警告并提升测试覆盖率 * refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题 * fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤 * docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明 * fix(ci): 修复 type-check 失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖 * fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖 * fix(ci): platform-web 添加缺失的 behavior-tree 依赖 * fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ECS Framework Editor</title>
|
||||
<title>ESEngine Editor</title>
|
||||
<!-- ES Module Shims: 为不支持 Import Maps 的浏览器提供 polyfill -->
|
||||
<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@esengine/editor-app",
|
||||
"version": "1.0.9",
|
||||
"description": "ECS Framework Editor Application - Cross-platform desktop editor",
|
||||
"description": "ESEngine Editor Application - Cross-platform desktop editor",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -17,11 +17,10 @@
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/ecs-components": "workspace:*",
|
||||
"@esengine/tilemap": "workspace:*",
|
||||
"@esengine/tilemap-editor": "workspace:*",
|
||||
"@esengine/ui": "workspace:*",
|
||||
"@esengine/ui-editor": "workspace:*",
|
||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "ecs-editor"
|
||||
version = "1.0.0"
|
||||
description = "ECS Framework Editor - Cross-platform desktop editor"
|
||||
description = "ESEngine Editor - Cross-platform desktop editor"
|
||||
authors = ["yhh"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! ECS Framework Editor - Tauri Backend
|
||||
//! ESEngine Editor - Tauri Backend
|
||||
//!
|
||||
//! Clean entry point that handles application setup and command registration.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"productName": "ECS Framework Editor",
|
||||
"productName": "ESEngine Editor",
|
||||
"version": "1.0.9",
|
||||
"identifier": "com.esengine.editor",
|
||||
"build": {
|
||||
@@ -35,7 +35,7 @@
|
||||
"ecs"
|
||||
],
|
||||
"name": "ECS Scene File",
|
||||
"description": "ECS Framework Scene File",
|
||||
"description": "ESEngine Scene File",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
@@ -51,7 +51,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ECS Framework Editor",
|
||||
"title": "ESEngine Editor",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as ECSFramework from '@esengine/ecs-framework';
|
||||
(window as any).ReactDOM = ReactDOM;
|
||||
(window as any).ReactJSXRuntime = ReactJSXRuntime;
|
||||
import {
|
||||
EditorPluginManager,
|
||||
PluginManager,
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
EntityStoreService,
|
||||
@@ -36,7 +36,6 @@ import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { ConsolePanel } from './components/ConsolePanel';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
||||
import { ProfilerWindow } from './components/ProfilerWindow';
|
||||
import { PortManager } from './components/PortManager';
|
||||
import { SettingsWindow } from './components/SettingsWindow';
|
||||
@@ -46,15 +45,11 @@ import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { MenuBar } from './components/MenuBar';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { UserDashboard } from './components/UserDashboard';
|
||||
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import { PluginLoader } from './services/PluginLoader';
|
||||
import { GitHubService } from './services/GitHubService';
|
||||
import { PluginPublishWizard } from './components/PluginPublishWizard';
|
||||
import { GitHubLoginDialog } from './components/GitHubLoginDialog';
|
||||
import { EngineService } from './services/EngineService';
|
||||
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
|
||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||
import { useLocale } from './hooks/useLocale';
|
||||
@@ -83,14 +78,13 @@ const logger = createLogger('App');
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const [pluginLoader] = useState(() => new PluginLoader());
|
||||
const [githubService] = useState(() => new GitHubService());
|
||||
const { showToast, hideToast } = useToast();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
|
||||
const [pluginManager, setPluginManager] = useState<EditorPluginManager | null>(null);
|
||||
const [pluginManager, setPluginManager] = useState<PluginManager | null>(null);
|
||||
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
|
||||
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
|
||||
const [inspectorRegistry, setInspectorRegistry] = useState<InspectorRegistry | null>(null);
|
||||
@@ -117,7 +111,6 @@ function App() {
|
||||
const [showProjectWizard, setShowProjectWizard] = useState(false);
|
||||
|
||||
const {
|
||||
showPluginManager, setShowPluginManager,
|
||||
showProfiler, setShowProfiler,
|
||||
showPortManager, setShowPortManager,
|
||||
showSettings, setShowSettings,
|
||||
@@ -126,12 +119,11 @@ function App() {
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
} = useDialogStore();
|
||||
const [settingsInitialCategory, setSettingsInitialCategory] = useState<string | undefined>(undefined);
|
||||
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
|
||||
const [activePanelId, setActivePanelId] = useState<string | undefined>(undefined);
|
||||
const [dynamicPanelTitles, setDynamicPanelTitles] = useState<Map<string, string>>(new Map());
|
||||
const [isEditorFullscreen, setIsEditorFullscreen] = useState(false);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
const [compilerDialog, setCompilerDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
@@ -274,6 +266,9 @@ function App() {
|
||||
const pluginInstaller = new PluginInstaller();
|
||||
await pluginInstaller.installBuiltinPlugins(services.pluginManager);
|
||||
|
||||
// 初始化编辑器模块(安装设置、面板等)
|
||||
await services.pluginManager.initializeEditor(Core.services);
|
||||
|
||||
services.notification.setCallbacks(showToast, hideToast);
|
||||
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
|
||||
|
||||
@@ -283,7 +278,9 @@ function App() {
|
||||
if (windowId === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
} else if (windowId === 'pluginManager') {
|
||||
setShowPluginManager(true);
|
||||
// 插件管理现在整合到设置窗口中
|
||||
setSettingsInitialCategory('plugins');
|
||||
setShowSettings(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -370,7 +367,7 @@ function App() {
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 1/2: 打开项目配置...' : 'Step 1/2: Opening project config...');
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 1/3: 打开项目配置...' : 'Step 1/3: Opening project config...');
|
||||
|
||||
const projectService = Core.services.resolve(ProjectService);
|
||||
|
||||
@@ -385,21 +382,40 @@ function App() {
|
||||
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
|
||||
await TauriAPI.setProjectBasePath(projectPath);
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
settings.addRecentProject(projectPath);
|
||||
|
||||
setCurrentProjectPath(projectPath);
|
||||
// 设置 projectLoaded 为 true,触发主界面渲染(包括 Viewport)
|
||||
setProjectLoaded(true);
|
||||
|
||||
// 等待引擎初始化完成(Viewport 渲染后会触发引擎初始化)
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 2/3: 初始化引擎和模块...' : 'Step 2/3: Initializing engine and modules...');
|
||||
const engineService = EngineService.getInstance();
|
||||
|
||||
// 等待引擎初始化(最多等待 30 秒,因为需要等待 Viewport 渲染)
|
||||
const engineReady = await engineService.waitForInitialization(30000);
|
||||
if (!engineReady) {
|
||||
throw new Error(locale === 'zh' ? '引擎初始化超时' : 'Engine initialization timeout');
|
||||
}
|
||||
|
||||
// 初始化模块系统(所有插件的 runtimeModule 会在 PluginManager 安装时自动注册)
|
||||
await engineService.initializeModuleSystems();
|
||||
|
||||
// 应用项目的 UI 设计分辨率
|
||||
// Apply project's UI design resolution
|
||||
const uiResolution = projectService.getUIDesignResolution();
|
||||
engineService.setUICanvasSize(uiResolution.width, uiResolution.height);
|
||||
|
||||
setStatus(t('header.status.projectOpened'));
|
||||
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 2/2: 初始化场景...' : 'Step 2/2: Initializing scene...');
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 3/3: 初始化场景...' : 'Step 3/3: Initializing scene...');
|
||||
|
||||
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
await sceneManagerService.newScene();
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
settings.addRecentProject(projectPath);
|
||||
|
||||
setCurrentProjectPath(projectPath);
|
||||
setProjectLoaded(true);
|
||||
|
||||
if (pluginManager) {
|
||||
setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...');
|
||||
await pluginLoader.loadProjectPlugins(projectPath, pluginManager);
|
||||
@@ -605,9 +621,21 @@ function App() {
|
||||
};
|
||||
|
||||
const handleCloseProject = async () => {
|
||||
// 卸载项目插件
|
||||
if (pluginManager) {
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
}
|
||||
|
||||
// 清理模块系统
|
||||
const engineService = EngineService.getInstance();
|
||||
engineService.clearModuleSystems();
|
||||
|
||||
// 关闭 ProjectService 中的项目
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
if (projectService) {
|
||||
await projectService.closeProject();
|
||||
}
|
||||
|
||||
setProjectLoaded(false);
|
||||
setCurrentProjectPath(null);
|
||||
setIsProfilerMode(false);
|
||||
@@ -623,12 +651,7 @@ function App() {
|
||||
|
||||
// 通知所有已加载的插件更新语言
|
||||
if (pluginManager) {
|
||||
const allPlugins = pluginManager.getAllEditorPlugins();
|
||||
allPlugins.forEach((plugin) => {
|
||||
if (plugin.setLocale) {
|
||||
plugin.setLocale(newLocale);
|
||||
}
|
||||
});
|
||||
pluginManager.setLocale(newLocale);
|
||||
|
||||
// 通过 MessageHub 通知需要重新获取节点模板
|
||||
if (messageHub) {
|
||||
@@ -747,10 +770,7 @@ function App() {
|
||||
];
|
||||
}
|
||||
|
||||
const enabledPlugins = pluginManager.getAllPluginMetadata()
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => p.name);
|
||||
|
||||
// 获取启用的插件面板
|
||||
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
|
||||
.filter((panelDesc) => {
|
||||
if (!panelDesc.component) {
|
||||
@@ -759,14 +779,7 @@ function App() {
|
||||
if (panelDesc.isDynamic) {
|
||||
return false;
|
||||
}
|
||||
return enabledPlugins.some((pluginName) => {
|
||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||
if (plugin && plugin.registerPanels) {
|
||||
const pluginPanels = plugin.registerPanels();
|
||||
return pluginPanels.some((p) => p.id === panelDesc.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.map((panelDesc) => {
|
||||
const Component = panelDesc.component;
|
||||
@@ -879,7 +892,7 @@ function App() {
|
||||
<>
|
||||
<div className="editor-titlebar" data-tauri-drag-region>
|
||||
<span className="titlebar-project-name">{projectName}</span>
|
||||
<span className="titlebar-app-name">ECS Framework Editor</span>
|
||||
<span className="titlebar-app-name">ESEngine Editor</span>
|
||||
</div>
|
||||
<div className={`editor-header ${isRemoteConnected ? 'remote-connected' : ''}`}>
|
||||
<MenuBar
|
||||
@@ -894,7 +907,10 @@ function App() {
|
||||
onOpenProject={handleOpenProject}
|
||||
onCloseProject={handleCloseProject}
|
||||
onExit={handleExit}
|
||||
onOpenPluginManager={() => setShowPluginManager(true)}
|
||||
onOpenPluginManager={() => {
|
||||
setSettingsInitialCategory('plugins');
|
||||
setShowSettings(true);
|
||||
}}
|
||||
onOpenProfiler={() => setShowProfiler(true)}
|
||||
onOpenPortManager={() => setShowPortManager(true)}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
@@ -904,12 +920,6 @@ function App() {
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
/>
|
||||
<div className="header-right">
|
||||
<UserProfile
|
||||
githubService={githubService}
|
||||
onLogin={() => setShowLoginDialog(true)}
|
||||
onOpenDashboard={() => setShowDashboard(true)}
|
||||
locale={locale}
|
||||
/>
|
||||
<div className="locale-dropdown" ref={localeMenuRef}>
|
||||
<button
|
||||
className="toolbar-btn locale-btn"
|
||||
@@ -948,22 +958,6 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showLoginDialog && (
|
||||
<GitHubLoginDialog
|
||||
githubService={githubService}
|
||||
onClose={() => setShowLoginDialog(false)}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDashboard && (
|
||||
<UserDashboard
|
||||
githubService={githubService}
|
||||
onClose={() => setShowDashboard(false)}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CompilerConfigDialog
|
||||
isOpen={compilerDialog.isOpen}
|
||||
compilerId={compilerDialog.compilerId}
|
||||
@@ -991,34 +985,11 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
<span>{t('footer.plugins')}: {pluginManager?.getAllEditorPlugins().length ?? 0}</span>
|
||||
<span>{t('footer.plugins')}: {pluginManager?.getAllPlugins().length ?? 0}</span>
|
||||
<span>{t('footer.entities')}: {entityStore?.getAllEntities().length ?? 0}</span>
|
||||
<span>{t('footer.core')}: {t('footer.active')}</span>
|
||||
</div>
|
||||
|
||||
{showPluginManager && pluginManager && notification && dialog && (
|
||||
<PluginManagerWindow
|
||||
pluginManager={pluginManager}
|
||||
githubService={githubService}
|
||||
onClose={() => setShowPluginManager(false)}
|
||||
locale={locale}
|
||||
projectPath={currentProjectPath}
|
||||
onOpen={() => {
|
||||
// 同步所有插件的语言状态
|
||||
const allPlugins = pluginManager.getAllEditorPlugins();
|
||||
allPlugins.forEach((plugin) => {
|
||||
if (plugin.setLocale) {
|
||||
plugin.setLocale(locale);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onRefresh={async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProfiler && (
|
||||
<ProfilerWindow onClose={() => setShowProfiler(false)} />
|
||||
@@ -1029,7 +1000,14 @@ function App() {
|
||||
)}
|
||||
|
||||
{showSettings && settingsRegistry && (
|
||||
<SettingsWindow onClose={() => setShowSettings(false)} settingsRegistry={settingsRegistry} />
|
||||
<SettingsWindow
|
||||
onClose={() => {
|
||||
setShowSettings(false);
|
||||
setSettingsInitialCategory(undefined);
|
||||
}}
|
||||
settingsRegistry={settingsRegistry}
|
||||
initialCategoryId={settingsInitialCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAbout && (
|
||||
|
||||
@@ -241,6 +241,18 @@ export class TauriAPI {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @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 临时目录路径
|
||||
|
||||
@@ -7,7 +7,6 @@ interface ErrorDialogData {
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showPluginManager: boolean;
|
||||
showProfiler: boolean;
|
||||
showPortManager: boolean;
|
||||
showSettings: boolean;
|
||||
@@ -16,7 +15,6 @@ interface DialogState {
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
|
||||
setShowPluginManager: (show: boolean) => void;
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowPortManager: (show: boolean) => void;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
@@ -28,7 +26,6 @@ interface DialogState {
|
||||
}
|
||||
|
||||
export const useDialogStore = create<DialogState>((set) => ({
|
||||
showPluginManager: false,
|
||||
showProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
@@ -37,7 +34,6 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
|
||||
setShowPluginManager: (show) => set({ showPluginManager: show }),
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowPortManager: (show) => set({ showPortManager: show }),
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
@@ -47,7 +43,6 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showPluginManager: false,
|
||||
showProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
|
||||
@@ -1,28 +1,69 @@
|
||||
import type { EditorPluginManager } from '@esengine/editor-core';
|
||||
import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
|
||||
import { GizmoPlugin } from '../../plugins/GizmoPlugin';
|
||||
import { TilemapEditorPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIEditorPlugin } from '@esengine/ui-editor';
|
||||
/**
|
||||
* 插件安装器
|
||||
* Plugin Installer
|
||||
*/
|
||||
|
||||
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 { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
|
||||
// 统一模块插件(CSS 已内联到 JS 中,导入时自动注入)
|
||||
import { TilemapPlugin } from '@esengine/tilemap';
|
||||
import { UIPlugin } from '@esengine/ui';
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree';
|
||||
|
||||
export class PluginInstaller {
|
||||
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
const plugins = [
|
||||
new GizmoPlugin(),
|
||||
new SceneInspectorPlugin(),
|
||||
new ProfilerPlugin(),
|
||||
new EditorAppearancePlugin(),
|
||||
new TilemapEditorPlugin(),
|
||||
new UIEditorPlugin()
|
||||
/**
|
||||
* 安装所有内置插件
|
||||
*/
|
||||
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: 'PluginConfigPlugin', plugin: PluginConfigPlugin },
|
||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (const { name, plugin } of builtinPlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await pluginManager.installEditor(plugin);
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to install plugin ${plugin.name}:`, error);
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// All builtin plugins registered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
LogService,
|
||||
SettingsRegistry,
|
||||
SceneManagerService,
|
||||
SceneTemplateRegistry,
|
||||
FileActionRegistry,
|
||||
EntityCreationRegistry,
|
||||
EditorPluginManager,
|
||||
PluginManager,
|
||||
IPluginManager,
|
||||
InspectorRegistry,
|
||||
IInspectorRegistry,
|
||||
PropertyRendererRegistry,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
CircleColliderComponent,
|
||||
AudioSourceComponent
|
||||
} from '@esengine/ecs-components';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
@@ -80,7 +83,7 @@ export interface EditorServices {
|
||||
settingsRegistry: SettingsRegistry;
|
||||
sceneManager: SceneManagerService;
|
||||
fileActionRegistry: FileActionRegistry;
|
||||
pluginManager: EditorPluginManager;
|
||||
pluginManager: PluginManager;
|
||||
diContainer: DIContainer;
|
||||
eventBus: TypedEventBus<EditorEventMap>;
|
||||
commandRegistry: CommandRegistry;
|
||||
@@ -106,15 +109,16 @@ export class ServiceRegistry {
|
||||
// 注册标准组件到编辑器和核心注册表
|
||||
// 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' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description' },
|
||||
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description' },
|
||||
{ name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description' },
|
||||
{ name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description' },
|
||||
{ name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description' }
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description', icon: 'Type' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description', icon: 'Atom' },
|
||||
{ name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description', icon: 'Square' },
|
||||
{ name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description', icon: 'Circle' },
|
||||
{ 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) {
|
||||
@@ -123,7 +127,8 @@ export class ServiceRegistry {
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description
|
||||
description: comp.description,
|
||||
icon: comp.icon
|
||||
});
|
||||
|
||||
// Register to core registry for serialization/deserialization
|
||||
@@ -158,9 +163,8 @@ export class ServiceRegistry {
|
||||
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
|
||||
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
|
||||
|
||||
const pluginManager = new EditorPluginManager();
|
||||
pluginManager.initialize(coreInstance, Core.services);
|
||||
Core.services.registerInstance(EditorPluginManager, pluginManager);
|
||||
const pluginManager = new PluginManager();
|
||||
Core.services.registerInstance(IPluginManager, pluginManager);
|
||||
|
||||
const diContainer = new DIContainer();
|
||||
const eventBus = new TypedEventBus<EditorEventMap>();
|
||||
@@ -202,6 +206,10 @@ export class ServiceRegistry {
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
@@ -235,4 +243,31 @@ export class ServiceRegistry {
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认场景模板
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-reac
|
||||
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 '../styles/AboutDialog.css';
|
||||
|
||||
interface AboutDialogProps {
|
||||
@@ -33,9 +34,9 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
title: 'About ECS Framework Editor',
|
||||
title: 'About ESEngine Editor',
|
||||
version: 'Version',
|
||||
description: 'High-performance ECS framework editor for game development',
|
||||
description: 'High-performance game editor for ECS-based game development',
|
||||
checkUpdate: 'Check for Updates',
|
||||
checking: 'Checking...',
|
||||
updateAvailable: 'New version available',
|
||||
@@ -49,9 +50,9 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
||||
github: 'GitHub'
|
||||
},
|
||||
zh: {
|
||||
title: '关于 ECS Framework Editor',
|
||||
title: '关于 ESEngine Editor',
|
||||
version: '版本',
|
||||
description: '高性能 ECS 框架编辑器,用于游戏开发',
|
||||
description: '高性能游戏编辑器,基于 ECS 架构',
|
||||
checkUpdate: '检查更新',
|
||||
checking: '检查中...',
|
||||
updateAvailable: '发现新版本',
|
||||
@@ -169,11 +170,11 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
||||
|
||||
<div className="about-content">
|
||||
<div className="about-logo">
|
||||
<div className="logo-placeholder">ECS</div>
|
||||
<MiniParticleLogo text="ESEngine" width={360} height={60} fontSize={42} />
|
||||
</div>
|
||||
|
||||
<div className="about-info">
|
||||
<h3>ECS Framework Editor</h3>
|
||||
<h3>ESEngine Editor</h3>
|
||||
<p className="about-version">
|
||||
{t('version')}: Editor {version}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw, Plus } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
@@ -8,6 +9,19 @@ import { ResizablePanel } from './ResizablePanel';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
import '../styles/AssetBrowser.css';
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <Plus size={size} />;
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
return <Plus size={size} />;
|
||||
}
|
||||
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -304,21 +318,15 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
} else if (asset.type === 'file') {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
console.log('[AssetBrowser] Opening scene:', asset.path);
|
||||
onOpenScene(asset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileActionRegistry) {
|
||||
console.log('[AssetBrowser] Handling double click for:', asset.path);
|
||||
console.log('[AssetBrowser] Extension:', asset.extension);
|
||||
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
|
||||
console.log('[AssetBrowser] Handled by plugin:', handled);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('[AssetBrowser] FileActionRegistry not available');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -436,12 +444,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
for (const template of templates) {
|
||||
items.push({
|
||||
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
|
||||
icon: template.icon,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: async () => {
|
||||
const fileName = `${template.defaultFileName}.${template.extension}`;
|
||||
const fileName = `new_${template.id}.${template.extension}`;
|
||||
const filePath = `${asset.path}/${fileName}`;
|
||||
const content = await template.createContent(fileName);
|
||||
await TauriAPI.writeFileContent(filePath, content);
|
||||
await template.create(filePath);
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
if (isOpen && compilerId) {
|
||||
try {
|
||||
const registry = Core.services.resolve(CompilerRegistry);
|
||||
console.log('[CompilerConfigDialog] Registry resolved:', registry);
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
|
||||
const comp = registry.get(compilerId);
|
||||
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
|
||||
setCompiler(comp || null);
|
||||
} catch (error) {
|
||||
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);
|
||||
|
||||
@@ -91,9 +91,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
};
|
||||
|
||||
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
|
||||
console.log('[EntityInspector] handlePropertyChange called:', propertyName, value);
|
||||
if (!selectedEntity) {
|
||||
console.log('[EntityInspector] No selectedEntity, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +107,6 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
});
|
||||
|
||||
// Also publish scene:modified so other panels can react
|
||||
console.log('[EntityInspector] Publishing scene:modified');
|
||||
messageHub.publish('scene:modified', {});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus } from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
@@ -8,6 +9,19 @@ import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { PromptDialog } from './PromptDialog';
|
||||
import '../styles/FileTree.css';
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <Plus size={size} />;
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
return <Plus size={size} />;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -557,7 +571,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
for (const template of templates) {
|
||||
baseItems.push({
|
||||
label: template.label,
|
||||
icon: template.icon,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: () => handleCreateTemplateFileClick(rootPath, template)
|
||||
});
|
||||
}
|
||||
@@ -639,7 +653,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
for (const template of templates) {
|
||||
items.push({
|
||||
label: template.label,
|
||||
icon: template.icon,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: () => handleCreateTemplateFileClick(node.path, template)
|
||||
});
|
||||
}
|
||||
@@ -724,7 +738,6 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
// Handle .ecs scene files
|
||||
const ext = node.name.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
console.log('[FileTree] Opening scene:', node.path);
|
||||
onOpenScene(node.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
/**
|
||||
* FlexLayoutDockContainer - Dockable panel container based on FlexLayout
|
||||
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
@@ -6,10 +11,86 @@ import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
|
||||
|
||||
export type { FlexDockPanel };
|
||||
|
||||
/**
|
||||
* 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;
|
||||
panels: FlexDockPanel[];
|
||||
onPanelClose?: (panelId: string) => void;
|
||||
activePanelId?: string;
|
||||
}
|
||||
|
||||
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: FlexLayoutDockContainerProps) {
|
||||
@@ -18,6 +99,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
const previousPanelIdsRef = useRef<string>('');
|
||||
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Persistent panel state | 持久化面板状态
|
||||
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
|
||||
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
|
||||
() => new Set(PERSISTENT_PANEL_IDS)
|
||||
);
|
||||
|
||||
const persistentPanels = useMemo(
|
||||
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
|
||||
[panels]
|
||||
);
|
||||
|
||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
|
||||
}, [panels, activePanelId]);
|
||||
@@ -155,10 +247,80 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
}
|
||||
}, [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 component = node.getComponent();
|
||||
const panel = panels.find((p) => p.id === component);
|
||||
return panel?.content || <div>Panel not found</div>;
|
||||
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) => {
|
||||
@@ -186,6 +348,52 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for persistent panel content.
|
||||
* 持久化面板内容容器。
|
||||
*/
|
||||
function PersistentPanelContainer({
|
||||
panel,
|
||||
rect,
|
||||
isVisible
|
||||
}: {
|
||||
panel: FlexDockPanel;
|
||||
rect?: DOMRect;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
|
||||
|
||||
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 ? 'visible' : 'hidden',
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
zIndex: isVisible ? 1 : -1,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{panel.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,28 +71,21 @@ export function GitHubAuth({ githubService, onSuccess, locale }: GitHubAuthProps
|
||||
setError('');
|
||||
|
||||
try {
|
||||
console.log('[GitHubAuth] Starting OAuth login...');
|
||||
|
||||
const deviceCodeResp = await githubService.requestDeviceCode();
|
||||
console.log('[GitHubAuth] Device code received:', deviceCodeResp.user_code);
|
||||
|
||||
setUserCode(deviceCodeResp.user_code);
|
||||
setVerificationUri(deviceCodeResp.verification_uri);
|
||||
|
||||
console.log('[GitHubAuth] Opening browser...');
|
||||
await open(deviceCodeResp.verification_uri);
|
||||
|
||||
console.log('[GitHubAuth] Starting authentication polling...');
|
||||
await githubService.authenticateWithDeviceFlow(
|
||||
deviceCodeResp.device_code,
|
||||
deviceCodeResp.interval,
|
||||
(status) => {
|
||||
console.log('[GitHubAuth] Auth status changed:', status);
|
||||
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[GitHubAuth] Authorization successful!');
|
||||
setAuthStatus('authorized');
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { UIRegistry, MessageHub, EditorPluginManager } from '@esengine/editor-core';
|
||||
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
|
||||
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import '../styles/MenuBar.css';
|
||||
@@ -18,7 +18,7 @@ interface MenuBarProps {
|
||||
locale?: string;
|
||||
uiRegistry?: UIRegistry;
|
||||
messageHub?: MessageHub;
|
||||
pluginManager?: EditorPluginManager;
|
||||
pluginManager?: PluginManager;
|
||||
onNewScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onSaveScene?: () => void;
|
||||
@@ -62,29 +62,7 @@ export function MenuBar({
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry && pluginManager) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
// 过滤掉被禁用插件的菜单项
|
||||
const enabledPlugins = pluginManager.getAllPluginMetadata()
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => p.name);
|
||||
|
||||
// 只显示启用插件的菜单项
|
||||
const filteredItems = items.filter((item) => {
|
||||
// 检查菜单项是否属于某个插件
|
||||
return enabledPlugins.some((pluginName) => {
|
||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||
if (plugin && plugin.registerMenuItems) {
|
||||
const pluginMenus = plugin.registerMenuItems();
|
||||
return pluginMenus.some((m) => m.id === item.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
setPluginMenuItems(filteredItems);
|
||||
} else if (uiRegistry) {
|
||||
// 如果没有 pluginManager,显示所有菜单项
|
||||
if (uiRegistry) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
}
|
||||
|
||||
@@ -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,198 @@
|
||||
/**
|
||||
* Plugin List Setting Component
|
||||
* 插件列表设置组件
|
||||
*
|
||||
* 简洁的插件列表,只显示:
|
||||
* - 勾选框表示启用状态
|
||||
* - 插件名称、版本
|
||||
* - 插件描述
|
||||
* - [Runtime] [Editor] 标签
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory } from '@esengine/editor-core';
|
||||
import { Check, Lock, RefreshCw, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
|
||||
interface PluginListSettingProps {
|
||||
pluginManager: PluginManager;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
core: { zh: '核心', en: 'Core' },
|
||||
rendering: { zh: '渲染', en: 'Rendering' },
|
||||
ui: { zh: 'UI', en: 'UI' },
|
||||
ai: { zh: 'AI', en: 'AI' },
|
||||
physics: { zh: '物理', en: 'Physics' },
|
||||
audio: { zh: '音频', en: 'Audio' },
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
content: { zh: '内容', en: 'Content' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'physics', 'audio', 'networking', 'tools'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
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 = (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.loader.descriptor.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
showWarning('核心插件不可禁用');
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnabled = !plugin.enabled;
|
||||
|
||||
// 检查依赖
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.loader.descriptor.id === dep.id);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 检查是否有其他插件依赖此插件
|
||||
const dependents = plugins.filter(p => {
|
||||
if (!p.enabled || p.loader.descriptor.id === pluginId) return false;
|
||||
const deps = p.loader.descriptor.dependencies || [];
|
||||
return deps.some(d => d.id === pluginId);
|
||||
});
|
||||
|
||||
if (dependents.length > 0) {
|
||||
showWarning(`以下插件依赖此插件: ${dependents.map(p => p.loader.descriptor.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录待处理的更改
|
||||
const newPendingChanges = new Map(pendingChanges);
|
||||
newPendingChanges.set(pluginId, newEnabled);
|
||||
setPendingChanges(newPendingChanges);
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.loader.descriptor.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
|
||||
// 调用 PluginManager 的启用/禁用方法
|
||||
if (newEnabled) {
|
||||
pluginManager.enable(pluginId);
|
||||
} else {
|
||||
pluginManager.disable(pluginId);
|
||||
}
|
||||
};
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.loader.descriptor.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
|
||||
|
||||
// 按照 categoryOrder 排序
|
||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||
|
||||
return (
|
||||
<div className="plugin-list-setting">
|
||||
{pendingChanges.size > 0 && (
|
||||
<div className="plugin-list-notice">
|
||||
<RefreshCw size={14} />
|
||||
<span>部分更改需要重启编辑器后生效</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedCategories.map(category => (
|
||||
<div key={category} className="plugin-category">
|
||||
<div className="plugin-category-header">
|
||||
{categoryLabels[category]?.zh || category}
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
const hasRuntime = !!plugin.loader.runtimeModule;
|
||||
const hasEditor = !!plugin.loader.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={descriptor.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(descriptor.id)}
|
||||
>
|
||||
<div className="plugin-checkbox">
|
||||
{descriptor.isCore ? (
|
||||
<Lock size={10} />
|
||||
) : (
|
||||
plugin.enabled && <Check size={10} />
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-header">
|
||||
<span className="plugin-name">{descriptor.name}</span>
|
||||
<span className="plugin-version">v{descriptor.version}</span>
|
||||
</div>
|
||||
{descriptor.description && (
|
||||
<div className="plugin-description">
|
||||
{descriptor.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>
|
||||
);
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Search,
|
||||
Grid,
|
||||
List,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
X,
|
||||
RefreshCw,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { PluginMarketPanel } from './PluginMarketPanel';
|
||||
import { PluginMarketService } from '../services/PluginMarketService';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import '../styles/PluginManagerWindow.css';
|
||||
|
||||
interface PluginManagerWindowProps {
|
||||
pluginManager: EditorPluginManager;
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onOpen?: () => void;
|
||||
locale: string;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
const categoryIcons: Record<EditorPluginCategory, string> = {
|
||||
[EditorPluginCategory.Tool]: 'Wrench',
|
||||
[EditorPluginCategory.Window]: 'LayoutGrid',
|
||||
[EditorPluginCategory.Inspector]: 'Search',
|
||||
[EditorPluginCategory.System]: 'Settings',
|
||||
[EditorPluginCategory.ImportExport]: 'Package'
|
||||
};
|
||||
|
||||
export function PluginManagerWindow({ pluginManager, githubService, onClose, onRefresh, onOpen, locale, projectPath }: PluginManagerWindowProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '插件管理器',
|
||||
searchPlaceholder: '搜索插件...',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
enablePlugin: '启用插件',
|
||||
disablePlugin: '禁用插件',
|
||||
refresh: '刷新',
|
||||
refreshPluginList: '刷新插件列表',
|
||||
close: '关闭',
|
||||
listView: '列表视图',
|
||||
gridView: '网格视图',
|
||||
noPlugins: '未安装插件',
|
||||
installed: '安装于',
|
||||
categoryTools: '工具',
|
||||
categoryWindows: '窗口',
|
||||
categoryInspectors: '检查器',
|
||||
categorySystem: '系统',
|
||||
categoryImportExport: '导入/导出',
|
||||
tabInstalled: '已安装',
|
||||
tabMarketplace: '插件市场'
|
||||
},
|
||||
en: {
|
||||
title: 'Plugin Manager',
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
enablePlugin: 'Enable plugin',
|
||||
disablePlugin: 'Disable plugin',
|
||||
refresh: 'Refresh',
|
||||
refreshPluginList: 'Refresh plugin list',
|
||||
close: 'Close',
|
||||
listView: 'List view',
|
||||
gridView: 'Grid view',
|
||||
noPlugins: 'No plugins installed',
|
||||
installed: 'Installed',
|
||||
categoryTools: 'Tools',
|
||||
categoryWindows: 'Windows',
|
||||
categoryInspectors: 'Inspectors',
|
||||
categorySystem: 'System',
|
||||
categoryImportExport: 'Import/Export',
|
||||
tabInstalled: 'Installed',
|
||||
tabMarketplace: 'Marketplace'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const getCategoryName = (category: EditorPluginCategory): string => {
|
||||
const categoryKeys: Record<EditorPluginCategory, string> = {
|
||||
[EditorPluginCategory.Tool]: 'categoryTools',
|
||||
[EditorPluginCategory.Window]: 'categoryWindows',
|
||||
[EditorPluginCategory.Inspector]: 'categoryInspectors',
|
||||
[EditorPluginCategory.System]: 'categorySystem',
|
||||
[EditorPluginCategory.ImportExport]: 'categoryImportExport'
|
||||
};
|
||||
return t(categoryKeys[category]);
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<'installed' | 'marketplace'>('installed');
|
||||
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<EditorPluginCategory>>(
|
||||
new Set(Object.values(EditorPluginCategory))
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const marketService = useMemo(() => new PluginMarketService(pluginManager), [pluginManager]);
|
||||
|
||||
// 设置项目路径到 marketService
|
||||
useEffect(() => {
|
||||
marketService.setProjectPath(projectPath);
|
||||
}, [projectPath, marketService]);
|
||||
|
||||
const updatePluginList = () => {
|
||||
const allPlugins = pluginManager.getAllPluginMetadata();
|
||||
setPlugins(allPlugins);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
updatePluginList();
|
||||
}, [pluginManager]);
|
||||
|
||||
// 监听 locale 变化,重新获取插件列表(以刷新插件的 displayName 和 description)
|
||||
useEffect(() => {
|
||||
updatePluginList();
|
||||
}, [locale]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!onRefresh || isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
updatePluginList();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh plugins:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlugin = async (name: string, enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await pluginManager.disablePlugin(name);
|
||||
} else {
|
||||
await pluginManager.enablePlugin(name);
|
||||
}
|
||||
const allPlugins = pluginManager.getAllPluginMetadata();
|
||||
setPlugins(allPlugins);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle plugin ${name}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: EditorPluginCategory) => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(category)) {
|
||||
newExpanded.delete(category);
|
||||
} else {
|
||||
newExpanded.add(category);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const filteredPlugins = plugins.filter((plugin) => {
|
||||
if (!filter) return true;
|
||||
const searchLower = filter.toLowerCase();
|
||||
return (
|
||||
plugin.name.toLowerCase().includes(searchLower) ||
|
||||
plugin.displayName.toLowerCase().includes(searchLower) ||
|
||||
plugin.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const pluginsByCategory = filteredPlugins.reduce(
|
||||
(acc, plugin) => {
|
||||
if (!acc[plugin.category]) {
|
||||
acc[plugin.category] = [];
|
||||
}
|
||||
acc[plugin.category].push(plugin);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<EditorPluginCategory, IEditorPluginMetadata[]>
|
||||
);
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const disabledCount = plugins.filter((p) => !p.enabled).length;
|
||||
|
||||
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-card-header">
|
||||
<div className="plugin-card-icon">
|
||||
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
|
||||
</div>
|
||||
<div className="plugin-card-info">
|
||||
<div className="plugin-card-title">{plugin.displayName}</div>
|
||||
<div className="plugin-card-version">v{plugin.version}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
|
||||
>
|
||||
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{plugin.description && <div className="plugin-card-description">{plugin.description}</div>}
|
||||
<div className="plugin-card-footer">
|
||||
<span className="plugin-card-category">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
|
||||
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
|
||||
})()}
|
||||
{getCategoryName(plugin.category)}
|
||||
</span>
|
||||
{plugin.installedAt && (
|
||||
<span className="plugin-card-installed">
|
||||
{t('installed')}: {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginList = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-list-icon">
|
||||
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
|
||||
</div>
|
||||
<div className="plugin-list-info">
|
||||
<div className="plugin-list-name">
|
||||
{plugin.displayName}
|
||||
<span className="plugin-list-version">v{plugin.version}</span>
|
||||
</div>
|
||||
{plugin.description && <div className="plugin-list-description">{plugin.description}</div>}
|
||||
</div>
|
||||
<div className="plugin-list-status">
|
||||
{plugin.enabled ? (
|
||||
<span className="status-badge enabled">{t('enabled')}</span>
|
||||
) : (
|
||||
<span className="status-badge disabled">{t('disabled')}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="plugin-list-toggle"
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
|
||||
>
|
||||
{plugin.enabled ? t('disable') : t('enable')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-manager-overlay" onClick={onClose}>
|
||||
<div className="plugin-manager-window" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="plugin-manager-header">
|
||||
<div className="plugin-manager-title">
|
||||
<Package size={20} />
|
||||
<h2>{t('title')}</h2>
|
||||
</div>
|
||||
<button className="plugin-manager-close" onClick={onClose} title={t('close')}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plugin-manager-tabs">
|
||||
<button
|
||||
className={`plugin-manager-tab ${activeTab === 'installed' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('installed')}
|
||||
>
|
||||
<Package size={16} />
|
||||
{t('tabInstalled')}
|
||||
</button>
|
||||
<button
|
||||
className={`plugin-manager-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('marketplace')}
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
{t('tabMarketplace')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<>
|
||||
<div className="plugin-toolbar">
|
||||
<div className="plugin-toolbar-left">
|
||||
<div className="plugin-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-toolbar-right">
|
||||
<div className="plugin-stats">
|
||||
<span className="stat-item enabled">
|
||||
<CheckCircle size={14} />
|
||||
{enabledCount} {t('enabled')}
|
||||
</span>
|
||||
<span className="stat-item disabled">
|
||||
<XCircle size={14} />
|
||||
{disabledCount} {t('disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
className="plugin-refresh-btn"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title={t('refreshPluginList')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: isRefreshing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
opacity: isRefreshing ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
|
||||
{t('refresh')}
|
||||
</button>
|
||||
)}
|
||||
<div className="plugin-view-mode">
|
||||
<button
|
||||
className={viewMode === 'list' ? 'active' : ''}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('listView')}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'grid' ? 'active' : ''}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('gridView')}
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="plugin-content"
|
||||
style={{ display: activeTab === 'installed' ? 'block' : 'none' }}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="plugin-empty">
|
||||
<Package size={48} />
|
||||
<p>{t('noPlugins')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-categories">
|
||||
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
|
||||
const cat = category as EditorPluginCategory;
|
||||
const isExpanded = expandedCategories.has(cat);
|
||||
|
||||
return (
|
||||
<div key={category} className="plugin-category">
|
||||
<div
|
||||
className="plugin-category-header"
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
<button className="plugin-category-toggle">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
<span className="plugin-category-icon">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[
|
||||
categoryIcons[cat]
|
||||
];
|
||||
return CategoryIcon ? <CategoryIcon size={16} /> : null;
|
||||
})()}
|
||||
</span>
|
||||
<span className="plugin-category-name">{getCategoryName(cat)}</span>
|
||||
<span className="plugin-category-count">
|
||||
{categoryPlugins.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={`plugin-category-content ${viewMode}`}>
|
||||
{viewMode === 'grid'
|
||||
? categoryPlugins.map(renderPluginCard)
|
||||
: categoryPlugins.map(renderPluginList)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'marketplace' && (
|
||||
<PluginMarketPanel
|
||||
marketService={marketService}
|
||||
locale={locale}
|
||||
projectPath={projectPath}
|
||||
onReloadPlugins={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
Download,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Star,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import type { PluginMarketService, PluginMarketMetadata } from '../services/PluginMarketService';
|
||||
import '../styles/PluginMarketPanel.css';
|
||||
|
||||
interface PluginMarketPanelProps {
|
||||
marketService: PluginMarketService;
|
||||
locale: string;
|
||||
projectPath: string | null;
|
||||
onReloadPlugins?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function PluginMarketPanel({ marketService, locale, projectPath, onReloadPlugins }: PluginMarketPanelProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '插件市场',
|
||||
searchPlaceholder: '搜索插件...',
|
||||
loading: '加载中...',
|
||||
loadError: '无法连接到插件市场',
|
||||
loadErrorDesc: '可能是网络连接问题,请检查您的网络设置后重试',
|
||||
retry: '重试',
|
||||
noPlugins: '没有找到插件',
|
||||
install: '安装',
|
||||
installed: '已安装',
|
||||
update: '更新',
|
||||
uninstall: '卸载',
|
||||
viewSource: '查看源码',
|
||||
official: '官方',
|
||||
verified: '认证',
|
||||
community: '社区',
|
||||
filterAll: '全部',
|
||||
filterOfficial: '官方插件',
|
||||
filterCommunity: '社区插件',
|
||||
categoryAll: '全部分类',
|
||||
installing: '安装中...',
|
||||
uninstalling: '卸载中...',
|
||||
useDirectSource: '使用直连源',
|
||||
useDirectSourceTip: '启用后直接从GitHub获取数据,绕过CDN缓存(适合测试)',
|
||||
latest: '最新',
|
||||
releaseNotes: '更新日志',
|
||||
selectVersion: '选择版本',
|
||||
noProjectOpen: '请先打开一个项目'
|
||||
},
|
||||
en: {
|
||||
title: 'Plugin Marketplace',
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
loading: 'Loading...',
|
||||
loadError: 'Unable to connect to plugin marketplace',
|
||||
loadErrorDesc: 'This might be a network connection issue. Please check your network settings and try again',
|
||||
retry: 'Retry',
|
||||
noPlugins: 'No plugins found',
|
||||
install: 'Install',
|
||||
installed: 'Installed',
|
||||
update: 'Update',
|
||||
uninstall: 'Uninstall',
|
||||
viewSource: 'View Source',
|
||||
official: 'Official',
|
||||
verified: 'Verified',
|
||||
community: 'Community',
|
||||
filterAll: 'All',
|
||||
filterOfficial: 'Official',
|
||||
filterCommunity: 'Community',
|
||||
categoryAll: 'All Categories',
|
||||
installing: 'Installing...',
|
||||
uninstalling: 'Uninstalling...',
|
||||
useDirectSource: 'Direct Source',
|
||||
useDirectSourceTip: 'Fetch data directly from GitHub, bypassing CDN cache (for testing)',
|
||||
latest: 'Latest',
|
||||
releaseNotes: 'Release Notes',
|
||||
selectVersion: 'Select Version',
|
||||
noProjectOpen: 'Please open a project first'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginMarketMetadata[]>([]);
|
||||
const [filteredPlugins, setFilteredPlugins] = useState<PluginMarketMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'official' | 'community'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
|
||||
const [useDirectSource, setUseDirectSource] = useState(marketService.isUsingDirectSource());
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterPlugins();
|
||||
}, [plugins, searchQuery, typeFilter, categoryFilter]);
|
||||
|
||||
const loadPlugins = async (bypassCache: boolean = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const pluginList = await marketService.fetchPluginList(bypassCache);
|
||||
setPlugins(pluginList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterPlugins = () => {
|
||||
let filtered = plugins;
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.description.toLowerCase().includes(query) ||
|
||||
p.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter((p) => p.category_type === typeFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
filtered = filtered.filter((p) => p.category === categoryFilter);
|
||||
}
|
||||
|
||||
setFilteredPlugins(filtered);
|
||||
};
|
||||
|
||||
const handleToggleDirectSource = () => {
|
||||
const newValue = !useDirectSource;
|
||||
setUseDirectSource(newValue);
|
||||
marketService.setUseDirectSource(newValue);
|
||||
loadPlugins(true);
|
||||
};
|
||||
|
||||
const handleInstall = async (plugin: PluginMarketMetadata, version?: string) => {
|
||||
if (!projectPath) {
|
||||
alert(t('noProjectOpen') || 'Please open a project first');
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
|
||||
|
||||
try {
|
||||
await marketService.installPlugin(plugin, version, onReloadPlugins);
|
||||
setPlugins([...plugins]);
|
||||
} catch (error) {
|
||||
console.error('Failed to install plugin:', error);
|
||||
alert(`Failed to install ${plugin.name}: ${error}`);
|
||||
} finally {
|
||||
setInstallingPlugins((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(plugin.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (plugin: PluginMarketMetadata) => {
|
||||
if (!confirm(`Are you sure you want to uninstall ${plugin.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
|
||||
|
||||
try {
|
||||
await marketService.uninstallPlugin(plugin.id, onReloadPlugins);
|
||||
setPlugins([...plugins]);
|
||||
} catch (error) {
|
||||
console.error('Failed to uninstall plugin:', error);
|
||||
alert(`Failed to uninstall ${plugin.name}: ${error}`);
|
||||
} finally {
|
||||
setInstallingPlugins((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(plugin.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category)))];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="plugin-market-loading">
|
||||
<RefreshCw size={32} className="spinning" />
|
||||
<p>{t('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="plugin-market-error">
|
||||
<AlertCircle size={64} className="error-icon" />
|
||||
<h3>{t('loadError')}</h3>
|
||||
<p className="error-description">{t('loadErrorDesc')}</p>
|
||||
<div className="error-details">
|
||||
<p className="error-message">{error}</p>
|
||||
</div>
|
||||
<button className="retry-button" onClick={() => loadPlugins(true)}>
|
||||
<RefreshCw size={16} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-market-panel">
|
||||
<div className="plugin-market-toolbar">
|
||||
<div className="plugin-market-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-filters">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as any)}
|
||||
className="plugin-market-filter-select"
|
||||
>
|
||||
<option value="all">{t('filterAll')}</option>
|
||||
<option value="official">{t('filterOfficial')}</option>
|
||||
<option value="community">{t('filterCommunity')}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="plugin-market-filter-select"
|
||||
>
|
||||
<option value="all">{t('categoryAll')}</option>
|
||||
{categories
|
||||
.filter((c) => c !== 'all')
|
||||
.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="plugin-market-direct-source-toggle" title={t('useDirectSourceTip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDirectSource}
|
||||
onChange={handleToggleDirectSource}
|
||||
/>
|
||||
<span className="toggle-label">{t('useDirectSource')}</span>
|
||||
</label>
|
||||
|
||||
<button className="plugin-market-refresh" onClick={() => loadPlugins(true)} title={t('retry')}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-content">
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<div className="plugin-market-empty">
|
||||
<Package size={48} />
|
||||
<p>{t('noPlugins')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-market-grid">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginMarketCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isInstalled={marketService.isInstalled(plugin.id)}
|
||||
hasUpdate={marketService.hasUpdate(plugin)}
|
||||
isInstalling={installingPlugins.has(plugin.id)}
|
||||
onInstall={(version) => handleInstall(plugin, version)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginMarketCardProps {
|
||||
plugin: PluginMarketMetadata;
|
||||
isInstalled: boolean;
|
||||
hasUpdate: boolean;
|
||||
isInstalling: boolean;
|
||||
onInstall: (version?: string) => void;
|
||||
onUninstall: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function PluginMarketCard({
|
||||
plugin,
|
||||
isInstalled,
|
||||
hasUpdate,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
t
|
||||
}: PluginMarketCardProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState(plugin.latestVersion);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : Package;
|
||||
|
||||
const selectedVersionData = plugin.versions.find((v) => v.version === selectedVersion);
|
||||
const multipleVersions = plugin.versions.length > 1;
|
||||
|
||||
return (
|
||||
<div className="plugin-market-card">
|
||||
<div className="plugin-market-card-header">
|
||||
<div className="plugin-market-card-icon">
|
||||
<IconComponent size={32} />
|
||||
</div>
|
||||
<div className="plugin-market-card-info">
|
||||
<div className="plugin-market-card-title">
|
||||
<span>{plugin.name}</span>
|
||||
{plugin.verified && (
|
||||
<span className="plugin-market-badge official" title={t('official')}>
|
||||
<CheckCircle size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-market-card-meta">
|
||||
<span className="plugin-market-card-author">
|
||||
<Github size={12} />
|
||||
{plugin.author.name}
|
||||
</span>
|
||||
{multipleVersions ? (
|
||||
<select
|
||||
className="plugin-market-version-select"
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t('selectVersion')}
|
||||
>
|
||||
{plugin.versions.map((v) => (
|
||||
<option key={v.version} value={v.version}>
|
||||
v{v.version} {v.version === plugin.latestVersion ? `(${t('latest')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="plugin-market-card-version">v{plugin.latestVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-market-card-description">{plugin.description}</div>
|
||||
|
||||
{selectedVersionData && selectedVersionData.changes && (
|
||||
<details className="plugin-market-version-changes">
|
||||
<summary>{t('releaseNotes')}</summary>
|
||||
<p>{selectedVersionData.changes}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{plugin.tags && plugin.tags.length > 0 && (
|
||||
<div className="plugin-market-card-tags">
|
||||
{plugin.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="plugin-market-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="plugin-market-card-footer">
|
||||
<button
|
||||
className="plugin-market-card-link"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await open(plugin.repository.url);
|
||||
} catch (error) {
|
||||
console.error('Failed to open URL:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewSource')}
|
||||
</button>
|
||||
|
||||
<div className="plugin-market-card-actions">
|
||||
{isInstalling ? (
|
||||
<button className="plugin-market-btn installing" disabled>
|
||||
<RefreshCw size={14} className="spinning" />
|
||||
{isInstalled ? t('uninstalling') : t('installing')}
|
||||
</button>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
{hasUpdate && (
|
||||
<button className="plugin-market-btn update" onClick={() => onInstall(plugin.latestVersion)}>
|
||||
<Download size={14} />
|
||||
{t('update')}
|
||||
</button>
|
||||
)}
|
||||
<button className="plugin-market-btn installed" onClick={onUninstall}>
|
||||
<CheckCircle size={14} />
|
||||
{t('uninstall')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="plugin-market-btn install" onClick={() => onInstall(selectedVersion)}>
|
||||
<Download size={14} />
|
||||
{t('install')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import '../styles/PluginPanel.css';
|
||||
|
||||
interface PluginPanelProps {
|
||||
pluginManager: EditorPluginManager;
|
||||
}
|
||||
|
||||
const categoryIcons: Record<EditorPluginCategory, string> = {
|
||||
[EditorPluginCategory.Tool]: 'Wrench',
|
||||
[EditorPluginCategory.Window]: 'LayoutGrid',
|
||||
[EditorPluginCategory.Inspector]: 'Search',
|
||||
[EditorPluginCategory.System]: 'Settings',
|
||||
[EditorPluginCategory.ImportExport]: 'Package'
|
||||
};
|
||||
|
||||
const categoryNames: Record<EditorPluginCategory, string> = {
|
||||
[EditorPluginCategory.Tool]: 'Tools',
|
||||
[EditorPluginCategory.Window]: 'Windows',
|
||||
[EditorPluginCategory.Inspector]: 'Inspectors',
|
||||
[EditorPluginCategory.System]: 'System',
|
||||
[EditorPluginCategory.ImportExport]: 'Import/Export'
|
||||
};
|
||||
|
||||
export function PluginPanel({ pluginManager }: PluginPanelProps) {
|
||||
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<EditorPluginCategory>>(
|
||||
new Set(Object.values(EditorPluginCategory))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePlugins = () => {
|
||||
const allPlugins = pluginManager.getAllPluginMetadata();
|
||||
setPlugins(allPlugins);
|
||||
};
|
||||
|
||||
updatePlugins();
|
||||
}, [pluginManager]);
|
||||
|
||||
const togglePlugin = async (name: string, enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await pluginManager.disablePlugin(name);
|
||||
} else {
|
||||
await pluginManager.enablePlugin(name);
|
||||
}
|
||||
const allPlugins = pluginManager.getAllPluginMetadata();
|
||||
setPlugins(allPlugins);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle plugin ${name}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: EditorPluginCategory) => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(category)) {
|
||||
newExpanded.delete(category);
|
||||
} else {
|
||||
newExpanded.add(category);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const filteredPlugins = plugins.filter((plugin) => {
|
||||
if (!filter) return true;
|
||||
const searchLower = filter.toLowerCase();
|
||||
return (
|
||||
plugin.name.toLowerCase().includes(searchLower) ||
|
||||
plugin.displayName.toLowerCase().includes(searchLower) ||
|
||||
plugin.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const pluginsByCategory = filteredPlugins.reduce((acc, plugin) => {
|
||||
if (!acc[plugin.category]) {
|
||||
acc[plugin.category] = [];
|
||||
}
|
||||
acc[plugin.category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const disabledCount = plugins.filter((p) => !p.enabled).length;
|
||||
|
||||
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-card-header">
|
||||
<div className="plugin-card-icon">
|
||||
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
|
||||
</div>
|
||||
<div className="plugin-card-info">
|
||||
<div className="plugin-card-title">{plugin.displayName}</div>
|
||||
<div className="plugin-card-version">v{plugin.version}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-card-description">{plugin.description}</div>
|
||||
)}
|
||||
<div className="plugin-card-footer">
|
||||
<span className="plugin-card-category">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
|
||||
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
|
||||
})()}
|
||||
{categoryNames[plugin.category]}
|
||||
</span>
|
||||
{plugin.installedAt && (
|
||||
<span className="plugin-card-installed">
|
||||
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginList = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-list-icon">
|
||||
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
|
||||
</div>
|
||||
<div className="plugin-list-info">
|
||||
<div className="plugin-list-name">
|
||||
{plugin.displayName}
|
||||
<span className="plugin-list-version">v{plugin.version}</span>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-list-description">{plugin.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-list-status">
|
||||
{plugin.enabled ? (
|
||||
<span className="status-badge enabled">Enabled</span>
|
||||
) : (
|
||||
<span className="status-badge disabled">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="plugin-list-toggle"
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-panel">
|
||||
<div className="plugin-toolbar">
|
||||
<div className="plugin-toolbar-left">
|
||||
<div className="plugin-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search plugins..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-toolbar-right">
|
||||
<div className="plugin-stats">
|
||||
<span className="stat-item enabled">
|
||||
<CheckCircle size={14} />
|
||||
{enabledCount} enabled
|
||||
</span>
|
||||
<span className="stat-item disabled">
|
||||
<XCircle size={14} />
|
||||
{disabledCount} disabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="plugin-view-mode">
|
||||
<button
|
||||
className={viewMode === 'list' ? 'active' : ''}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List view"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'grid' ? 'active' : ''}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid view"
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-content">
|
||||
{plugins.length === 0 ? (
|
||||
<div className="plugin-empty">
|
||||
<Package size={48} />
|
||||
<p>No plugins installed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-categories">
|
||||
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
|
||||
const cat = category as EditorPluginCategory;
|
||||
const isExpanded = expandedCategories.has(cat);
|
||||
|
||||
return (
|
||||
<div key={category} className="plugin-category">
|
||||
<div
|
||||
className="plugin-category-header"
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
<button className="plugin-category-toggle">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<span className="plugin-category-icon">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
|
||||
return CategoryIcon ? <CategoryIcon size={16} /> : null;
|
||||
})()}
|
||||
</span>
|
||||
<span className="plugin-category-name">{categoryNames[cat]}</span>
|
||||
<span className="plugin-category-count">
|
||||
{categoryPlugins.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={`plugin-category-content ${viewMode}`}>
|
||||
{viewMode === 'grid'
|
||||
? categoryPlugins.map(renderPluginCard)
|
||||
: categoryPlugins.map(renderPluginList)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,948 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { X, AlertCircle, CheckCircle, Loader, ExternalLink, FolderOpen, FileArchive } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import { PluginPublishService, type PluginPublishInfo, type PublishProgress } from '../services/PluginPublishService';
|
||||
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
|
||||
import { PluginSourceParser, type ParsedPluginInfo } from '../services/PluginSourceParser';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { EditorPluginCategory, type IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
import '../styles/PluginPublishWizard.css';
|
||||
|
||||
interface PluginPublishWizardProps {
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
locale: string;
|
||||
inline?: boolean; // 是否内联显示(在 tab 中)而不是弹窗
|
||||
}
|
||||
|
||||
type Step = 'auth' | 'selectSource' | 'info' | 'building' | 'confirm' | 'publishing' | 'success' | 'error';
|
||||
|
||||
type SourceType = 'folder' | 'zip';
|
||||
|
||||
function calculateNextVersion(currentVersion: string): string {
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
|
||||
|
||||
const [major, minor, patch] = parts;
|
||||
return `${major}.${minor}.${(patch ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export function PluginPublishWizard({ githubService, onClose, locale, inline = false }: PluginPublishWizardProps) {
|
||||
const [publishService] = useState(() => new PluginPublishService(githubService));
|
||||
const [buildService] = useState(() => new PluginBuildService());
|
||||
const [sourceParser] = useState(() => new PluginSourceParser());
|
||||
|
||||
const [step, setStep] = useState<Step>(githubService.isAuthenticated() ? 'selectSource' : 'auth');
|
||||
const [sourceType, setSourceType] = useState<SourceType | null>(null);
|
||||
const [parsedPluginInfo, setParsedPluginInfo] = useState<ParsedPluginInfo | null>(null);
|
||||
const [publishInfo, setPublishInfo] = useState<Partial<PluginPublishInfo>>({
|
||||
category: 'community',
|
||||
tags: []
|
||||
});
|
||||
const [prUrl, setPrUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
|
||||
const [builtZipPath, setBuiltZipPath] = useState<string>('');
|
||||
const [existingPR, setExistingPR] = useState<{ number: number; url: string } | null>(null);
|
||||
const [existingVersions, setExistingVersions] = useState<string[]>([]);
|
||||
const [suggestedVersion, setSuggestedVersion] = useState<string>('');
|
||||
const [existingManifest, setExistingManifest] = useState<any>(null);
|
||||
const [isUpdate, setIsUpdate] = useState(false);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '发布插件到市场',
|
||||
updateTitle: '更新插件版本',
|
||||
stepAuth: '步骤 1: GitHub 登录',
|
||||
stepSelectSource: '步骤 2: 选择插件源',
|
||||
stepInfo: '步骤 3: 插件信息',
|
||||
stepInfoUpdate: '步骤 3: 版本更新',
|
||||
stepBuilding: '步骤 4: 构建打包',
|
||||
stepConfirm: '步骤 5: 确认发布',
|
||||
stepConfirmNoBuilding: '步骤 4: 确认发布',
|
||||
githubLogin: 'GitHub 登录',
|
||||
oauthLogin: 'OAuth 登录(推荐)',
|
||||
tokenLogin: 'Token 登录',
|
||||
oauthInstructions: '点击下方按钮开始授权:',
|
||||
oauthStep1: '1. 点击"开始授权"按钮',
|
||||
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
|
||||
oauthStep3: '3. 输入下方显示的代码并授权',
|
||||
oauthStep4: '4. 授权完成后会自动跳转',
|
||||
startAuth: '开始授权',
|
||||
authorizing: '等待授权中...',
|
||||
authorized: '授权成功!',
|
||||
authFailed: '授权失败',
|
||||
userCode: '授权码',
|
||||
copyCode: '复制代码',
|
||||
openBrowser: '打开浏览器',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: '粘贴你的 GitHub Token',
|
||||
tokenHint: '需要 repo 和 workflow 权限',
|
||||
createToken: '创建 Token',
|
||||
login: '登录',
|
||||
switchToToken: '使用 Token 登录',
|
||||
switchToOAuth: '使用 OAuth 登录',
|
||||
selectSource: '选择插件源',
|
||||
selectSourceDesc: '选择插件的来源类型',
|
||||
selectFolder: '选择源代码文件夹',
|
||||
selectFolderDesc: '选择包含你的插件源代码的文件夹(需要有 package.json,系统将自动构建)',
|
||||
selectZip: '选择 ZIP 文件',
|
||||
selectZipDesc: '选择已构建好的插件 ZIP 包(必须包含 package.json 和 dist 目录)',
|
||||
zipRequirements: 'ZIP 文件要求',
|
||||
zipStructure: 'ZIP 结构',
|
||||
zipStructureDetails: 'ZIP 文件必须包含以下内容:',
|
||||
zipFile1: 'package.json - 插件元数据',
|
||||
zipFile2: 'dist/ - 构建后的代码目录(包含 index.esm.js)',
|
||||
zipExample: '示例结构',
|
||||
zipBuildScript: '打包脚本',
|
||||
zipBuildScriptDesc: '可以使用以下命令打包:',
|
||||
recommendFolder: '💡 建议使用"源代码文件夹"方式,系统会自动构建',
|
||||
browseFolder: '浏览文件夹',
|
||||
browseZip: '浏览 ZIP 文件',
|
||||
selectedFolder: '已选择文件夹',
|
||||
selectedZip: '已选择 ZIP',
|
||||
sourceTypeFolder: '源代码文件夹',
|
||||
sourceTypeZip: 'ZIP 文件',
|
||||
pluginInfo: '插件信息',
|
||||
version: '版本号',
|
||||
currentVersion: '当前版本',
|
||||
suggestedVersion: '建议版本',
|
||||
versionHistory: '版本历史',
|
||||
updatePlugin: '更新插件',
|
||||
newPlugin: '新插件',
|
||||
category: '分类',
|
||||
official: '官方',
|
||||
community: '社区',
|
||||
repositoryUrl: '仓库地址',
|
||||
repositoryPlaceholder: 'https://github.com/username/repo',
|
||||
releaseNotes: '更新说明',
|
||||
releaseNotesPlaceholder: '描述这个版本的变更...',
|
||||
tags: '标签(逗号分隔)',
|
||||
tagsPlaceholder: 'ui, tool, editor',
|
||||
homepage: '主页 URL(可选)',
|
||||
next: '下一步',
|
||||
back: '上一步',
|
||||
build: '构建并打包',
|
||||
building: '构建中...',
|
||||
confirm: '确认发布',
|
||||
publishing: '发布中...',
|
||||
publishSuccess: '发布成功!',
|
||||
publishError: '发布失败',
|
||||
buildError: '构建失败',
|
||||
prCreated: 'Pull Request 已创建',
|
||||
viewPR: '查看 PR',
|
||||
close: '关闭',
|
||||
buildingStep1: '正在安装依赖...',
|
||||
buildingStep2: '正在构建项目...',
|
||||
buildingStep3: '正在打包 ZIP...',
|
||||
publishingStep1: '正在 Fork 仓库...',
|
||||
publishingStep2: '正在创建分支...',
|
||||
publishingStep3: '正在上传文件...',
|
||||
publishingStep4: '正在创建 Pull Request...',
|
||||
confirmMessage: '确认要发布以下插件?',
|
||||
reviewMessage: '你的插件提交已创建 PR,维护者将进行审核。审核通过后,插件将自动发布到市场。',
|
||||
existingPRDetected: '检测到现有 PR',
|
||||
existingPRMessage: '该插件已有待审核的 PR #{{number}}。点击"确认"将更新现有 PR(不会创建新的 PR)。',
|
||||
viewExistingPR: '查看现有 PR'
|
||||
},
|
||||
en: {
|
||||
title: 'Publish Plugin to Marketplace',
|
||||
updateTitle: 'Update Plugin Version',
|
||||
stepAuth: 'Step 1: GitHub Authentication',
|
||||
stepSelectSource: 'Step 2: Select Plugin Source',
|
||||
stepInfo: 'Step 3: Plugin Information',
|
||||
stepInfoUpdate: 'Step 3: Version Update',
|
||||
stepBuilding: 'Step 4: Build & Package',
|
||||
stepConfirm: 'Step 5: Confirm Publication',
|
||||
stepConfirmNoBuilding: 'Step 4: Confirm Publication',
|
||||
githubLogin: 'GitHub Login',
|
||||
oauthLogin: 'OAuth Login (Recommended)',
|
||||
tokenLogin: 'Token Login',
|
||||
oauthInstructions: 'Click the button below to start authorization:',
|
||||
oauthStep1: '1. Click "Start Authorization"',
|
||||
oauthStep2: '2. Open GitHub authorization page in browser',
|
||||
oauthStep3: '3. Enter the code shown below and authorize',
|
||||
oauthStep4: '4. Authorization will complete automatically',
|
||||
startAuth: 'Start Authorization',
|
||||
authorizing: 'Waiting for authorization...',
|
||||
authorized: 'Authorized!',
|
||||
authFailed: 'Authorization failed',
|
||||
userCode: 'Authorization Code',
|
||||
copyCode: 'Copy Code',
|
||||
openBrowser: 'Open Browser',
|
||||
tokenLabel: 'GitHub Personal Access Token',
|
||||
tokenPlaceholder: 'Paste your GitHub Token',
|
||||
tokenHint: 'Requires repo and workflow permissions',
|
||||
createToken: 'Create Token',
|
||||
login: 'Login',
|
||||
switchToToken: 'Use Token Login',
|
||||
switchToOAuth: 'Use OAuth Login',
|
||||
selectSource: 'Select Plugin Source',
|
||||
selectSourceDesc: 'Choose the plugin source type',
|
||||
selectFolder: 'Select Source Folder',
|
||||
selectFolderDesc: 'Select the folder containing your plugin source code (must have package.json, will be built automatically)',
|
||||
selectZip: 'Select ZIP File',
|
||||
selectZipDesc: 'Select a pre-built plugin ZIP package (must contain package.json and dist directory)',
|
||||
zipRequirements: 'ZIP File Requirements',
|
||||
zipStructure: 'ZIP Structure',
|
||||
zipStructureDetails: 'The ZIP file must contain:',
|
||||
zipFile1: 'package.json - Plugin metadata',
|
||||
zipFile2: 'dist/ - Built code directory (with index.esm.js)',
|
||||
zipExample: 'Example Structure',
|
||||
zipBuildScript: 'Build Script',
|
||||
zipBuildScriptDesc: 'You can use the following commands to package:',
|
||||
recommendFolder: '💡 Recommended: Use "Source Folder" mode for automatic build',
|
||||
browseFolder: 'Browse Folder',
|
||||
browseZip: 'Browse ZIP File',
|
||||
selectedFolder: 'Selected Folder',
|
||||
selectedZip: 'Selected ZIP',
|
||||
sourceTypeFolder: 'Source Folder',
|
||||
sourceTypeZip: 'ZIP File',
|
||||
pluginInfo: 'Plugin Information',
|
||||
version: 'Version',
|
||||
currentVersion: 'Current Version',
|
||||
suggestedVersion: 'Suggested Version',
|
||||
versionHistory: 'Version History',
|
||||
updatePlugin: 'Update Plugin',
|
||||
newPlugin: 'New Plugin',
|
||||
category: 'Category',
|
||||
official: 'Official',
|
||||
community: 'Community',
|
||||
repositoryUrl: 'Repository URL',
|
||||
repositoryPlaceholder: 'https://github.com/username/repo',
|
||||
releaseNotes: 'Release Notes',
|
||||
releaseNotesPlaceholder: 'Describe the changes in this version...',
|
||||
tags: 'Tags (comma separated)',
|
||||
tagsPlaceholder: 'ui, tool, editor',
|
||||
homepage: 'Homepage URL (optional)',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
build: 'Build & Package',
|
||||
building: 'Building...',
|
||||
confirm: 'Confirm & Publish',
|
||||
publishing: 'Publishing...',
|
||||
publishSuccess: 'Published Successfully!',
|
||||
publishError: 'Publication Failed',
|
||||
buildError: 'Build Failed',
|
||||
prCreated: 'Pull Request Created',
|
||||
viewPR: 'View PR',
|
||||
close: 'Close',
|
||||
buildingStep1: 'Installing dependencies...',
|
||||
buildingStep2: 'Building project...',
|
||||
buildingStep3: 'Packaging ZIP...',
|
||||
publishingStep1: 'Forking repository...',
|
||||
publishingStep2: 'Creating branch...',
|
||||
publishingStep3: 'Uploading files...',
|
||||
publishingStep4: 'Creating Pull Request...',
|
||||
confirmMessage: 'Confirm publishing this plugin?',
|
||||
reviewMessage:
|
||||
'Your plugin submission has been created as a PR. Maintainers will review it. Once approved, the plugin will be published to the marketplace.',
|
||||
existingPRDetected: 'Existing PR Detected',
|
||||
existingPRMessage: 'This plugin already has a pending PR #{{number}}. Clicking "Confirm" will update the existing PR (no new PR will be created).',
|
||||
viewExistingPR: 'View Existing PR'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setStep('selectSource');
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择并解析插件源(文件夹或 ZIP)
|
||||
* 统一处理逻辑,避免代码重复
|
||||
*/
|
||||
const handleSelectSource = async (type: SourceType) => {
|
||||
setError('');
|
||||
setSourceType(type);
|
||||
|
||||
try {
|
||||
let parsedInfo: ParsedPluginInfo;
|
||||
|
||||
if (type === 'folder') {
|
||||
// 选择文件夹
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('selectFolder')
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
// 使用 PluginSourceParser 解析文件夹
|
||||
parsedInfo = await sourceParser.parseFromFolder(selected as string);
|
||||
} else {
|
||||
// 选择 ZIP 文件
|
||||
const selected = await openDialog({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: t('selectZip'),
|
||||
filters: [
|
||||
{
|
||||
name: 'ZIP Files',
|
||||
extensions: ['zip']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
// 使用 PluginSourceParser 解析 ZIP
|
||||
parsedInfo = await sourceParser.parseFromZip(selected as string);
|
||||
}
|
||||
|
||||
// 验证 package.json
|
||||
sourceParser.validatePackageJson(parsedInfo.packageJson);
|
||||
|
||||
setParsedPluginInfo(parsedInfo);
|
||||
|
||||
// 检测已发布的版本
|
||||
await checkExistingVersions(parsedInfo.packageJson);
|
||||
|
||||
// 检测是否已有待审核的 PR
|
||||
await checkExistingPR(parsedInfo.packageJson);
|
||||
|
||||
// 进入下一步
|
||||
setStep('info');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Failed to parse plugin source:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse plugin source');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测插件是否已发布,获取版本信息
|
||||
*/
|
||||
const checkExistingVersions = async (packageJson: { name: string; version: string }) => {
|
||||
try {
|
||||
const pluginId = sourceParser.generatePluginId(packageJson.name);
|
||||
const manifestContent = await githubService.getFileContent(
|
||||
'esengine',
|
||||
'ecs-editor-plugins',
|
||||
`plugins/community/${pluginId}/manifest.json`,
|
||||
'main'
|
||||
);
|
||||
const manifest = JSON.parse(manifestContent);
|
||||
|
||||
if (Array.isArray(manifest.versions)) {
|
||||
const versions = manifest.versions.map((v: any) => v.version);
|
||||
setExistingVersions(versions);
|
||||
setExistingManifest(manifest);
|
||||
setIsUpdate(true);
|
||||
|
||||
// 计算建议版本号
|
||||
const latestVersion = manifest.latestVersion || versions[0];
|
||||
const suggested = calculateNextVersion(latestVersion);
|
||||
setSuggestedVersion(suggested);
|
||||
|
||||
// 更新模式:自动填充现有信息
|
||||
setPublishInfo((prev) => ({
|
||||
...prev,
|
||||
version: suggested,
|
||||
repositoryUrl: manifest.repository?.url || '',
|
||||
category: manifest.category_type || 'community',
|
||||
tags: manifest.tags || [],
|
||||
homepage: manifest.homepage
|
||||
}));
|
||||
} else {
|
||||
// 首次发布
|
||||
resetToNewPlugin(packageJson.version);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[PluginPublishWizard] No existing versions found, this is a new plugin');
|
||||
resetToNewPlugin(packageJson.version);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置为新插件状态
|
||||
*/
|
||||
const resetToNewPlugin = (version: string) => {
|
||||
setExistingVersions([]);
|
||||
setExistingManifest(null);
|
||||
setIsUpdate(false);
|
||||
setPublishInfo((prev) => ({
|
||||
...prev,
|
||||
version
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测是否已有待审核的 PR
|
||||
*/
|
||||
const checkExistingPR = async (packageJson: { name: string; version: string }) => {
|
||||
try {
|
||||
const user = githubService.getUser();
|
||||
if (user) {
|
||||
const branchName = `add-plugin-${packageJson.name}-v${packageJson.version}`;
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
const pr = await githubService.findPullRequestByBranch('esengine', 'ecs-editor-plugins', headBranch);
|
||||
if (pr) {
|
||||
setExistingPR({ number: pr.number, url: pr.html_url });
|
||||
} else {
|
||||
setExistingPR(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[PluginPublishWizard] Failed to check existing PR:', err);
|
||||
setExistingPR(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从信息填写步骤进入下一步
|
||||
* - 如果是 ZIP,直接跳到确认发布
|
||||
* - 如果是文件夹,需要先构建
|
||||
*/
|
||||
const handleNext = () => {
|
||||
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsedPluginInfo) {
|
||||
setError('Plugin source not selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP 文件已经构建好,直接跳到确认步骤
|
||||
if (parsedPluginInfo.sourceType === 'zip' && parsedPluginInfo.zipPath) {
|
||||
setBuiltZipPath(parsedPluginInfo.zipPath);
|
||||
setStep('confirm');
|
||||
} else {
|
||||
// 文件夹需要构建
|
||||
setStep('building');
|
||||
handleBuild();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建插件(仅对文件夹源有效)
|
||||
*/
|
||||
const handleBuild = async () => {
|
||||
if (!parsedPluginInfo || parsedPluginInfo.sourceType !== 'folder') {
|
||||
setError('Cannot build: plugin source is not a folder');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBuildLog([]);
|
||||
setBuildProgress(null);
|
||||
setError('');
|
||||
|
||||
buildService.setProgressCallback((progress) => {
|
||||
console.log('[PluginPublishWizard] Build progress:', progress);
|
||||
setBuildProgress(progress);
|
||||
|
||||
if (progress.step === 'install') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep1')) {
|
||||
return [...prev, t('buildingStep1')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'build') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep2')) {
|
||||
return [...prev, t('buildingStep2')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'package') {
|
||||
setBuildLog((prev) => {
|
||||
if (prev[prev.length - 1] !== t('buildingStep3')) {
|
||||
return [...prev, t('buildingStep3')];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (progress.step === 'complete') {
|
||||
setBuildLog((prev) => [...prev, t('buildComplete')]);
|
||||
}
|
||||
|
||||
if (progress.output) {
|
||||
console.log('[Build output]', progress.output);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const zipPath = await buildService.buildPlugin(parsedPluginInfo.sourcePath);
|
||||
console.log('[PluginPublishWizard] Build completed, ZIP at:', zipPath);
|
||||
setBuiltZipPath(zipPath);
|
||||
setStep('confirm');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Build failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布插件到市场
|
||||
*/
|
||||
const handlePublish = async () => {
|
||||
setStep('publishing');
|
||||
setError('');
|
||||
setPublishProgress(null);
|
||||
|
||||
// 设置进度回调
|
||||
publishService.setProgressCallback((progress) => {
|
||||
setPublishProgress(progress);
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证必填字段
|
||||
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
|
||||
throw new Error('Missing required fields');
|
||||
}
|
||||
|
||||
// 验证插件源
|
||||
if (!parsedPluginInfo) {
|
||||
throw new Error('Plugin source not selected');
|
||||
}
|
||||
|
||||
// 验证 ZIP 路径
|
||||
if (!builtZipPath) {
|
||||
throw new Error('Plugin ZIP file not available');
|
||||
}
|
||||
|
||||
const { packageJson } = parsedPluginInfo;
|
||||
|
||||
const pluginMetadata: IEditorPluginMetadata = {
|
||||
name: packageJson.name,
|
||||
displayName: packageJson.description || packageJson.name,
|
||||
description: packageJson.description || '',
|
||||
version: packageJson.version,
|
||||
category: EditorPluginCategory.Tool,
|
||||
icon: 'Package',
|
||||
enabled: true,
|
||||
installedAt: Date.now()
|
||||
};
|
||||
|
||||
const fullPublishInfo: PluginPublishInfo = {
|
||||
pluginMetadata,
|
||||
version: publishInfo.version || packageJson.version,
|
||||
releaseNotes: publishInfo.releaseNotes || '',
|
||||
repositoryUrl: publishInfo.repositoryUrl || '',
|
||||
category: publishInfo.category || 'community',
|
||||
tags: publishInfo.tags,
|
||||
homepage: publishInfo.homepage,
|
||||
screenshots: publishInfo.screenshots
|
||||
};
|
||||
|
||||
console.log('[PluginPublishWizard] Publishing with info:', fullPublishInfo);
|
||||
console.log('[PluginPublishWizard] Built ZIP path:', builtZipPath);
|
||||
|
||||
const prUrl = await publishService.publishPlugin(fullPublishInfo, builtZipPath);
|
||||
setPrUrl(prUrl);
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
console.error('[PluginPublishWizard] Publish failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const openPR = async () => {
|
||||
if (prUrl) {
|
||||
await open(prUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const wizardContent = (
|
||||
<div className={inline ? 'plugin-publish-wizard inline' : 'plugin-publish-wizard'} onClick={(e) => inline ? undefined : e.stopPropagation()}>
|
||||
<div className="plugin-publish-header">
|
||||
<h2>{t('title')}</h2>
|
||||
{!inline && (
|
||||
<button className="plugin-publish-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="plugin-publish-content">
|
||||
{step === 'auth' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepAuth')}</h3>
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={handleAuthSuccess}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'selectSource' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepSelectSource')}</h3>
|
||||
<p>{t('selectSourceDesc')}</p>
|
||||
|
||||
<div className="source-type-selection">
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('folder')}
|
||||
>
|
||||
<FolderOpen size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeFolder')}</strong>
|
||||
<p>{t('selectFolderDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('zip')}
|
||||
>
|
||||
<FileArchive size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeZip')}</strong>
|
||||
<p>{t('selectZipDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ZIP 文件要求说明 */}
|
||||
<details className="zip-requirements-details">
|
||||
<summary>
|
||||
<AlertCircle size={16} />
|
||||
{t('zipRequirements')}
|
||||
</summary>
|
||||
<div className="zip-requirements-content">
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipStructure')}</h4>
|
||||
<p>{t('zipStructureDetails')}</p>
|
||||
<ul>
|
||||
<li><code>package.json</code> - {t('zipFile1')}</li>
|
||||
<li><code>dist/</code> - {t('zipFile2')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipBuildScript')}</h4>
|
||||
<p>{t('zipBuildScriptDesc')}</p>
|
||||
<pre className="build-script-example">
|
||||
{`npm install
|
||||
npm run build
|
||||
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
|
||||
# ZIP 结构:
|
||||
# plugin.zip
|
||||
# ├── package.json
|
||||
# └── dist/
|
||||
# └── index.esm.js`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="recommendation-notice">
|
||||
{t('recommendFolder')}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="selected-source">
|
||||
{parsedPluginInfo.sourceType === 'folder' ? (
|
||||
<FolderOpen size={20} />
|
||||
) : (
|
||||
<FileArchive size={20} />
|
||||
)}
|
||||
<div className="source-details">
|
||||
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
|
||||
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('auth')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'info' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepInfo')}</h3>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('version')} *</label>
|
||||
{isUpdate && (
|
||||
<div className="version-info">
|
||||
<div className="version-notice">
|
||||
<CheckCircle size={16} />
|
||||
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
|
||||
</div>
|
||||
{suggestedVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-version-suggest"
|
||||
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
|
||||
>
|
||||
{t('suggestedVersion')}: {suggestedVersion}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.version || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
{isUpdate && (
|
||||
<details className="version-history">
|
||||
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
|
||||
<ul>
|
||||
{existingVersions.map((v) => (
|
||||
<li key={v}>v{v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={publishInfo.releaseNotes || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isUpdate && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>{t('category')} *</label>
|
||||
<select
|
||||
value={publishInfo.category}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
|
||||
}
|
||||
>
|
||||
<option value="community">{t('community')}</option>
|
||||
<option value="official">{t('official')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('repositoryUrl')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.repositoryUrl || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
|
||||
placeholder={t('repositoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('tags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.tags?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({
|
||||
...publishInfo,
|
||||
tags: e.target.value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
placeholder={t('tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleNext}>
|
||||
{sourceType === 'zip' ? t('next') : t('build')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'building' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('building')}</h3>
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, i) => (
|
||||
<div key={i} className="log-line">
|
||||
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
|
||||
<span>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepConfirm')}</h3>
|
||||
|
||||
<p>{t('confirmMessage')}</p>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('selectSource')}:</span>
|
||||
<span className="detail-value">
|
||||
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('version')}:</span>
|
||||
<span className="detail-value">{publishInfo.version}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('category')}:</span>
|
||||
<span className="detail-value">{t(publishInfo.category!)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('repositoryUrl')}:</span>
|
||||
<span className="detail-value">{publishInfo.repositoryUrl}</span>
|
||||
</div>
|
||||
{builtZipPath && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Package Path:</span>
|
||||
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{builtZipPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handlePublish}>
|
||||
{t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'publishing' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="publish-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${publishProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
<p className="progress-percent">{publishProgress.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="publish-step success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h3>{t('publishSuccess')}</h3>
|
||||
<p>{t('prCreated')}</p>
|
||||
<p className="review-message">{t('reviewMessage')}</p>
|
||||
|
||||
<button className="btn-link" onClick={openPR}>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewPR')}
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="publish-step error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h3>{t('publishError')}</h3>
|
||||
<p>{error}</p>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return inline ? wizardContent : (
|
||||
<div className="plugin-publish-overlay" onClick={onClose}>
|
||||
{wizardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { X, FolderOpen, Loader, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import type { GitHubService, PublishedPlugin } from '../services/GitHubService';
|
||||
import { PluginPublishService, type PublishProgress } from '../services/PluginPublishService';
|
||||
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { EditorPluginCategory } from '@esengine/editor-core';
|
||||
import type { IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
import '../styles/PluginUpdateDialog.css';
|
||||
|
||||
interface PluginUpdateDialogProps {
|
||||
plugin: PublishedPlugin;
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
type Step = 'selectFolder' | 'info' | 'building' | 'publishing' | 'success' | 'error';
|
||||
|
||||
function calculateNextVersion(currentVersion: string): string {
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
|
||||
|
||||
const [major, minor, patch] = parts;
|
||||
return `${major}.${minor}.${(patch ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export function PluginUpdateDialog({ plugin, githubService, onClose, onSuccess, locale }: PluginUpdateDialogProps) {
|
||||
const [publishService] = useState(() => new PluginPublishService(githubService));
|
||||
const [buildService] = useState(() => new PluginBuildService());
|
||||
|
||||
const [step, setStep] = useState<Step>('selectFolder');
|
||||
const [pluginFolder, setPluginFolder] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [suggestedVersion] = useState(() => calculateNextVersion(plugin.latestVersion));
|
||||
const [error, setError] = useState('');
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
|
||||
const [prUrl, setPrUrl] = useState('');
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '更新插件',
|
||||
currentVersion: '当前版本',
|
||||
newVersion: '新版本号',
|
||||
useSuggested: '使用建议版本',
|
||||
releaseNotes: '更新说明',
|
||||
releaseNotesPlaceholder: '描述这个版本的变更...',
|
||||
selectFolder: '选择插件文件夹',
|
||||
selectFolderDesc: '选择包含更新后插件源代码的文件夹',
|
||||
browseFolder: '浏览文件夹',
|
||||
selectedFolder: '已选择文件夹',
|
||||
next: '下一步',
|
||||
back: '上一步',
|
||||
buildAndPublish: '构建并发布',
|
||||
building: '构建中...',
|
||||
publishing: '发布中...',
|
||||
success: '更新成功!',
|
||||
error: '更新失败',
|
||||
viewPR: '查看 PR',
|
||||
close: '关闭',
|
||||
buildError: '构建失败',
|
||||
publishError: '发布失败',
|
||||
buildingStep1: '正在安装依赖...',
|
||||
buildingStep2: '正在构建项目...',
|
||||
buildingStep3: '正在打包 ZIP...',
|
||||
publishingStep1: '正在 Fork 仓库...',
|
||||
publishingStep2: '正在创建分支...',
|
||||
publishingStep3: '正在上传文件...',
|
||||
publishingStep4: '正在创建 Pull Request...',
|
||||
reviewMessage: '你的插件更新已创建 PR,维护者将进行审核。审核通过后,新版本将自动发布到市场。'
|
||||
},
|
||||
en: {
|
||||
title: 'Update Plugin',
|
||||
currentVersion: 'Current Version',
|
||||
newVersion: 'New Version',
|
||||
useSuggested: 'Use Suggested',
|
||||
releaseNotes: 'Release Notes',
|
||||
releaseNotesPlaceholder: 'Describe the changes in this version...',
|
||||
selectFolder: 'Select Plugin Folder',
|
||||
selectFolderDesc: 'Select the folder containing your updated plugin source code',
|
||||
browseFolder: 'Browse Folder',
|
||||
selectedFolder: 'Selected Folder',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
buildAndPublish: 'Build & Publish',
|
||||
building: 'Building...',
|
||||
publishing: 'Publishing...',
|
||||
success: 'Update Successful!',
|
||||
error: 'Update Failed',
|
||||
viewPR: 'View PR',
|
||||
close: 'Close',
|
||||
buildError: 'Build Failed',
|
||||
publishError: 'Publish Failed',
|
||||
buildingStep1: 'Installing dependencies...',
|
||||
buildingStep2: 'Building project...',
|
||||
buildingStep3: 'Packaging ZIP...',
|
||||
publishingStep1: 'Forking repository...',
|
||||
publishingStep2: 'Creating branch...',
|
||||
publishingStep3: 'Uploading files...',
|
||||
publishingStep4: 'Creating Pull Request...',
|
||||
reviewMessage: 'Your plugin update has been created as a PR. Maintainers will review it. Once approved, the new version will be published to the marketplace.'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('selectFolder')
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
setPluginFolder(selected as string);
|
||||
setStep('info');
|
||||
} catch (err) {
|
||||
console.error('[PluginUpdateDialog] Failed to select folder:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to select folder');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildAndPublish = async () => {
|
||||
if (!version || !releaseNotes) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('building');
|
||||
setBuildLog([]);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
buildService.setProgressCallback((progress) => {
|
||||
setBuildProgress(progress);
|
||||
if (progress.output) {
|
||||
setBuildLog((prev) => [...prev, progress.output!]);
|
||||
}
|
||||
});
|
||||
|
||||
const zipPath = await buildService.buildPlugin(pluginFolder);
|
||||
console.log('[PluginUpdateDialog] Build completed:', zipPath);
|
||||
|
||||
setStep('publishing');
|
||||
publishService.setProgressCallback((progress) => {
|
||||
setPublishProgress(progress);
|
||||
});
|
||||
|
||||
const { readTextFile } = await import('@tauri-apps/plugin-fs');
|
||||
const packageJsonPath = `${pluginFolder}/package.json`;
|
||||
const packageJsonContent = await readTextFile(packageJsonPath);
|
||||
const pkgJson = JSON.parse(packageJsonContent);
|
||||
|
||||
const pluginMetadata: IEditorPluginMetadata = {
|
||||
name: pkgJson.name,
|
||||
displayName: pkgJson.description || pkgJson.name,
|
||||
description: pkgJson.description || '',
|
||||
version: pkgJson.version,
|
||||
category: EditorPluginCategory.Tool,
|
||||
icon: 'Package',
|
||||
enabled: true,
|
||||
installedAt: Date.now()
|
||||
};
|
||||
|
||||
const publishInfo = {
|
||||
pluginMetadata,
|
||||
version,
|
||||
releaseNotes,
|
||||
category: plugin.category_type as 'official' | 'community',
|
||||
repositoryUrl: plugin.repositoryUrl || '',
|
||||
tags: []
|
||||
};
|
||||
|
||||
const prUrl = await publishService.publishPlugin(publishInfo, zipPath);
|
||||
|
||||
console.log('[PluginUpdateDialog] Update published:', prUrl);
|
||||
setPrUrl(prUrl);
|
||||
setStep('success');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[PluginUpdateDialog] Failed to update plugin:', err);
|
||||
setError(err instanceof Error ? err.message : 'Update failed');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'selectFolder':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('selectFolder')}</h3>
|
||||
<p className="step-description">{t('selectFolderDesc')}</p>
|
||||
<button className="btn-browse" onClick={handleSelectFolder}>
|
||||
<FolderOpen size={16} />
|
||||
{t('browseFolder')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'info':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<div className="current-plugin-info">
|
||||
<h4>{plugin.name}</h4>
|
||||
<p>
|
||||
{t('currentVersion')}: <strong>v{plugin.latestVersion}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginFolder && (
|
||||
<div className="selected-folder-info">
|
||||
<FolderOpen size={16} />
|
||||
<span>{pluginFolder}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('newVersion')} *</label>
|
||||
<div className="version-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder={suggestedVersion}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-suggest"
|
||||
onClick={() => setVersion(suggestedVersion)}
|
||||
>
|
||||
{t('useSuggested')} ({suggestedVersion})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={releaseNotes}
|
||||
onChange={(e) => setReleaseNotes(e.target.value)}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="update-dialog-actions">
|
||||
<button className="btn-back" onClick={() => setStep('selectFolder')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleBuildAndPublish}
|
||||
disabled={!version || !releaseNotes}
|
||||
>
|
||||
{t('buildAndPublish')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'building':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('building')}</h3>
|
||||
{buildProgress && (
|
||||
<div className="progress-container">
|
||||
<p className="progress-message">{buildProgress.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{buildLog.length > 0 && (
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, index) => (
|
||||
<div key={index} className="log-line">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'publishing':
|
||||
return (
|
||||
<div className="update-dialog-step">
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="progress-container">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${publishProgress.progress}%` }} />
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<div className="update-dialog-step success-step">
|
||||
<CheckCircle size={64} className="success-icon" />
|
||||
<h3>{t('success')}</h3>
|
||||
<p className="success-message">{t('reviewMessage')}</p>
|
||||
{prUrl && (
|
||||
<button className="btn-view-pr" onClick={() => open(prUrl)}>
|
||||
{t('viewPR')}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="update-dialog-step error-step">
|
||||
<AlertCircle size={64} className="error-icon" />
|
||||
<h3>{t('error')}</h3>
|
||||
<p className="error-message">{error}</p>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-update-dialog-overlay">
|
||||
<div className="plugin-update-dialog">
|
||||
<div className="update-dialog-header">
|
||||
<h2>{t('title')}: {plugin.name}</h2>
|
||||
<button className="update-dialog-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="update-dialog-content">{renderStepContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, IFileSystemService } from '@esengine/editor-core';
|
||||
import type { IFileSystem } from '@esengine/editor-core';
|
||||
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
|
||||
import { ChevronRight, ChevronDown, Lock } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
|
||||
import { AssetSaveDialog } from './dialogs/AssetSaveDialog';
|
||||
import { AssetField } from './inspectors/fields/AssetField';
|
||||
import '../styles/PropertyInspector.css';
|
||||
|
||||
const animationClipsEditor = new AnimationClipsFieldEditor();
|
||||
@@ -198,18 +197,55 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
case 'asset': {
|
||||
const controlledBy = getControlledBy(propertyName);
|
||||
const assetMeta = metadata as { assetType?: string; extensions?: string[] };
|
||||
const fileExtension = assetMeta.extensions?.[0] || '';
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
if (fileExtension === '.tilemap.json') {
|
||||
messageHub.publish('tilemap:create-asset', {
|
||||
entityId: entity?.id,
|
||||
onChange: (newValue: string) => handleChange(propertyName, newValue)
|
||||
});
|
||||
} else if (fileExtension === '.btree') {
|
||||
messageHub.publish('behavior-tree:create-asset', {
|
||||
entityId: entity?.id,
|
||||
onChange: (newValue: string) => handleChange(propertyName, newValue)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const creatableExtensions = ['.tilemap.json', '.btree'];
|
||||
const canCreate = assetMeta.extensions?.some(ext => creatableExtensions.includes(ext));
|
||||
|
||||
return (
|
||||
<AssetDropField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? ''}
|
||||
assetType={assetMeta.assetType}
|
||||
extensions={assetMeta.extensions}
|
||||
readOnly={metadata.readOnly || !!controlledBy}
|
||||
controlledBy={controlledBy}
|
||||
entityId={entity?.id?.toString()}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
<div key={propertyName} className="property-field">
|
||||
<label className="property-label">
|
||||
{label}
|
||||
{controlledBy && (
|
||||
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
|
||||
<Lock size={10} />
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<AssetField
|
||||
value={value ?? null}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue || '')}
|
||||
fileExtension={fileExtension}
|
||||
placeholder="拖拽或选择资源"
|
||||
readonly={metadata.readOnly || !!controlledBy}
|
||||
onNavigate={handleNavigate}
|
||||
onCreate={canCreate ? handleCreate : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -885,223 +921,3 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
|
||||
);
|
||||
}
|
||||
|
||||
interface AssetDropFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
assetType?: string;
|
||||
extensions?: string[];
|
||||
readOnly?: boolean;
|
||||
controlledBy?: string;
|
||||
entityId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function AssetDropField({ label, value, assetType, extensions, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Determine if this asset type can be created
|
||||
const creatableExtensions = ['.tilemap.json', '.btree'];
|
||||
const canCreate = extensions?.some(ext => creatableExtensions.includes(ext));
|
||||
const fileExtension = extensions?.[0];
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowSaveDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveAsset = async (relativePath: string) => {
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
|
||||
if (!fileSystem) {
|
||||
console.error('[AssetDropField] FileSystem service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get absolute path from project
|
||||
const projectService = Core.services.tryResolve(
|
||||
(await import('@esengine/editor-core')).ProjectService
|
||||
);
|
||||
const currentProject = projectService?.getCurrentProject();
|
||||
if (!currentProject) {
|
||||
console.error('[AssetDropField] No project loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = `${currentProject.path}/${relativePath}`.replace(/\\/g, '/');
|
||||
|
||||
// Create default content based on file type
|
||||
let defaultContent = '';
|
||||
if (fileExtension === '.tilemap.json') {
|
||||
defaultContent = JSON.stringify({
|
||||
name: 'New Tilemap',
|
||||
version: 2,
|
||||
width: 20,
|
||||
height: 15,
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
layers: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Layer 0',
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
data: new Array(20 * 15).fill(0)
|
||||
}
|
||||
],
|
||||
tilesets: []
|
||||
}, null, 2);
|
||||
} else if (fileExtension === '.btree') {
|
||||
defaultContent = JSON.stringify({
|
||||
name: 'New Behavior Tree',
|
||||
version: 1,
|
||||
nodes: [],
|
||||
connections: []
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// Write file
|
||||
await fileSystem.writeFile(absolutePath, defaultContent);
|
||||
|
||||
// Update component with relative path
|
||||
onChange(relativePath);
|
||||
|
||||
// Open editor panel if tilemap
|
||||
if (messageHub && fileExtension === '.tilemap.json' && entityId) {
|
||||
const { useTilemapEditorStore } = await import('@esengine/tilemap-editor');
|
||||
useTilemapEditorStore.getState().setEntityId(entityId);
|
||||
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
|
||||
}
|
||||
|
||||
console.log('[AssetDropField] Created asset:', relativePath);
|
||||
} catch (error) {
|
||||
console.error('[AssetDropField] Failed to create asset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!readOnly) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (readOnly) return;
|
||||
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath) {
|
||||
if (fileExtension) {
|
||||
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
|
||||
const lowerPath = assetPath.toLowerCase();
|
||||
// Check if the path ends with any of the specified extensions
|
||||
// This handles both simple extensions (.json) and compound extensions (.tilemap.json)
|
||||
const isValidExtension = extensions.some((ext) => {
|
||||
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
|
||||
return lowerPath.endsWith(normalizedExt);
|
||||
});
|
||||
if (isValidExtension) {
|
||||
onChange(assetPath);
|
||||
}
|
||||
} else {
|
||||
onChange(assetPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (path: string) => {
|
||||
if (!path) return '';
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readOnly) onChange('');
|
||||
};
|
||||
|
||||
const handleNavigate = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (value) {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path: value });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
{label}
|
||||
{controlledBy && (
|
||||
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
|
||||
<Lock size={10} />
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className={`property-asset-drop ${isDragging ? 'dragging' : ''} ${value ? 'has-value' : ''} ${controlledBy ? 'controlled' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
title={controlledBy ? `Controlled by ${controlledBy}` : (value || 'Drop asset here')}
|
||||
>
|
||||
<span className="property-asset-text">
|
||||
{value ? getFileName(value) : 'None'}
|
||||
</span>
|
||||
<div className="property-asset-actions">
|
||||
{canCreate && !readOnly && !value && (
|
||||
<button
|
||||
className="property-asset-btn property-asset-btn-create"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}
|
||||
title="创建新资产"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
{value && (
|
||||
<button
|
||||
className="property-asset-btn"
|
||||
onClick={handleNavigate}
|
||||
title="在资产浏览器中显示"
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
{value && !readOnly && (
|
||||
<button className="property-asset-clear" onClick={handleClear}>×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Dialog */}
|
||||
<AssetSaveDialog
|
||||
isOpen={showSaveDialog}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
onSave={handleSaveAsset}
|
||||
title={fileExtension === '.tilemap.json' ? '创建 Tilemap 资产' : '创建资产'}
|
||||
defaultFileName={fileExtension === '.tilemap.json' ? 'new-tilemap' : 'new-asset'}
|
||||
fileExtension={fileExtension}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,42 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight } from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 12): React.ReactNode {
|
||||
if (!iconName) return <Plus size={size} />;
|
||||
|
||||
// 获取图标组件
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
// 回退到 Plus 图标
|
||||
return <Plus size={size} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类别图标映射
|
||||
*/
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
'rendering': 'Image',
|
||||
'ui': 'LayoutGrid',
|
||||
'physics': 'Box',
|
||||
'audio': 'Volume2',
|
||||
'basic': 'Plus',
|
||||
'other': 'MoreHorizontal',
|
||||
};
|
||||
|
||||
type ViewMode = 'local' | 'remote';
|
||||
|
||||
interface SceneHierarchyProps {
|
||||
@@ -261,43 +291,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateSpriteEntity = () => {
|
||||
// Count only Sprite entities for naming
|
||||
const spriteCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('Sprite ')).length;
|
||||
const entityName = `Sprite ${spriteCount + 1}`;
|
||||
|
||||
const command = new CreateSpriteEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateAnimatedSpriteEntity = () => {
|
||||
const animCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('AnimatedSprite ')).length;
|
||||
const entityName = `AnimatedSprite ${animCount + 1}`;
|
||||
|
||||
const command = new CreateAnimatedSpriteEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateCameraEntity = () => {
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Camera ${entityCount + 1}`;
|
||||
|
||||
const command = new CreateCameraEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async () => {
|
||||
if (!selectedId) return;
|
||||
|
||||
@@ -539,9 +532,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
entityId={contextMenu.entityId}
|
||||
pluginTemplates={pluginTemplates}
|
||||
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
|
||||
onCreateSprite={() => { handleCreateSpriteEntity(); closeContextMenu(); }}
|
||||
onCreateAnimatedSprite={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}
|
||||
onCreateCamera={() => { handleCreateCameraEntity(); closeContextMenu(); }}
|
||||
onCreateFromTemplate={async (template) => {
|
||||
await template.create(contextMenu.entityId ?? undefined);
|
||||
closeContextMenu();
|
||||
@@ -561,9 +551,6 @@ interface ContextMenuWithSubmenuProps {
|
||||
entityId: number | null;
|
||||
pluginTemplates: EntityCreationTemplate[];
|
||||
onCreateEmpty: () => void;
|
||||
onCreateSprite: () => void;
|
||||
onCreateAnimatedSprite: () => void;
|
||||
onCreateCamera: () => void;
|
||||
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
@@ -571,8 +558,7 @@ interface ContextMenuWithSubmenuProps {
|
||||
|
||||
function ContextMenuWithSubmenu({
|
||||
x, y, locale, entityId, pluginTemplates,
|
||||
onCreateEmpty, onCreateSprite, onCreateAnimatedSprite, onCreateCamera,
|
||||
onCreateFromTemplate, onDelete
|
||||
onCreateEmpty, onCreateFromTemplate, onDelete
|
||||
}: ContextMenuWithSubmenuProps) {
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
@@ -580,7 +566,7 @@ function ContextMenuWithSubmenu({
|
||||
|
||||
const categoryLabels: Record<string, { zh: string; en: string }> = {
|
||||
'basic': { zh: '基础', en: 'Basic' },
|
||||
'rendering': { zh: '渲染', en: 'Rendering' },
|
||||
'rendering': { zh: '2D 对象', en: '2D Objects' },
|
||||
'ui': { zh: 'UI', en: 'UI' },
|
||||
'physics': { zh: '物理', en: 'Physics' },
|
||||
'audio': { zh: '音频', en: 'Audio' },
|
||||
@@ -592,6 +578,7 @@ function ContextMenuWithSubmenu({
|
||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
|
||||
};
|
||||
|
||||
// 将模板按类别分组(所有模板现在都来自插件)
|
||||
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||
const cat = template.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -599,7 +586,10 @@ function ContextMenuWithSubmenu({
|
||||
return acc;
|
||||
}, {} as Record<string, EntityCreationTemplate[]>);
|
||||
|
||||
const hasPluginCategories = Object.keys(templatesByCategory).length > 0;
|
||||
// 按顺序排序每个类别内的模板
|
||||
Object.values(templatesByCategory).forEach(templates => {
|
||||
templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
});
|
||||
|
||||
const handleSubmenuEnter = (category: string, e: React.MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -607,6 +597,14 @@ function ContextMenuWithSubmenu({
|
||||
setActiveSubmenu(category);
|
||||
};
|
||||
|
||||
// 定义类别显示顺序
|
||||
const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other'];
|
||||
const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => {
|
||||
const orderA = categoryOrder.indexOf(a);
|
||||
const orderB = categoryOrder.indexOf(b);
|
||||
return (orderA === -1 ? 999 : orderA) - (orderB === -1 ? 999 : orderB);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -618,41 +616,10 @@ function ContextMenuWithSubmenu({
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
|
||||
|
||||
<div
|
||||
className="context-menu-item-with-submenu"
|
||||
onMouseEnter={(e) => handleSubmenuEnter('rendering', e)}
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<button>
|
||||
<Image size={12} />
|
||||
<span>{locale === 'zh' ? '2D 对象' : '2D Objects'}</span>
|
||||
<ChevronRight size={12} className="submenu-arrow" />
|
||||
</button>
|
||||
{activeSubmenu === 'rendering' && (
|
||||
<div
|
||||
className="context-submenu"
|
||||
style={{ left: submenuPosition.x, top: submenuPosition.y }}
|
||||
onMouseEnter={() => setActiveSubmenu('rendering')}
|
||||
>
|
||||
<button onClick={onCreateSprite}>
|
||||
<Image size={12} />
|
||||
<span>Sprite</span>
|
||||
</button>
|
||||
<button onClick={onCreateAnimatedSprite}>
|
||||
<Film size={12} />
|
||||
<span>{locale === 'zh' ? '动画 Sprite' : 'Animated Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={onCreateCamera}>
|
||||
<Camera size={12} />
|
||||
<span>{locale === 'zh' ? '相机' : 'Camera'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasPluginCategories && Object.entries(templatesByCategory).map(([category, templates]) => (
|
||||
{/* 按类别渲染所有模板 */}
|
||||
{sortedCategories.map(([category, templates]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="context-menu-item-with-submenu"
|
||||
@@ -660,7 +627,7 @@ function ContextMenuWithSubmenu({
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<button>
|
||||
{templates[0]?.icon || <Plus size={12} />}
|
||||
{getIconComponent(categoryIconMap[category], 12)}
|
||||
<span>{getCategoryLabel(category)}</span>
|
||||
<ChevronRight size={12} className="submenu-arrow" />
|
||||
</button>
|
||||
@@ -672,7 +639,7 @@ function ContextMenuWithSubmenu({
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||
{template.icon || <Plus size={12} />}
|
||||
{getIconComponent(template.icon as string, 12)}
|
||||
<span>{template.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Settings as SettingsIcon, ChevronRight } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
|
||||
import { PluginListSetting } from './PluginListSetting';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
onClose: () => void;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
initialCategoryId?: string;
|
||||
}
|
||||
|
||||
export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProps) {
|
||||
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
|
||||
const [categories, setCategories] = useState<SettingCategory[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
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());
|
||||
|
||||
@@ -20,19 +23,42 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
|
||||
setCategories(allCategories);
|
||||
|
||||
if (allCategories.length > 0 && !selectedCategoryId) {
|
||||
const firstCategory = allCategories[0];
|
||||
if (firstCategory) {
|
||||
setSelectedCategoryId(firstCategory.id);
|
||||
// 如果有 initialCategoryId,尝试使用它
|
||||
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()) {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
// Project-scoped settings are loaded from ProjectService
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, resolution.width);
|
||||
} else if (key === 'project.uiDesignResolution.height') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, resolution.height);
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else {
|
||||
// For other project settings, use default
|
||||
initialValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
} else {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(initialValues);
|
||||
@@ -52,17 +78,48 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
|
||||
setErrors(newErrors);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (errors.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
// Track UI resolution changes for batch saving
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
settings.set(key, value);
|
||||
changedSettings[key] = value;
|
||||
// Project-scoped settings are saved to ProjectService
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
newWidth = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.height') {
|
||||
newHeight = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
// Preset changes width and height together
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
}
|
||||
changedSettings[key] = value;
|
||||
} else {
|
||||
settings.set(key, value);
|
||||
changedSettings[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Save UI resolution if changed
|
||||
if (uiResolutionChanged && projectService) {
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
@@ -216,6 +273,23 @@ export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProp
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pluginList': {
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (!pluginManager) {
|
||||
return (
|
||||
<div className="settings-field settings-field-full">
|
||||
<p className="settings-error">PluginManager 不可用</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-field settings-field-full">
|
||||
<PluginListSetting pluginManager={pluginManager} />
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'ECS Framework Editor',
|
||||
title: 'ESEngine Editor',
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create Project',
|
||||
@@ -72,7 +72,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
later: 'Later'
|
||||
},
|
||||
zh: {
|
||||
title: 'ECS 框架编辑器',
|
||||
title: 'ESEngine 编辑器',
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Github, LogOut, User, LayoutDashboard, Loader2 } from 'lucide-react';
|
||||
import type { GitHubService, GitHubUser } from '../services/GitHubService';
|
||||
import '../styles/UserProfile.css';
|
||||
|
||||
interface UserProfileProps {
|
||||
githubService: GitHubService;
|
||||
onLogin: () => void;
|
||||
onOpenDashboard: () => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }: UserProfileProps) {
|
||||
const [user, setUser] = useState<GitHubUser | null>(githubService.getUser());
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(githubService.isLoadingUserInfo());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
dashboard: '个人中心',
|
||||
profile: '个人信息',
|
||||
notLoggedIn: '未登录',
|
||||
loadingUser: '加载中...'
|
||||
},
|
||||
en: {
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
dashboard: 'Dashboard',
|
||||
profile: 'Profile',
|
||||
notLoggedIn: 'Not logged in',
|
||||
loadingUser: 'Loading...'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听加载状态变化
|
||||
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
|
||||
setIsLoadingUser(isLoading);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [githubService]);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听认证状态变化
|
||||
const checkUser = () => {
|
||||
const currentUser = githubService.getUser();
|
||||
setUser((prevUser) => {
|
||||
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
|
||||
return currentUser;
|
||||
} else if (!currentUser && prevUser) {
|
||||
return null;
|
||||
}
|
||||
return prevUser;
|
||||
});
|
||||
};
|
||||
|
||||
// 每秒检查一次用户状态
|
||||
const interval = setInterval(checkUser, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [githubService]);
|
||||
|
||||
const handleLogout = () => {
|
||||
githubService.logout();
|
||||
setUser(null);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="user-profile">
|
||||
<button
|
||||
className="login-button"
|
||||
onClick={onLogin}
|
||||
disabled={isLoadingUser}
|
||||
title={isLoadingUser ? t('loadingUser') : undefined}
|
||||
>
|
||||
{isLoadingUser ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spinning" />
|
||||
{t('loadingUser')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Github size={16} />
|
||||
{t('login')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-profile" ref={menuRef}>
|
||||
<button className="user-avatar-button" onClick={() => setShowMenu(!showMenu)}>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.name} className="user-avatar" />
|
||||
) : (
|
||||
<div className="user-avatar-placeholder">
|
||||
<User size={20} />
|
||||
</div>
|
||||
)}
|
||||
<span className="user-name">{user.name || user.login}</span>
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<img src={user.avatar_url} alt={user.name} className="user-menu-avatar" />
|
||||
<div className="user-menu-info">
|
||||
<div className="user-menu-name">{user.name || user.login}</div>
|
||||
<div className="user-menu-login">@{user.login}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
onOpenDashboard();
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t('dashboard')}
|
||||
</button>
|
||||
|
||||
<button className="user-menu-item" onClick={handleLogout}>
|
||||
<LogOut size={16} />
|
||||
{t('logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 资产选择框 */
|
||||
.asset-field {
|
||||
margin-bottom: 6px;
|
||||
min-width: 0; /* 允许在flex容器中收缩 */
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
@@ -18,6 +19,8 @@
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
min-width: 0; /* 允许在flex容器中收缩 */
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
.asset-field__container.hovered {
|
||||
@@ -40,6 +43,7 @@
|
||||
background: #262626;
|
||||
border-right: 1px solid #333;
|
||||
color: #888;
|
||||
flex-shrink: 0; /* 图标不收缩 */
|
||||
}
|
||||
|
||||
.asset-field__container.hovered .asset-field__icon {
|
||||
@@ -55,7 +59,8 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
min-width: 0; /* 关键:允许flex项收缩到小于内容宽度 */
|
||||
overflow: hidden; /* 配合min-width: 0防止溢出 */
|
||||
}
|
||||
|
||||
.asset-field__input:hover {
|
||||
@@ -68,6 +73,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%; /* 确保不超出父容器 */
|
||||
display: block; /* 让text-overflow生效 */
|
||||
}
|
||||
|
||||
.asset-field__input.empty .asset-field__value {
|
||||
@@ -81,6 +88,7 @@
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 0 1px;
|
||||
flex-shrink: 0; /* 操作按钮不收缩 */
|
||||
}
|
||||
|
||||
.asset-field__button {
|
||||
|
||||
@@ -111,7 +111,6 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
if (!component) return;
|
||||
|
||||
const componentName = getComponentTypeName(component.constructor as any);
|
||||
console.log('Removing component:', componentName);
|
||||
|
||||
// Check if any other component depends on this one
|
||||
const dependentComponents: string[] = [];
|
||||
@@ -120,12 +119,10 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const dependencies = getComponentDependencies(otherComponent.constructor as any);
|
||||
const otherName = getComponentTypeName(otherComponent.constructor as any);
|
||||
console.log('Checking', otherName, 'dependencies:', dependencies);
|
||||
if (dependencies && dependencies.includes(componentName)) {
|
||||
dependentComponents.push(otherName);
|
||||
}
|
||||
}
|
||||
console.log('Dependent components:', dependentComponents);
|
||||
|
||||
if (dependentComponents.length > 0) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { EditorEngineSync } from '../services/EditorEngineSync';
|
||||
|
||||
@@ -63,30 +62,12 @@ async function initializeEngine(canvasId: string): Promise<void> {
|
||||
await engine.initialize(canvasId);
|
||||
|
||||
// Initialize sync service
|
||||
// 初始化同步服务
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
if (messageHub && entityStore) {
|
||||
EditorEngineSync.getInstance().initialize(messageHub, entityStore);
|
||||
|
||||
// Create default camera if none exists
|
||||
// 如果不存在相机则创建默认相机
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
|
||||
if (existingCameras.length === 0) {
|
||||
const cameraEntity = scene.createEntity('Main Camera');
|
||||
cameraEntity.addComponent(new TransformComponent());
|
||||
const camera = new CameraComponent();
|
||||
camera.orthographicSize = 1;
|
||||
cameraEntity.addComponent(camera);
|
||||
|
||||
// Register with EntityStore so it appears in hierarchy
|
||||
// 注册到 EntityStore 以便在层级视图中显示
|
||||
entityStore.addEntity(cameraEntity);
|
||||
messageHub.publish('entity:added', { entity: cameraEntity });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.warn('Failed to initialize sync service | 同步服务初始化失败:', syncError);
|
||||
@@ -189,11 +170,7 @@ export function useEngine(
|
||||
clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
}
|
||||
// Unregister viewport on cleanup
|
||||
if (viewportRegisteredRef.current) {
|
||||
engineRef.current.unregisterViewport(options.viewportId);
|
||||
viewportRegisteredRef.current = false;
|
||||
}
|
||||
// 注意:Viewport 现在是持久化的,不会被卸载,所以不需要 unregisterViewport
|
||||
};
|
||||
}, [options.canvasId, options.viewportId, options.autoInit, options.showGrid, options.showGizmos]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Translations } from '@esengine/editor-core';
|
||||
|
||||
export const en: Translations = {
|
||||
app: {
|
||||
title: 'ECS Framework Editor'
|
||||
title: 'ESEngine Editor'
|
||||
},
|
||||
header: {
|
||||
toolbar: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Translations } from '@esengine/editor-core';
|
||||
|
||||
export const zh: Translations = {
|
||||
app: {
|
||||
title: 'ECS 框架编辑器'
|
||||
title: 'ESEngine 编辑器'
|
||||
},
|
||||
header: {
|
||||
toolbar: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'reflect-metadata';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { setGlobalLogLevel, LogLevel } from '@esengine/ecs-framework';
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Gizmo Plugin
|
||||
* Gizmo 插件
|
||||
*
|
||||
* Registers gizmo support for built-in components
|
||||
* 为内置组件注册 gizmo 支持
|
||||
*/
|
||||
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IEditorPlugin } from '@esengine/editor-core';
|
||||
import { EditorPluginCategory } from '@esengine/editor-core';
|
||||
import { registerSpriteGizmo } from '../gizmos';
|
||||
|
||||
export class GizmoPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/gizmo-plugin';
|
||||
readonly version = '1.0.0';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
|
||||
get displayName(): string {
|
||||
return 'Gizmo System';
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return 'Provides gizmo support for editor components';
|
||||
}
|
||||
|
||||
async install(_core: Core, _services: ServiceContainer): Promise<void> {
|
||||
// Register gizmo support for SpriteComponent
|
||||
// 为 SpriteComponent 注册 gizmo 支持
|
||||
registerSpriteGizmo();
|
||||
|
||||
console.log('[GizmoPlugin] Installed - registered gizmo support for built-in components');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
console.log('[GizmoPlugin] Uninstalled');
|
||||
}
|
||||
}
|
||||
|
||||
export const gizmoPlugin = new GizmoPlugin();
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { IEditorPlugin, EditorPluginCategory, PanelPosition } from '@esengine/editor-core';
|
||||
import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@esengine/editor-core';
|
||||
|
||||
/**
|
||||
* Scene Inspector 插件
|
||||
*
|
||||
* 提供场景层级视图和实体检视功能
|
||||
*/
|
||||
export class SceneInspectorPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/scene-inspector';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Scene Inspector';
|
||||
readonly category = EditorPluginCategory.Inspector;
|
||||
readonly description = 'Scene hierarchy and entity inspector';
|
||||
readonly icon = '🔍';
|
||||
|
||||
async install(_core: Core, _services: ServiceContainer): Promise<void> {
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-scene-inspector',
|
||||
label: 'Scene Inspector',
|
||||
parentId: 'view',
|
||||
onClick: () => {
|
||||
},
|
||||
shortcut: 'Ctrl+Shift+I',
|
||||
order: 100
|
||||
},
|
||||
{
|
||||
id: 'scene-create-entity',
|
||||
label: 'Create Entity',
|
||||
parentId: 'scene',
|
||||
onClick: () => {
|
||||
},
|
||||
shortcut: 'Ctrl+N',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerToolbar(): ToolbarItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'toolbar-create-entity',
|
||||
label: 'New Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '➕',
|
||||
onClick: () => {
|
||||
},
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'toolbar-delete-entity',
|
||||
label: 'Delete Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '🗑️',
|
||||
onClick: () => {
|
||||
},
|
||||
order: 20
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'panel-scene-hierarchy',
|
||||
title: 'Scene Hierarchy',
|
||||
position: PanelPosition.Left,
|
||||
defaultSize: 250,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '📋',
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'panel-entity-inspector',
|
||||
title: 'Entity Inspector',
|
||||
position: PanelPosition.Right,
|
||||
defaultSize: 300,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '🔎',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getSerializers(): ISerializer[] {
|
||||
return [
|
||||
{
|
||||
serialize: (data: any) => {
|
||||
const json = JSON.stringify(data);
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(json);
|
||||
},
|
||||
deserialize: (data: Uint8Array) => {
|
||||
const decoder = new TextDecoder();
|
||||
const json = decoder.decode(data);
|
||||
return JSON.parse(json);
|
||||
},
|
||||
getSupportedType: () => 'scene'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
}
|
||||
|
||||
async onProjectOpen(projectPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async onProjectClose(): Promise<void> {
|
||||
}
|
||||
}
|
||||
+37
-23
@@ -1,24 +1,21 @@
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
/**
|
||||
* Editor Appearance Plugin
|
||||
* 编辑器外观插件
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import { IEditorPlugin, EditorPluginCategory, SettingsRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
|
||||
const logger = createLogger('EditorAppearancePlugin');
|
||||
|
||||
/**
|
||||
* Editor Appearance Plugin
|
||||
*
|
||||
* Manages editor appearance settings like font size
|
||||
* Editor Appearance 编辑器模块
|
||||
*/
|
||||
export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/editor-appearance';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Editor Appearance';
|
||||
readonly category = EditorPluginCategory.System;
|
||||
readonly description = 'Configure editor appearance settings';
|
||||
readonly icon = '🎨';
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
class EditorAppearanceEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
|
||||
settingsRegistry.registerCategory({
|
||||
@@ -77,9 +74,6 @@ export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
logger.info('Editor is ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply font settings from settings
|
||||
*/
|
||||
private applyFontSettings(): void {
|
||||
const settings = SettingsService.getInstance();
|
||||
const baseFontSize = settings.get<number>('editor.fontSize', 13);
|
||||
@@ -87,8 +81,6 @@ export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
logger.info(`Applying font size: ${baseFontSize}px`);
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply font sizes
|
||||
root.style.setProperty('--font-size-xs', `${baseFontSize - 2}px`);
|
||||
root.style.setProperty('--font-size-sm', `${baseFontSize - 1}px`);
|
||||
root.style.setProperty('--font-size-base', `${baseFontSize}px`);
|
||||
@@ -97,9 +89,6 @@ export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
root.style.setProperty('--font-size-xl', `${baseFontSize + 5}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for settings changes
|
||||
*/
|
||||
private setupSettingsListener(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const changedSettings = event.detail;
|
||||
@@ -112,3 +101,28 @@ export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
}) as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/editor-appearance',
|
||||
name: 'Editor Appearance',
|
||||
version: '1.0.0',
|
||||
description: 'Configure editor appearance settings',
|
||||
category: 'tools',
|
||||
icon: 'Palette',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'EditorAppearanceEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'earliest'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const EditorAppearancePlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new EditorAppearanceEditorModule()
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Gizmo Plugin
|
||||
* Gizmo 插件
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import { registerSpriteGizmo } from '../../gizmos';
|
||||
|
||||
/**
|
||||
* Gizmo 编辑器模块
|
||||
*/
|
||||
class GizmoEditorModule implements IEditorModuleLoader {
|
||||
async install(_services: ServiceContainer): Promise<void> {
|
||||
registerSpriteGizmo();
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// Uninstalled
|
||||
}
|
||||
|
||||
getGizmoProviders(): GizmoProviderRegistration[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/gizmo',
|
||||
name: 'Gizmo System',
|
||||
version: '1.0.0',
|
||||
description: 'Provides gizmo support for editor components',
|
||||
category: 'tools',
|
||||
icon: 'Move',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'GizmoEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'preDefault'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const GizmoPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new GizmoEditorModule()
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Plugin Config Plugin
|
||||
* 插件配置插件
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
|
||||
const logger = createLogger('PluginConfigPlugin');
|
||||
|
||||
/**
|
||||
* Plugin Config 编辑器模块
|
||||
*/
|
||||
class PluginConfigEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'plugins',
|
||||
title: '插件',
|
||||
description: '管理项目使用的插件',
|
||||
sections: [
|
||||
{
|
||||
id: 'engine-plugins',
|
||||
title: '插件管理',
|
||||
description: '启用或禁用项目需要的插件。禁用不需要的插件可以减少打包体积。',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.enabledPlugins',
|
||||
label: '',
|
||||
type: 'pluginList',
|
||||
defaultValue: [],
|
||||
description: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
logger.info('Installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
logger.info('Uninstalled');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
logger.info('Editor is ready');
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/plugin-config',
|
||||
name: 'Plugin Config',
|
||||
version: '1.0.0',
|
||||
description: 'Configure engine plugins',
|
||||
category: 'tools',
|
||||
icon: 'Package',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'PluginConfigEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const PluginConfigPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new PluginConfigEditorModule()
|
||||
};
|
||||
+60
-41
@@ -1,28 +1,30 @@
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { IEditorPlugin, EditorPluginCategory, MenuItem, MessageHub, PanelDescriptor, SettingsRegistry } from '@esengine/editor-core';
|
||||
import { ProfilerDockPanel } from '../components/ProfilerDockPanel';
|
||||
import { ProfilerService } from '../services/ProfilerService';
|
||||
|
||||
/**
|
||||
* Profiler Plugin
|
||||
*
|
||||
* Displays real-time performance metrics for ECS systems
|
||||
* 性能分析器插件
|
||||
*/
|
||||
export class ProfilerPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/profiler';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Performance Profiler';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
readonly description = 'Real-time performance monitoring for ECS systems';
|
||||
readonly icon = '📊';
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
PanelDescriptor,
|
||||
MenuItemDescriptor
|
||||
} from '@esengine/editor-core';
|
||||
import { MessageHub, SettingsRegistry, PanelPosition } from '@esengine/editor-core';
|
||||
import { ProfilerDockPanel } from '../../components/ProfilerDockPanel';
|
||||
import { ProfilerService } from '../../services/ProfilerService';
|
||||
|
||||
/**
|
||||
* Profiler 编辑器模块
|
||||
*/
|
||||
class ProfilerEditorModule implements IEditorModuleLoader {
|
||||
private messageHub: MessageHub | null = null;
|
||||
private profilerService: ProfilerService | null = null;
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
this.messageHub = services.resolve(MessageHub);
|
||||
|
||||
// 注册设置
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'profiler',
|
||||
@@ -86,51 +88,68 @@ export class ProfilerPlugin implements IEditorPlugin {
|
||||
]
|
||||
});
|
||||
|
||||
// 创建并启动 ProfilerService
|
||||
this.profilerService = new ProfilerService();
|
||||
|
||||
// 将服务实例存储到全局,供组件访问
|
||||
(window as any).__PROFILER_SERVICE__ = this.profilerService;
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 ProfilerService
|
||||
if (this.profilerService) {
|
||||
this.profilerService.destroy();
|
||||
this.profilerService = null;
|
||||
}
|
||||
|
||||
delete (window as any).__PROFILER_SERVICE__;
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
const items = [
|
||||
{
|
||||
id: 'window.profiler',
|
||||
label: 'Profiler',
|
||||
parentId: 'window',
|
||||
order: 100,
|
||||
onClick: () => {
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
return items;
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'profiler-monitor',
|
||||
title: 'Performance Monitor',
|
||||
position: 'center' as any,
|
||||
position: PanelPosition.Center,
|
||||
closable: false,
|
||||
component: ProfilerDockPanel,
|
||||
order: 200
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getMenuItems(): MenuItemDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'window.profiler',
|
||||
label: 'Profiler',
|
||||
parentId: 'window',
|
||||
execute: () => {
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/profiler',
|
||||
name: 'Performance Profiler',
|
||||
version: '1.0.0',
|
||||
description: 'Real-time performance monitoring for ECS systems',
|
||||
category: 'tools',
|
||||
icon: 'BarChart3',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProfilerEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault',
|
||||
panels: ['profiler-monitor']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ProfilerPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new ProfilerEditorModule()
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Project Settings Plugin
|
||||
* 项目设置插件
|
||||
*
|
||||
* Registers project-level settings like UI design resolution.
|
||||
* 注册项目级别的设置,如 UI 设计分辨率。
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IPluginLoader, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
|
||||
const logger = createLogger('ProjectSettingsPlugin');
|
||||
|
||||
/**
|
||||
* Common UI design resolution presets
|
||||
* 常见的 UI 设计分辨率预设
|
||||
*/
|
||||
export const UI_RESOLUTION_PRESETS = [
|
||||
{ label: '1920 x 1080 (Full HD)', value: { width: 1920, height: 1080 } },
|
||||
{ label: '1280 x 720 (HD)', value: { width: 1280, height: 720 } },
|
||||
{ label: '1366 x 768 (HD+)', value: { width: 1366, height: 768 } },
|
||||
{ label: '2560 x 1440 (2K)', value: { width: 2560, height: 1440 } },
|
||||
{ label: '3840 x 2160 (4K)', value: { width: 3840, height: 2160 } },
|
||||
{ label: '750 x 1334 (iPhone 6/7/8)', value: { width: 750, height: 1334 } },
|
||||
{ label: '1125 x 2436 (iPhone X/11/12)', value: { width: 1125, height: 2436 } },
|
||||
{ label: '1080 x 1920 (Mobile Portrait)', value: { width: 1080, height: 1920 } },
|
||||
{ label: '800 x 600 (SVGA)', value: { width: 800, height: 600 } },
|
||||
{ label: '1024 x 768 (XGA)', value: { width: 1024, height: 768 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Project Settings 编辑器模块
|
||||
*/
|
||||
class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
private settingsListener: ((event: Event) => void) | null = null;
|
||||
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
|
||||
// Setup listener for UI design resolution changes
|
||||
this.setupSettingsListener();
|
||||
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'project',
|
||||
title: '项目',
|
||||
description: '项目级别的配置',
|
||||
sections: [
|
||||
{
|
||||
id: 'ui-settings',
|
||||
title: 'UI 设置',
|
||||
description: '配置 UI 系统的基础参数',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.uiDesignResolution.width',
|
||||
label: '设计宽度',
|
||||
type: 'number',
|
||||
defaultValue: 1920,
|
||||
description: 'UI 画布的设计宽度(像素)',
|
||||
min: 320,
|
||||
max: 7680,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
key: 'project.uiDesignResolution.height',
|
||||
label: '设计高度',
|
||||
type: 'number',
|
||||
defaultValue: 1080,
|
||||
description: 'UI 画布的设计高度(像素)',
|
||||
min: 240,
|
||||
max: 4320,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
key: 'project.uiDesignResolution.preset',
|
||||
label: '分辨率预设',
|
||||
type: 'select',
|
||||
defaultValue: '1920x1080',
|
||||
description: '选择常见的分辨率预设',
|
||||
options: UI_RESOLUTION_PRESETS.map(p => ({
|
||||
label: p.label,
|
||||
value: `${p.value.width}x${p.value.height}`
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
logger.info('Installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// Remove settings listener
|
||||
if (this.settingsListener) {
|
||||
window.removeEventListener('settings:changed', this.settingsListener);
|
||||
this.settingsListener = null;
|
||||
}
|
||||
logger.info('Uninstalled');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
logger.info('Editor is ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup listener for settings changes
|
||||
* 设置设置变化监听器
|
||||
*/
|
||||
private setupSettingsListener(): void {
|
||||
this.settingsListener = ((event: CustomEvent) => {
|
||||
const changedSettings = event.detail;
|
||||
|
||||
// Check if UI design resolution changed
|
||||
if ('project.uiDesignResolution.width' in changedSettings ||
|
||||
'project.uiDesignResolution.height' in changedSettings ||
|
||||
'project.uiDesignResolution.preset' in changedSettings) {
|
||||
|
||||
logger.info('UI design resolution changed, applying...');
|
||||
this.applyUIDesignResolution();
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', this.settingsListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI design resolution from ProjectService
|
||||
* 从 ProjectService 应用 UI 设计分辨率
|
||||
*/
|
||||
private applyUIDesignResolution(): void {
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
if (!projectService) {
|
||||
logger.warn('ProjectService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
const engineService = EngineService.getInstance();
|
||||
|
||||
if (engineService.isInitialized()) {
|
||||
engineService.setUICanvasSize(resolution.width, resolution.height);
|
||||
logger.info(`Applied UI design resolution: ${resolution.width}x${resolution.height}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/project-settings',
|
||||
name: 'Project Settings',
|
||||
version: '1.0.0',
|
||||
description: 'Configure project-level settings',
|
||||
category: 'tools',
|
||||
icon: 'Settings',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProjectSettingsEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ProjectSettingsPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new ProjectSettingsEditorModule()
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Scene Inspector Plugin
|
||||
* 场景检视器插件
|
||||
*/
|
||||
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
PanelDescriptor,
|
||||
MenuItemDescriptor,
|
||||
ToolbarItemDescriptor,
|
||||
EntityCreationTemplate
|
||||
} from '@esengine/editor-core';
|
||||
import { PanelPosition, EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
|
||||
/**
|
||||
* Scene Inspector 编辑器模块
|
||||
*/
|
||||
class SceneInspectorEditorModule implements IEditorModuleLoader {
|
||||
async install(_services: ServiceContainer): Promise<void> {
|
||||
// Installed
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// Uninstalled
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'panel-scene-hierarchy',
|
||||
title: 'Scene Hierarchy',
|
||||
position: PanelPosition.Left,
|
||||
defaultSize: 250,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: 'List',
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'panel-entity-inspector',
|
||||
title: 'Entity Inspector',
|
||||
position: PanelPosition.Right,
|
||||
defaultSize: 300,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: 'Search',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getMenuItems(): MenuItemDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-scene-inspector',
|
||||
label: 'Scene Inspector',
|
||||
parentId: 'view',
|
||||
shortcut: 'Ctrl+Shift+I'
|
||||
},
|
||||
{
|
||||
id: 'scene-create-entity',
|
||||
label: 'Create Entity',
|
||||
parentId: 'scene',
|
||||
shortcut: 'Ctrl+N'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getToolbarItems(): ToolbarItemDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'toolbar-create-entity',
|
||||
label: 'New Entity',
|
||||
icon: 'Plus',
|
||||
tooltip: 'Create new entity',
|
||||
execute: () => {}
|
||||
},
|
||||
{
|
||||
id: 'toolbar-delete-entity',
|
||||
label: 'Delete Entity',
|
||||
icon: 'Trash2',
|
||||
tooltip: 'Delete selected entity',
|
||||
execute: () => {}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
// Sprite
|
||||
{
|
||||
id: 'create-sprite-entity',
|
||||
label: 'Sprite',
|
||||
icon: 'Image',
|
||||
category: 'rendering',
|
||||
order: 10,
|
||||
create: (): number => {
|
||||
return this.createEntity('Sprite', (entity) => {
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new SpriteComponent());
|
||||
});
|
||||
}
|
||||
},
|
||||
// Animated Sprite
|
||||
{
|
||||
id: 'create-animated-sprite-entity',
|
||||
label: '动画 Sprite',
|
||||
icon: 'Film',
|
||||
category: 'rendering',
|
||||
order: 11,
|
||||
create: (): number => {
|
||||
return this.createEntity('AnimatedSprite', (entity) => {
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new SpriteAnimatorComponent());
|
||||
});
|
||||
}
|
||||
},
|
||||
// Camera
|
||||
{
|
||||
id: 'create-camera-entity',
|
||||
label: '相机',
|
||||
icon: 'Camera',
|
||||
category: 'rendering',
|
||||
order: 12,
|
||||
create: (): number => {
|
||||
return this.createEntity('Camera', (entity) => {
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new CameraComponent());
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private createEntity(baseName: string, setupFn: (entity: Entity) => void): number {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
// 计数已有同类实体
|
||||
const count = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith(`${baseName} `)).length;
|
||||
const entityName = `${baseName} ${count + 1}`;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
setupFn(entity);
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {}
|
||||
async onProjectOpen(_projectPath: string): Promise<void> {}
|
||||
async onProjectClose(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/scene-inspector',
|
||||
name: 'Scene Inspector',
|
||||
version: '1.0.0',
|
||||
description: 'Scene hierarchy and entity inspector',
|
||||
category: 'tools',
|
||||
icon: 'Search',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'SceneInspectorEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default',
|
||||
panels: ['panel-scene-hierarchy', 'panel-entity-inspector'],
|
||||
inspectors: ['EntityInspector']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const SceneInspectorPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
editorModule: new SceneInspectorEditorModule()
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 内置插件
|
||||
* Built-in plugins
|
||||
*/
|
||||
|
||||
export { GizmoPlugin } from './GizmoPlugin';
|
||||
export { SceneInspectorPlugin } from './SceneInspectorPlugin';
|
||||
export { ProfilerPlugin } from './ProfilerPlugin';
|
||||
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
|
||||
export { PluginConfigPlugin } from './PluginConfigPlugin';
|
||||
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
|
||||
@@ -3,13 +3,13 @@
|
||||
* 管理Rust引擎生命周期的服务。
|
||||
*/
|
||||
|
||||
import { EngineBridge, EngineRenderSystem, CameraConfig, GizmoDataProviderFn, HasGizmoProviderFn } from '@esengine/ecs-engine-bindgen';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { EngineBridge, EngineRenderSystem, GizmoDataProviderFn, HasGizmoProviderFn, CameraConfig, CameraSystem } from '@esengine/ecs-engine-bindgen';
|
||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, type SystemContext } from '@esengine/editor-core';
|
||||
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystem } from '@esengine/ecs-components';
|
||||
import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap';
|
||||
import { UIRenderDataProvider } from '@esengine/ui';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree';
|
||||
import { UIRenderDataProvider, invalidateUIRenderCaches } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
AssetManager,
|
||||
@@ -32,10 +32,13 @@ export class EngineService {
|
||||
private bridge: EngineBridge | null = null;
|
||||
private scene: Scene | null = null;
|
||||
private renderSystem: EngineRenderSystem | null = null;
|
||||
private cameraSystem: CameraSystem | null = null;
|
||||
private animatorSystem: SpriteAnimatorSystem | null = null;
|
||||
private tilemapSystem: TilemapRenderingSystem | null = null;
|
||||
private behaviorTreeSystem: BehaviorTreeExecutionSystem | null = null;
|
||||
private uiRenderProvider: UIRenderDataProvider | null = null;
|
||||
private initialized = false;
|
||||
private modulesInitialized = false;
|
||||
private running = false;
|
||||
private animationFrameId: number | null = null;
|
||||
private lastTime = 0;
|
||||
@@ -46,6 +49,7 @@ export class EngineService {
|
||||
private assetPathResolver: AssetPathResolver | null = null;
|
||||
private assetSystemInitialized = false;
|
||||
private initializationError: Error | null = null;
|
||||
private canvasId: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -60,15 +64,37 @@ export class EngineService {
|
||||
return EngineService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待引擎初始化完成
|
||||
* @param timeout 超时时间(毫秒),默认 10 秒
|
||||
*/
|
||||
async waitForInitialization(timeout = 10000): Promise<boolean> {
|
||||
if (this.initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
while (!this.initialized && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the engine with canvas.
|
||||
* 使用canvas初始化引擎。
|
||||
*
|
||||
* 注意:此方法只初始化引擎基础设施(Core、渲染系统等),
|
||||
* 模块的初始化需要在项目打开后调用 initializeModuleSystems()
|
||||
*/
|
||||
async initialize(canvasId: string): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvasId = canvasId;
|
||||
|
||||
try {
|
||||
// Create engine bridge | 创建引擎桥接
|
||||
this.bridge = new EngineBridge({
|
||||
@@ -96,7 +122,7 @@ export class EngineService {
|
||||
Core.create({ debug: false });
|
||||
}
|
||||
|
||||
// Use existing Core scene or create new one | 使用现有Core场景或创建新的
|
||||
// 使用现有 Core 场景或创建新的
|
||||
if (Core.scene) {
|
||||
this.scene = Core.scene as Scene;
|
||||
} else {
|
||||
@@ -104,38 +130,15 @@ export class EngineService {
|
||||
Core.setScene(this.scene);
|
||||
}
|
||||
|
||||
// Add sprite animator system (disabled by default in editor mode)
|
||||
// 添加精灵动画系统(编辑器模式下默认禁用)
|
||||
this.animatorSystem = new SpriteAnimatorSystem();
|
||||
this.animatorSystem.enabled = false;
|
||||
this.scene!.addSystem(this.animatorSystem);
|
||||
// Add camera system (基础系统,始终需要)
|
||||
this.cameraSystem = new CameraSystem(this.bridge);
|
||||
this.scene.addSystem(this.cameraSystem);
|
||||
|
||||
// Add tilemap rendering system
|
||||
// 添加瓦片地图渲染系统
|
||||
this.tilemapSystem = new TilemapRenderingSystem();
|
||||
this.scene!.addSystem(this.tilemapSystem);
|
||||
|
||||
// Add render system to the scene | 将渲染系统添加到场景
|
||||
// Add render system to the scene (基础系统,始终需要)
|
||||
this.renderSystem = new EngineRenderSystem(this.bridge, TransformComponent);
|
||||
this.scene!.addSystem(this.renderSystem);
|
||||
|
||||
// Register tilemap system as render data provider
|
||||
// 将瓦片地图系统注册为渲染数据提供者
|
||||
this.renderSystem.addRenderDataProvider(this.tilemapSystem);
|
||||
|
||||
// Register UI render data provider
|
||||
// 注册 UI 渲染数据提供者
|
||||
this.uiRenderProvider = new UIRenderDataProvider();
|
||||
this.renderSystem.addRenderDataProvider(this.uiRenderProvider);
|
||||
|
||||
// Set up texture callback for UI text rendering
|
||||
// 设置 UI 文本渲染的纹理回调
|
||||
this.uiRenderProvider.setTextureCallback((id: number, dataUrl: string) => {
|
||||
this.bridge!.loadTexture(id, dataUrl);
|
||||
});
|
||||
this.scene.addSystem(this.renderSystem);
|
||||
|
||||
// Inject GizmoRegistry into render system
|
||||
// 将 GizmoRegistry 注入渲染系统
|
||||
this.renderSystem.setGizmoRegistry(
|
||||
((component, entity, isSelected) =>
|
||||
GizmoRegistry.getGizmoData(component, entity, isSelected)) as GizmoDataProviderFn,
|
||||
@@ -143,6 +146,10 @@ export class EngineService {
|
||||
GizmoRegistry.hasProvider(component.constructor as any)) as HasGizmoProviderFn
|
||||
);
|
||||
|
||||
// Set initial UI canvas size (will be updated from ProjectService when project opens)
|
||||
// 设置初始 UI 画布尺寸(项目打开后会从 ProjectService 更新为项目配置的分辨率)
|
||||
this.renderSystem.setUICanvasSize(1920, 1080);
|
||||
|
||||
// Initialize asset system | 初始化资产系统
|
||||
await this.initializeAssetSystem();
|
||||
|
||||
@@ -184,6 +191,107 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模块系统
|
||||
* Initialize module systems for all enabled plugins
|
||||
*
|
||||
* 通过 PluginManager 初始化所有插件的运行时模块
|
||||
* Initialize all plugin runtime modules via PluginManager
|
||||
*/
|
||||
async initializeModuleSystems(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
console.error('Engine not initialized. Call initialize() first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scene || !this.renderSystem || !this.bridge) {
|
||||
console.error('Scene or render system not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果之前已经初始化过模块,先清理
|
||||
if (this.modulesInitialized) {
|
||||
this.clearModuleSystems();
|
||||
}
|
||||
|
||||
// 获取 PluginManager
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (!pluginManager) {
|
||||
console.error('PluginManager not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化所有插件的运行时模块(注册组件和服务)
|
||||
// Initialize all plugin runtime modules (register components and services)
|
||||
await pluginManager.initializeRuntime(Core.services);
|
||||
|
||||
// 创建系统上下文
|
||||
// Create system context
|
||||
const context: SystemContext = {
|
||||
core: Core,
|
||||
engineBridge: this.bridge,
|
||||
renderSystem: this.renderSystem,
|
||||
isEditor: true
|
||||
};
|
||||
|
||||
// 让插件为场景创建系统
|
||||
// Let plugins create systems for scene
|
||||
pluginManager.createSystemsForScene(this.scene, context);
|
||||
|
||||
// 保存插件创建的系统引用
|
||||
// Save system references created by plugins
|
||||
this.animatorSystem = context.animatorSystem as SpriteAnimatorSystem | undefined ?? null;
|
||||
this.tilemapSystem = context.tilemapSystem as TilemapRenderingSystem | undefined ?? null;
|
||||
this.behaviorTreeSystem = context.behaviorTreeSystem as BehaviorTreeExecutionSystem | undefined ?? null;
|
||||
this.uiRenderProvider = context.uiRenderProvider as UIRenderDataProvider | undefined ?? null;
|
||||
|
||||
// 设置 UI 渲染数据提供者到 EngineRenderSystem
|
||||
// Set UI render data provider to EngineRenderSystem
|
||||
if (this.uiRenderProvider && this.renderSystem) {
|
||||
this.renderSystem.setUIRenderDataProvider(this.uiRenderProvider);
|
||||
}
|
||||
|
||||
// 在编辑器模式下,动画和行为树系统默认禁用
|
||||
// In editor mode, animation and behavior tree systems are disabled by default
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = false;
|
||||
}
|
||||
|
||||
this.modulesInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理模块系统
|
||||
* 用于项目关闭或切换时
|
||||
* Clear module systems, used when project closes or switches
|
||||
*/
|
||||
clearModuleSystems(): void {
|
||||
// 通过 PluginManager 清理场景系统
|
||||
// Clear scene systems via PluginManager
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (pluginManager) {
|
||||
pluginManager.clearSceneSystems();
|
||||
}
|
||||
|
||||
// 清空本地引用(系统的实际清理由场景管理)
|
||||
// Clear local references (actual system cleanup is managed by scene)
|
||||
this.animatorSystem = null;
|
||||
this.tilemapSystem = null;
|
||||
this.behaviorTreeSystem = null;
|
||||
this.uiRenderProvider = null;
|
||||
this.modulesInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块系统是否已初始化
|
||||
*/
|
||||
isModulesInitialized(): boolean {
|
||||
return this.modulesInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start render loop (editor preview mode).
|
||||
* 启动渲染循环(编辑器预览模式)。
|
||||
@@ -248,11 +356,22 @@ export class EngineService {
|
||||
this.running = true;
|
||||
this.lastTime = performance.now();
|
||||
|
||||
// Enable preview mode for UI rendering (screen space overlay)
|
||||
// 启用预览模式用于 UI 渲染(屏幕空间叠加)
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setPreviewMode(true);
|
||||
}
|
||||
|
||||
// Enable animator system and start auto-play animations
|
||||
// 启用动画系统并启动自动播放的动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = true;
|
||||
}
|
||||
// Enable behavior tree system for preview
|
||||
// 启用行为树系统用于预览
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = true;
|
||||
}
|
||||
this.startAutoPlayAnimations();
|
||||
|
||||
this.gameLoop();
|
||||
@@ -310,11 +429,22 @@ export class EngineService {
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
|
||||
// Disable preview mode for UI rendering (back to world space)
|
||||
// 禁用预览模式用于 UI 渲染(返回世界空间)
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setPreviewMode(false);
|
||||
}
|
||||
|
||||
// Disable animator system and stop all animations
|
||||
// 禁用动画系统并停止所有动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
// Disable behavior tree system
|
||||
// 禁用行为树系统
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = false;
|
||||
}
|
||||
this.stopAllAnimations();
|
||||
|
||||
// Note: Don't cancel animationFrameId here, as renderLoop should keep running
|
||||
@@ -669,6 +799,42 @@ export class EngineService {
|
||||
return this.renderSystem?.getShowGizmos() ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set UI canvas size for boundary display.
|
||||
* 设置 UI 画布尺寸以显示边界。
|
||||
*/
|
||||
setUICanvasSize(width: number, height: number): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setUICanvasSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI canvas size.
|
||||
* 获取 UI 画布尺寸。
|
||||
*/
|
||||
getUICanvasSize(): { width: number; height: number } {
|
||||
return this.renderSystem?.getUICanvasSize() ?? { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set UI canvas boundary visibility.
|
||||
* 设置 UI 画布边界可见性。
|
||||
*/
|
||||
setShowUICanvasBoundary(show: boolean): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setShowUICanvasBoundary(show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI canvas boundary visibility.
|
||||
* 获取 UI 画布边界可见性。
|
||||
*/
|
||||
getShowUICanvasBoundary(): boolean {
|
||||
return this.renderSystem?.getShowUICanvasBoundary() ?? true;
|
||||
}
|
||||
|
||||
// ===== Scene Snapshot API =====
|
||||
// ===== 场景快照 API =====
|
||||
|
||||
@@ -711,24 +877,18 @@ export class EngineService {
|
||||
// Clear tilemap rendering cache before restoring
|
||||
// 恢复前清除瓦片地图渲染缓存
|
||||
if (this.tilemapSystem) {
|
||||
console.log('[EngineService] Clearing tilemap cache before restore');
|
||||
this.tilemapSystem.clearCache();
|
||||
}
|
||||
|
||||
// Clear UI text cache before restoring
|
||||
// 恢复前清除 UI 文本缓存
|
||||
if (this.uiRenderProvider) {
|
||||
console.log('[EngineService] Clearing UI text cache before restore');
|
||||
this.uiRenderProvider.clearTextCache();
|
||||
}
|
||||
// Clear UI render caches before restoring
|
||||
// 恢复前清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Use SceneSerializer from core library
|
||||
console.log('[EngineService] Deserializing scene snapshot');
|
||||
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {
|
||||
strategy: 'replace',
|
||||
preserveIds: true
|
||||
});
|
||||
console.log('[EngineService] Scene deserialized, entities:', this.scene.entities.buffer.length);
|
||||
|
||||
// 加载场景资源 / Load scene resources
|
||||
if (this.sceneResourceManager) {
|
||||
@@ -762,7 +922,6 @@ export class EngineService {
|
||||
}
|
||||
|
||||
// Notify UI to refresh
|
||||
console.log('[EngineService] Publishing scene:restored event');
|
||||
messageHub.publish('scene:restored', {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
interface ImportMap {
|
||||
imports: Record<string, string>;
|
||||
scopes?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
const SDK_MODULES: Record<string, string> = {
|
||||
'@esengine/editor-runtime': 'editor-runtime.js',
|
||||
'@esengine/behavior-tree': 'behavior-tree.js',
|
||||
};
|
||||
|
||||
class ImportMapManager {
|
||||
private initialized = false;
|
||||
private importMap: ImportMap = { imports: {} };
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.buildImportMap();
|
||||
this.injectImportMap();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[ImportMapManager] Import Map initialized:', this.importMap);
|
||||
}
|
||||
|
||||
private async buildImportMap(): Promise<void> {
|
||||
const baseUrl = this.getBaseUrl();
|
||||
|
||||
for (const [moduleName, fileName] of Object.entries(SDK_MODULES)) {
|
||||
this.importMap.imports[moduleName] = `${baseUrl}assets/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
return window.location.origin + '/';
|
||||
}
|
||||
|
||||
private injectImportMap(): void {
|
||||
const existingMap = document.querySelector('script[type="importmap"]');
|
||||
if (existingMap) {
|
||||
try {
|
||||
const existing = JSON.parse(existingMap.textContent || '{}');
|
||||
this.importMap.imports = { ...existing.imports, ...this.importMap.imports };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
existingMap.remove();
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.type = 'importmap';
|
||||
script.textContent = JSON.stringify(this.importMap, null, 2);
|
||||
|
||||
const head = document.head;
|
||||
const firstScript = head.querySelector('script');
|
||||
if (firstScript) {
|
||||
head.insertBefore(script, firstScript);
|
||||
} else {
|
||||
head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
getImportMap(): ImportMap {
|
||||
return { ...this.importMap };
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
export const importMapManager = new ImportMapManager();
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EditorPluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
|
||||
import type { IEditorPlugin } from '@esengine/editor-core';
|
||||
/**
|
||||
* 项目插件加载器
|
||||
* Project Plugin Loader
|
||||
*/
|
||||
|
||||
import { PluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
|
||||
import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { importMapManager } from './ImportMapManager';
|
||||
import { PluginSDKRegistry } from './PluginSDKRegistry';
|
||||
|
||||
interface PluginPackageJson {
|
||||
name: string;
|
||||
@@ -18,36 +23,45 @@ interface PluginPackageJson {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件加载器
|
||||
* 插件元数据(用于卸载时清理)
|
||||
*/
|
||||
interface LoadedPluginMeta {
|
||||
name: string;
|
||||
scriptElement?: HTMLScriptElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目插件加载器
|
||||
*
|
||||
* 负责从项目的 plugins 目录加载用户插件。
|
||||
* 统一使用 project:// 协议加载预编译的 JS 文件。
|
||||
* 使用全局变量方案加载插件:
|
||||
* 1. 插件构建时将 @esengine/* 标记为 external
|
||||
* 2. 插件输出为 IIFE 格式,依赖从 window.__ESENGINE__ 获取
|
||||
* 3. 插件导出到 window.__ESENGINE_PLUGINS__[pluginName]
|
||||
*/
|
||||
export class PluginLoader {
|
||||
private loadedPluginNames: Set<string> = new Set();
|
||||
private moduleVersions: Map<string, number> = new Map();
|
||||
private loadedPlugins: Map<string, LoadedPluginMeta> = new Map();
|
||||
|
||||
/**
|
||||
* 加载项目中的所有插件
|
||||
*/
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
// 确保 Import Map 已初始化
|
||||
await importMapManager.initialize();
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: PluginManager): Promise<void> {
|
||||
// 确保 SDK 已注册到全局
|
||||
PluginSDKRegistry.initialize();
|
||||
|
||||
// 初始化插件容器
|
||||
this.initPluginContainer();
|
||||
|
||||
const pluginsPath = `${projectPath}/plugins`;
|
||||
|
||||
try {
|
||||
const exists = await TauriAPI.pathExists(pluginsPath);
|
||||
if (!exists) {
|
||||
console.log('[PluginLoader] No plugins directory found');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await TauriAPI.listDirectory(pluginsPath);
|
||||
const pluginDirs = entries.filter((entry) => entry.is_dir && !entry.name.startsWith('.'));
|
||||
|
||||
console.log(`[PluginLoader] Found ${pluginDirs.length} plugin(s)`);
|
||||
|
||||
for (const entry of pluginDirs) {
|
||||
const pluginPath = `${pluginsPath}/${entry.name}`;
|
||||
await this.loadPlugin(pluginPath, entry.name, pluginManager);
|
||||
@@ -57,13 +71,22 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化插件容器
|
||||
*/
|
||||
private initPluginContainer(): void {
|
||||
if (!window.__ESENGINE_PLUGINS__) {
|
||||
window.__ESENGINE_PLUGINS__ = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个插件
|
||||
*/
|
||||
private async loadPlugin(
|
||||
pluginPath: string,
|
||||
pluginDirName: string,
|
||||
pluginManager: EditorPluginManager
|
||||
pluginManager: PluginManager
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. 读取 package.json
|
||||
@@ -72,12 +95,13 @@ export class PluginLoader {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果插件已加载,先卸载
|
||||
if (this.loadedPluginNames.has(packageJson.name)) {
|
||||
await this.unloadPlugin(packageJson.name, pluginManager);
|
||||
// 2. 如果插件已加载,跳过
|
||||
if (this.loadedPlugins.has(packageJson.name)) {
|
||||
console.warn(`[PluginLoader] Plugin ${packageJson.name} already loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 确定入口文件(必须是编译后的 JS)
|
||||
// 3. 确定入口文件
|
||||
const entryPoint = this.resolveEntryPoint(packageJson);
|
||||
|
||||
// 4. 验证文件存在
|
||||
@@ -89,27 +113,35 @@ export class PluginLoader {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 构建模块 URL(使用 project:// 协议)
|
||||
const moduleUrl = this.buildModuleUrl(pluginDirName, entryPoint, packageJson.name);
|
||||
console.log(`[PluginLoader] Loading: ${packageJson.name} from ${moduleUrl}`);
|
||||
// 5. 读取插件代码
|
||||
const pluginCode = await TauriAPI.readFileContent(fullPath);
|
||||
|
||||
// 6. 动态导入模块
|
||||
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||
// 6. 执行插件代码(CSS 应内联到 JS 中,会自动注入)
|
||||
const pluginLoader = await this.executePluginCode(
|
||||
pluginCode,
|
||||
packageJson.name,
|
||||
pluginDirName
|
||||
);
|
||||
|
||||
// 7. 查找并验证插件实例
|
||||
const pluginInstance = this.findPluginInstance(module);
|
||||
if (!pluginInstance) {
|
||||
console.error(`[PluginLoader] No valid plugin instance found in ${packageJson.name}`);
|
||||
if (!pluginLoader) {
|
||||
console.error(`[PluginLoader] No valid plugin found in ${packageJson.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. 安装插件
|
||||
await pluginManager.installEditor(pluginInstance);
|
||||
this.loadedPluginNames.add(packageJson.name);
|
||||
console.log(`[PluginLoader] Successfully loaded: ${packageJson.name}`);
|
||||
// 7. 注册插件
|
||||
pluginManager.register(pluginLoader);
|
||||
|
||||
// 9. 同步语言设置
|
||||
this.syncPluginLocale(pluginInstance, packageJson.name);
|
||||
// 8. 初始化编辑器模块(注册面板、文件处理器等)
|
||||
const pluginId = pluginLoader.descriptor.id;
|
||||
await pluginManager.initializePluginEditor(pluginId, Core.services);
|
||||
|
||||
// 9. 记录已加载
|
||||
this.loadedPlugins.set(packageJson.name, {
|
||||
name: packageJson.name,
|
||||
});
|
||||
|
||||
// 10. 同步语言设置
|
||||
this.syncPluginLocale(pluginLoader, packageJson.name);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);
|
||||
@@ -119,6 +151,82 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行插件代码并返回插件加载器
|
||||
*/
|
||||
private async executePluginCode(
|
||||
code: string,
|
||||
pluginName: string,
|
||||
_pluginDirName: string
|
||||
): Promise<IPluginLoader | null> {
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
|
||||
try {
|
||||
// 插件代码是 IIFE 格式,会自动导出到 window.__ESENGINE_PLUGINS__
|
||||
await this.executeViaScriptTag(code, pluginName);
|
||||
|
||||
// 从全局容器获取插件模块
|
||||
const pluginModule = window.__ESENGINE_PLUGINS__[pluginKey];
|
||||
if (!pluginModule) {
|
||||
// 尝试其他可能的 key 格式
|
||||
const altKeys = Object.keys(window.__ESENGINE_PLUGINS__).filter(k =>
|
||||
k.includes(pluginName.replace(/@/g, '').replace(/\//g, '_').replace(/-/g, '_'))
|
||||
);
|
||||
|
||||
if (altKeys.length > 0 && altKeys[0] !== undefined) {
|
||||
const foundKey = altKeys[0];
|
||||
const altModule = window.__ESENGINE_PLUGINS__[foundKey];
|
||||
return this.findPluginLoader(altModule);
|
||||
}
|
||||
|
||||
console.error(`[PluginLoader] Plugin ${pluginName} did not export to __ESENGINE_PLUGINS__`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findPluginLoader(pluginModule);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to execute plugin code for ${pluginName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 script 标签执行代码
|
||||
*/
|
||||
private executeViaScriptTag(code: string, pluginName: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
|
||||
const blob = new Blob([code], { type: 'application/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = `plugin-${pluginKey}`;
|
||||
script.async = false;
|
||||
|
||||
script.onload = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = (e) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error(`Script load failed: ${e}`));
|
||||
};
|
||||
|
||||
script.src = blobUrl;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理插件名称为有效的 key
|
||||
*/
|
||||
private sanitizePluginKey(pluginName: string): string {
|
||||
return pluginName.replace(/[@/\-.]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件的 package.json
|
||||
*/
|
||||
@@ -136,7 +244,7 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析插件入口文件路径(始终使用编译后的 JS)
|
||||
* 解析插件入口文件路径
|
||||
*/
|
||||
private resolveEntryPoint(packageJson: PluginPackageJson): string {
|
||||
const entry = (
|
||||
@@ -149,41 +257,18 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模块 URL(使用 project:// 协议)
|
||||
*
|
||||
* Windows 上需要用 http://project.localhost/ 格式
|
||||
* macOS/Linux 上用 project://localhost/ 格式
|
||||
* 查找模块中的插件加载器
|
||||
*/
|
||||
private buildModuleUrl(pluginDirName: string, entryPoint: string, pluginName: string): string {
|
||||
// 版本号 + 时间戳确保每次加载都是新模块(绕过浏览器缓存)
|
||||
const version = (this.moduleVersions.get(pluginName) || 0) + 1;
|
||||
this.moduleVersions.set(pluginName, version);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const path = `/plugins/${pluginDirName}/${entryPoint}?v=${version}&t=${timestamp}`;
|
||||
|
||||
// Windows 使用 http://scheme.localhost 格式
|
||||
// macOS/Linux 使用 scheme://localhost 格式
|
||||
const isWindows = navigator.userAgent.includes('Windows');
|
||||
if (isWindows) {
|
||||
return `http://project.localhost${path}`;
|
||||
}
|
||||
return `project://localhost${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找模块中的插件实例
|
||||
*/
|
||||
private findPluginInstance(module: any): IEditorPlugin | null {
|
||||
private findPluginLoader(module: any): IPluginLoader | null {
|
||||
// 优先检查 default 导出
|
||||
if (module.default && this.isPluginInstance(module.default)) {
|
||||
if (module.default && this.isPluginLoader(module.default)) {
|
||||
return module.default;
|
||||
}
|
||||
|
||||
// 检查命名导出
|
||||
// 检查命名导出(常见的命名:Plugin, XXXPlugin)
|
||||
for (const key of Object.keys(module)) {
|
||||
const value = module[key];
|
||||
if (value && this.isPluginInstance(value)) {
|
||||
if (value && this.isPluginLoader(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -192,33 +277,43 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象是否为有效的插件实例
|
||||
* 验证对象是否为有效的插件加载器
|
||||
*/
|
||||
private isPluginInstance(obj: any): obj is IEditorPlugin {
|
||||
private isPluginLoader(obj: any): obj is IPluginLoader {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 新的 IPluginLoader 接口检查
|
||||
if (obj.descriptor && this.isPluginDescriptor(obj.descriptor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象是否为有效的插件描述符
|
||||
*/
|
||||
private isPluginDescriptor(obj: any): obj is PluginDescriptor {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.version === 'string' &&
|
||||
typeof obj.displayName === 'string' &&
|
||||
typeof obj.category === 'string' &&
|
||||
typeof obj.install === 'function' &&
|
||||
typeof obj.uninstall === 'function'
|
||||
typeof obj.version === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步插件语言设置
|
||||
*/
|
||||
private syncPluginLocale(plugin: IEditorPlugin, pluginName: string): void {
|
||||
private syncPluginLocale(plugin: IPluginLoader, pluginName: string): void {
|
||||
try {
|
||||
const localeService = Core.services.resolve(LocaleService);
|
||||
const currentLocale = localeService.getCurrentLocale();
|
||||
|
||||
if (plugin.setLocale) {
|
||||
plugin.setLocale(currentLocale);
|
||||
if (plugin.editorModule?.setLocale) {
|
||||
plugin.editorModule.setLocale(currentLocale);
|
||||
}
|
||||
|
||||
// 通知 UI 刷新
|
||||
@@ -229,33 +324,37 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个插件
|
||||
*/
|
||||
private async unloadPlugin(pluginName: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
try {
|
||||
await pluginManager.uninstallEditor(pluginName);
|
||||
this.loadedPluginNames.delete(pluginName);
|
||||
console.log(`[PluginLoader] Unloaded: ${pluginName}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to unload ${pluginName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载所有已加载的插件
|
||||
*/
|
||||
async unloadProjectPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
for (const pluginName of this.loadedPluginNames) {
|
||||
await this.unloadPlugin(pluginName, pluginManager);
|
||||
async unloadProjectPlugins(_pluginManager: PluginManager): Promise<void> {
|
||||
for (const pluginName of this.loadedPlugins.keys()) {
|
||||
// 清理全局容器中的插件
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
if (window.__ESENGINE_PLUGINS__?.[pluginKey]) {
|
||||
delete window.__ESENGINE_PLUGINS__[pluginKey];
|
||||
}
|
||||
|
||||
// 移除 script 标签
|
||||
const scriptEl = document.getElementById(`plugin-${pluginKey}`);
|
||||
if (scriptEl) {
|
||||
scriptEl.remove();
|
||||
}
|
||||
}
|
||||
this.loadedPluginNames.clear();
|
||||
this.loadedPlugins.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件名称列表
|
||||
*/
|
||||
getLoadedPluginNames(): string[] {
|
||||
return Array.from(this.loadedPluginNames);
|
||||
return Array.from(this.loadedPlugins.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// 全局类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
__ESENGINE_PLUGINS__: Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { EditorPluginManager } from '@esengine/editor-core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface PluginAuthor {
|
||||
name: string;
|
||||
github: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface PluginRepository {
|
||||
type?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginVersion {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
changes: string;
|
||||
zipUrl: string;
|
||||
requirements: PluginRequirements;
|
||||
}
|
||||
|
||||
export interface PluginRequirements {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
author: PluginAuthor;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
repository: PluginRepository;
|
||||
license: string;
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
latestVersion: string;
|
||||
versions: PluginVersion[];
|
||||
verified?: boolean;
|
||||
category_type?: 'official' | 'community';
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
cdn: string;
|
||||
plugins: PluginMarketMetadata[];
|
||||
}
|
||||
|
||||
interface InstalledPluginInfo {
|
||||
id: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
export class PluginMarketService {
|
||||
private readonly REGISTRY_URLS = [
|
||||
'https://cdn.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json',
|
||||
'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json',
|
||||
'https://fastly.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json'
|
||||
];
|
||||
|
||||
private readonly GITHUB_DIRECT_URL = 'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json';
|
||||
|
||||
private readonly STORAGE_KEY = 'ecs-editor-installed-marketplace-plugins';
|
||||
private readonly USE_DIRECT_SOURCE_KEY = 'ecs-editor-use-direct-source';
|
||||
|
||||
private pluginManager: EditorPluginManager;
|
||||
private installedPlugins: Map<string, InstalledPluginInfo> = new Map();
|
||||
private projectPath: string | null = null;
|
||||
|
||||
constructor(pluginManager: EditorPluginManager) {
|
||||
this.pluginManager = pluginManager;
|
||||
this.loadInstalledPlugins();
|
||||
}
|
||||
|
||||
setProjectPath(path: string | null): void {
|
||||
this.projectPath = path;
|
||||
}
|
||||
|
||||
isUsingDirectSource(): boolean {
|
||||
return localStorage.getItem(this.USE_DIRECT_SOURCE_KEY) === 'true';
|
||||
}
|
||||
|
||||
setUseDirectSource(useDirect: boolean): void {
|
||||
localStorage.setItem(this.USE_DIRECT_SOURCE_KEY, String(useDirect));
|
||||
}
|
||||
|
||||
async fetchPluginList(bypassCache: boolean = false): Promise<PluginMarketMetadata[]> {
|
||||
const useDirectSource = this.isUsingDirectSource();
|
||||
|
||||
if (useDirectSource) {
|
||||
return await this.fetchFromUrl(this.GITHUB_DIRECT_URL, bypassCache);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < this.REGISTRY_URLS.length; i++) {
|
||||
try {
|
||||
const url = this.REGISTRY_URLS[i];
|
||||
if (!url) continue;
|
||||
|
||||
const plugins = await this.fetchFromUrl(url, bypassCache, i + 1, this.REGISTRY_URLS.length);
|
||||
return plugins;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[PluginMarketService] Failed to fetch from URL ${i + 1}: ${errorMessage}`);
|
||||
errors.push(`URL ${i + 1}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalError = `无法从任何数据源加载插件列表。尝试的错误:\n${errors.join('\n')}`;
|
||||
console.error('[PluginMarketService] All URLs failed:', finalError);
|
||||
throw new Error(finalError);
|
||||
}
|
||||
|
||||
private async fetchFromUrl(
|
||||
baseUrl: string,
|
||||
bypassCache: boolean,
|
||||
urlIndex?: number,
|
||||
totalUrls?: number
|
||||
): Promise<PluginMarketMetadata[]> {
|
||||
let url = baseUrl;
|
||||
if (bypassCache) {
|
||||
url += `?t=${Date.now()}`;
|
||||
if (urlIndex && totalUrls) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const registry: PluginRegistry = await response.json();
|
||||
return registry.plugins;
|
||||
}
|
||||
|
||||
async installPlugin(plugin: PluginMarketMetadata, version?: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
const targetVersion = version || plugin.latestVersion;
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened. Please open a project first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取指定版本信息
|
||||
const versionInfo = plugin.versions.find((v) => v.version === targetVersion);
|
||||
if (!versionInfo) {
|
||||
throw new Error(`Version ${targetVersion} not found for plugin ${plugin.name}`);
|
||||
}
|
||||
|
||||
// 下载 ZIP 文件
|
||||
const zipBlob = await this.downloadZip(versionInfo.zipUrl);
|
||||
|
||||
// 解压到项目 plugins 目录
|
||||
await this.extractZipToProject(zipBlob, plugin.id);
|
||||
|
||||
// 标记为已安装
|
||||
this.markAsInstalled(plugin, targetVersion);
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
await onReload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to install plugin ${plugin.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallPlugin(pluginId: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened');
|
||||
}
|
||||
|
||||
try {
|
||||
// 从编辑器卸载
|
||||
await this.pluginManager.uninstallEditor(pluginId);
|
||||
|
||||
// 调用 Tauri 后端命令删除插件目录
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('uninstall_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId
|
||||
});
|
||||
|
||||
// 从已安装列表移除
|
||||
this.installedPlugins.delete(pluginId);
|
||||
this.saveInstalledPlugins();
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
await onReload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to uninstall plugin ${pluginId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
isInstalled(pluginId: string): boolean {
|
||||
return this.installedPlugins.has(pluginId);
|
||||
}
|
||||
|
||||
getInstalledVersion(pluginId: string): string | undefined {
|
||||
return this.installedPlugins.get(pluginId)?.version;
|
||||
}
|
||||
|
||||
hasUpdate(plugin: PluginMarketMetadata): boolean {
|
||||
const installedVersion = this.getInstalledVersion(plugin.id);
|
||||
if (!installedVersion) return false;
|
||||
|
||||
return this.compareVersions(plugin.latestVersion, installedVersion) > 0;
|
||||
}
|
||||
|
||||
private async downloadZip(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ZIP: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
private async extractZipToProject(zipBlob: Blob, pluginId: string): Promise<void> {
|
||||
if (!this.projectPath) {
|
||||
throw new Error('Project path not set');
|
||||
}
|
||||
|
||||
try {
|
||||
// 将 Blob 转换为 ArrayBuffer
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// 转换为 base64
|
||||
let binary = '';
|
||||
const len = uint8Array.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(uint8Array[i] ?? 0);
|
||||
}
|
||||
const base64Data = btoa(binary);
|
||||
|
||||
// 调用 Tauri 后端命令进行安装
|
||||
await invoke<string>('install_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId,
|
||||
zipDataBase64: base64Data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to extract ZIP:', error);
|
||||
throw new Error(`Failed to extract plugin: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private markAsInstalled(plugin: PluginMarketMetadata, version: string): void {
|
||||
this.installedPlugins.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
version: version,
|
||||
installedAt: new Date().toISOString()
|
||||
});
|
||||
this.saveInstalledPlugins();
|
||||
}
|
||||
|
||||
private loadInstalledPlugins(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
const plugins: InstalledPluginInfo[] = JSON.parse(stored);
|
||||
this.installedPlugins = new Map(plugins.map((p) => [p.id, p]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to load installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveInstalledPlugins(): void {
|
||||
try {
|
||||
const plugins = Array.from(this.installedPlugins.values());
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(plugins));
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to save installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
import { GitHubService } from './GitHubService';
|
||||
import type { IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
|
||||
export interface PluginPublishInfo {
|
||||
pluginMetadata: IEditorPluginMetadata;
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
repositoryUrl: string;
|
||||
category: 'official' | 'community';
|
||||
tags?: string[];
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
requirements?: {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PublishStep =
|
||||
| 'checking-fork'
|
||||
| 'creating-fork'
|
||||
| 'checking-branch'
|
||||
| 'creating-branch'
|
||||
| 'creating-manifest'
|
||||
| 'uploading-files'
|
||||
| 'creating-pr'
|
||||
| 'complete';
|
||||
|
||||
export interface PublishProgress {
|
||||
step: PublishStep;
|
||||
message: string;
|
||||
progress: number; // 0-100
|
||||
}
|
||||
|
||||
export class PluginPublishService {
|
||||
private readonly REGISTRY_OWNER = 'esengine';
|
||||
private readonly REGISTRY_REPO = 'ecs-editor-plugins';
|
||||
|
||||
private githubService: GitHubService;
|
||||
private progressCallback?: (progress: PublishProgress) => void;
|
||||
|
||||
constructor(githubService: GitHubService) {
|
||||
this.githubService = githubService;
|
||||
}
|
||||
|
||||
setProgressCallback(callback: (progress: PublishProgress) => void): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
private notifyProgress(step: PublishStep, message: string, progress: number): void {
|
||||
this.progressCallback?.({ step, message, progress });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布插件到市场
|
||||
* @param publishInfo 插件发布信息
|
||||
* @param zipPath 插件 ZIP 文件路径(必需)
|
||||
* @returns Pull Request URL
|
||||
*/
|
||||
async publishPlugin(publishInfo: PluginPublishInfo, zipPath: string): Promise<string> {
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
try {
|
||||
const { branchName, existingPR } = await this.preparePublishEnvironment(
|
||||
publishInfo.pluginMetadata.name,
|
||||
publishInfo.version
|
||||
);
|
||||
|
||||
const user = this.githubService.getUser()!;
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
|
||||
// 上传 ZIP 文件
|
||||
await this.uploadZipFile(user.login, branchName, pluginId, publishInfo, zipPath);
|
||||
|
||||
// 生成并上传 manifest
|
||||
this.notifyProgress('creating-manifest', 'Generating manifest.json...', 60);
|
||||
const manifest = this.generateManifest(publishInfo, user.login);
|
||||
const manifestPath = `plugins/${publishInfo.category}/${pluginId}/manifest.json`;
|
||||
|
||||
await this.uploadManifest(user.login, branchName, manifestPath, manifest, publishInfo);
|
||||
|
||||
// 创建或更新 PR
|
||||
return await this.createOrUpdatePR(existingPR, branchName, publishInfo, user.login);
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to publish plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async preparePublishEnvironment(
|
||||
pluginName: string,
|
||||
version: string
|
||||
): Promise<{ branchName: string; existingPR: { number: number; html_url: string } | null }> {
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 10);
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 15);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 12);
|
||||
await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 15);
|
||||
}
|
||||
|
||||
const branchName = `add-plugin-${pluginName}-v${version}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 20);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
headBranch
|
||||
);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress(
|
||||
'checking-branch',
|
||||
`Branch and PR already exist, will update existing PR #${existingPR.number}`,
|
||||
30
|
||||
);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 30);
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 25);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}'...`, 27);
|
||||
try {
|
||||
await this.githubService.createBranch(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
branchName,
|
||||
'main'
|
||||
);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 30);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { branchName, existingPR };
|
||||
}
|
||||
|
||||
private generatePluginId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
private async uploadZipFile(
|
||||
owner: string,
|
||||
branch: string,
|
||||
pluginId: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
zipPath: string
|
||||
): Promise<void> {
|
||||
const { TauriAPI } = await import('../api/tauri');
|
||||
const base64Zip = await TauriAPI.readFileAsBase64(zipPath);
|
||||
|
||||
this.notifyProgress('uploading-files', 'Uploading plugin ZIP file...', 30);
|
||||
|
||||
const zipFilePath = `plugins/${publishInfo.category}/${pluginId}/versions/${publishInfo.version}.zip`;
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateBinaryFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
zipFilePath,
|
||||
base64Zip,
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version} ZIP`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'ZIP file uploaded successfully', 55);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload ZIP: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExistingManifest(
|
||||
pluginId: string,
|
||||
category: 'official' | 'community'
|
||||
): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const manifestPath = `plugins/${category}/${pluginId}/manifest.json`;
|
||||
const content = await this.githubService.getFileContent(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
'main'
|
||||
);
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadManifest(
|
||||
owner: string,
|
||||
branch: string,
|
||||
manifestPath: string,
|
||||
manifest: Record<string, unknown>,
|
||||
publishInfo: PluginPublishInfo
|
||||
): Promise<void> {
|
||||
this.notifyProgress('uploading-files', 'Checking for existing manifest...', 65);
|
||||
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
const existingManifest = await this.getExistingManifest(pluginId, publishInfo.category);
|
||||
|
||||
let finalManifest = manifest;
|
||||
|
||||
if (existingManifest) {
|
||||
this.notifyProgress('uploading-files', 'Merging with existing manifest...', 68);
|
||||
finalManifest = this.mergeManifestVersions(existingManifest, manifest, publishInfo.version);
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', `Uploading manifest to ${manifestPath}...`, 70);
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
JSON.stringify(finalManifest, null, 2),
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'Manifest uploaded successfully', 80);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload manifest: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeManifestVersions(
|
||||
existingManifest: Record<string, any>,
|
||||
newManifest: Record<string, any>,
|
||||
newVersion: string
|
||||
): Record<string, any> {
|
||||
const existingVersions: any[] = Array.isArray(existingManifest.versions)
|
||||
? existingManifest.versions
|
||||
: [];
|
||||
|
||||
const newVersionInfo = (newManifest.versions as any[])[0];
|
||||
|
||||
const versionExists = existingVersions.some((v: any) => v.version === newVersion);
|
||||
|
||||
let updatedVersions: any[];
|
||||
if (versionExists) {
|
||||
updatedVersions = existingVersions.map((v: any) =>
|
||||
v.version === newVersion ? newVersionInfo : v
|
||||
);
|
||||
} else {
|
||||
updatedVersions = [...existingVersions, newVersionInfo];
|
||||
}
|
||||
|
||||
updatedVersions.sort((a: any, b: any) => {
|
||||
const [aMajor, aMinor, aPatch] = a.version.split('.').map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.version.split('.').map(Number);
|
||||
|
||||
if (aMajor !== bMajor) return bMajor - aMajor;
|
||||
if (aMinor !== bMinor) return bMinor - aMinor;
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
|
||||
const mergedManifest: any = {
|
||||
...existingManifest,
|
||||
...newManifest,
|
||||
latestVersion: updatedVersions[0].version,
|
||||
versions: updatedVersions
|
||||
};
|
||||
|
||||
delete mergedManifest.version;
|
||||
delete mergedManifest.distribution;
|
||||
|
||||
return mergedManifest as Record<string, any>;
|
||||
}
|
||||
|
||||
private async createOrUpdatePR(
|
||||
existingPR: { number: number; html_url: string } | null,
|
||||
branchName: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
userLogin: string
|
||||
): Promise<string> {
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Add plugin: ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`;
|
||||
const prBody = this.generatePRDescription(publishInfo);
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${userLogin}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
}
|
||||
|
||||
private generateManifest(publishInfo: PluginPublishInfo, githubUsername: string): Record<string, unknown> {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category, tags, homepage, screenshots, requirements } =
|
||||
publishInfo;
|
||||
|
||||
const repoMatch = repositoryUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!repoMatch || !repoMatch[1] || !repoMatch[2]) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
|
||||
const owner = repoMatch[1];
|
||||
const repo = repoMatch[2];
|
||||
const repoName = repo.replace(/\.git$/, '');
|
||||
|
||||
const pluginId = this.generatePluginId(pluginMetadata.name);
|
||||
|
||||
const zipUrl = `https://cdn.jsdelivr.net/gh/${this.REGISTRY_OWNER}/${this.REGISTRY_REPO}@gh-pages/plugins/${category}/${pluginId}/versions/${version}.zip`;
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
'editor': 'Window',
|
||||
'tool': 'Tool',
|
||||
'inspector': 'Inspector',
|
||||
'system': 'System',
|
||||
'import-export': 'ImportExport'
|
||||
};
|
||||
|
||||
const validCategory = categoryMap[pluginMetadata.category?.toLowerCase() || ''] || 'Tool';
|
||||
|
||||
const versionInfo = {
|
||||
version: version,
|
||||
releaseDate: new Date().toISOString(),
|
||||
changes: releaseNotes || 'No release notes provided',
|
||||
zipUrl: zipUrl,
|
||||
requirements: requirements || {
|
||||
'ecs-version': '>=1.0.0',
|
||||
'editor-version': '>=1.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginMetadata.displayName,
|
||||
latestVersion: version,
|
||||
versions: [versionInfo],
|
||||
author: {
|
||||
name: githubUsername,
|
||||
github: githubUsername
|
||||
},
|
||||
description: pluginMetadata.description || 'No description provided',
|
||||
category: validCategory,
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: repositoryUrl
|
||||
},
|
||||
license: 'MIT',
|
||||
tags: tags || [],
|
||||
icon: pluginMetadata.icon || 'Package',
|
||||
homepage: homepage || repositoryUrl,
|
||||
screenshots: screenshots || []
|
||||
};
|
||||
}
|
||||
|
||||
private generatePRDescription(publishInfo: PluginPublishInfo): string {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category } = publishInfo;
|
||||
|
||||
return `## Plugin Submission
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginMetadata.displayName}
|
||||
- **ID**: ${pluginMetadata.name}
|
||||
- **Version**: ${version}
|
||||
- **Category**: ${category}
|
||||
- **Repository**: ${repositoryUrl}
|
||||
|
||||
### Description
|
||||
|
||||
${pluginMetadata.description || 'No description provided'}
|
||||
|
||||
### Release Notes
|
||||
|
||||
${releaseNotes}
|
||||
|
||||
### Checklist
|
||||
|
||||
- [x] Plugin is built and tested
|
||||
- [x] Repository is publicly accessible
|
||||
- [x] Manifest.json is correctly formatted
|
||||
- [ ] Code has been reviewed for security concerns
|
||||
- [ ] Plugin follows ECS Editor plugin guidelines
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Publisher**
|
||||
`;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async deletePlugin(pluginId: string, pluginName: string, category: 'official' | 'community', reason: string, forceRecreate: boolean = false): Promise<string> {
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 5);
|
||||
|
||||
try {
|
||||
let forkedRepo: string;
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
forkedRepo = `${user.login}/${this.REGISTRY_REPO}`;
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 10);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 7);
|
||||
forkedRepo = await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 10);
|
||||
}
|
||||
|
||||
const branchName = `remove-plugin-${pluginId}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 15);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
if (forceRecreate) {
|
||||
this.notifyProgress('checking-branch', 'Deleting old branch to recreate...', 16);
|
||||
await this.githubService.deleteBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = false;
|
||||
this.notifyProgress('checking-branch', 'Old branch deleted', 17);
|
||||
} else {
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(this.REGISTRY_OWNER, this.REGISTRY_REPO, headBranch);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress('checking-branch', `Branch and PR already exist, will update existing PR #${existingPR.number}`, 20);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 20);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 18);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}' from main repository...`, 19);
|
||||
|
||||
try {
|
||||
const mainRef = await this.githubService.getRef(this.REGISTRY_OWNER, this.REGISTRY_REPO, 'heads/main');
|
||||
await this.githubService.createBranchFromSha(user.login, this.REGISTRY_REPO, branchName, mainRef.object.sha);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 20);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', 'Collecting plugin files...', 25);
|
||||
|
||||
const pluginPath = `plugins/${category}/${pluginId}`;
|
||||
|
||||
const contents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
pluginPath,
|
||||
'main'
|
||||
);
|
||||
|
||||
if (contents.length === 0) {
|
||||
throw new Error(`Plugin directory not found: ${pluginPath}`);
|
||||
}
|
||||
|
||||
const filesToDelete: Array<{ path: string; sha: string }> = [];
|
||||
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
filesToDelete.push({ path: item.path, sha: item.sha });
|
||||
} else if (item.type === 'dir') {
|
||||
const subContents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
item.path,
|
||||
'main'
|
||||
);
|
||||
for (const subItem of subContents) {
|
||||
if (subItem.type === 'file') {
|
||||
filesToDelete.push({ path: subItem.path, sha: subItem.sha });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
throw new Error(`No files found to delete in ${pluginPath}`);
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', `Deleting ${filesToDelete.length} files...`, 40);
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await this.githubService.deleteFileWithSha(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
file.path,
|
||||
file.sha,
|
||||
`Remove ${pluginName}`,
|
||||
branchName
|
||||
);
|
||||
deletedCount++;
|
||||
const progress = 40 + Math.floor((deletedCount / filesToDelete.length) * 40);
|
||||
this.notifyProgress('uploading-files', `Deleted ${deletedCount}/${filesToDelete.length} files`, progress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[PluginPublishService] Failed to delete ${file.path}:`, errorMsg);
|
||||
errors.push(`${file.path}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to delete ${errors.length} file(s):\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('No files were deleted');
|
||||
}
|
||||
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Remove plugin: ${pluginName}`;
|
||||
const prBody = `## Plugin Removal Request
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginName}
|
||||
- **ID**: ${pluginId}
|
||||
- **Category**: ${category}
|
||||
|
||||
### Reason for Removal
|
||||
|
||||
${reason}
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Manager**
|
||||
`;
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${user.login}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to delete plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Plugin SDK Registry
|
||||
* 插件 SDK 注册器
|
||||
*
|
||||
* 将编辑器核心模块暴露为全局变量,供插件使用。
|
||||
* 插件构建时将这些模块标记为 external,运行时从全局对象获取。
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 编辑器启动时调用 PluginSDKRegistry.initialize()
|
||||
* 2. 插件构建配置中设置 external: ['@esengine/editor-runtime', ...]
|
||||
* 3. 插件构建配置中设置 globals: { '@esengine/editor-runtime': '__ESENGINE__.editorRuntime' }
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
|
||||
// 导入所有需要暴露给插件的模块
|
||||
import * as editorRuntime from '@esengine/editor-runtime';
|
||||
import * as ecsFramework from '@esengine/ecs-framework';
|
||||
import * as behaviorTree from '@esengine/behavior-tree';
|
||||
import * as ecsComponents from '@esengine/ecs-components';
|
||||
|
||||
// 存储服务实例引用(在初始化时设置)
|
||||
let entityStoreInstance: EntityStoreService | null = null;
|
||||
let messageHubInstance: MessageHub | null = null;
|
||||
|
||||
// SDK 模块映射
|
||||
const SDK_MODULES = {
|
||||
'@esengine/editor-runtime': editorRuntime,
|
||||
'@esengine/ecs-framework': ecsFramework,
|
||||
'@esengine/behavior-tree': behaviorTree,
|
||||
'@esengine/ecs-components': ecsComponents,
|
||||
} as const;
|
||||
|
||||
// 全局变量名称映射(用于插件构建配置)
|
||||
export const SDK_GLOBALS = {
|
||||
'@esengine/editor-runtime': '__ESENGINE__.editorRuntime',
|
||||
'@esengine/ecs-framework': '__ESENGINE__.ecsFramework',
|
||||
'@esengine/behavior-tree': '__ESENGINE__.behaviorTree',
|
||||
'@esengine/ecs-components': '__ESENGINE__.ecsComponents',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 插件 API 接口
|
||||
* 为插件提供统一的访问接口,避免模块实例不一致的问题
|
||||
*/
|
||||
export interface IPluginAPI {
|
||||
/** 获取当前场景 */
|
||||
getScene(): any;
|
||||
/** 获取 EntityStoreService */
|
||||
getEntityStore(): EntityStoreService;
|
||||
/** 获取 MessageHub */
|
||||
getMessageHub(): MessageHub;
|
||||
/** 解析服务 */
|
||||
resolveService<T>(serviceType: any): T;
|
||||
/** 获取 Core 实例 */
|
||||
getCore(): typeof Core;
|
||||
}
|
||||
|
||||
// 扩展 Window.__ESENGINE__ 类型(基础类型已在 PluginAPI.ts 中定义)
|
||||
interface ESEngineGlobal {
|
||||
editorRuntime: typeof editorRuntime;
|
||||
ecsFramework: typeof ecsFramework;
|
||||
behaviorTree: typeof behaviorTree;
|
||||
ecsComponents: typeof ecsComponents;
|
||||
require: (moduleName: string) => any;
|
||||
api: IPluginAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件 SDK 注册器
|
||||
*/
|
||||
export class PluginSDKRegistry {
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 SDK 注册器
|
||||
* 将所有 SDK 模块暴露到全局对象
|
||||
*/
|
||||
static initialize(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取服务实例(使用编辑器内部的类型,确保类型匹配)
|
||||
entityStoreInstance = Core.services.resolve(EntityStoreService);
|
||||
messageHubInstance = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStoreInstance) {
|
||||
console.error('[PluginSDKRegistry] EntityStoreService not registered yet!');
|
||||
}
|
||||
if (!messageHubInstance) {
|
||||
console.error('[PluginSDKRegistry] MessageHub not registered yet!');
|
||||
}
|
||||
|
||||
// 创建插件 API - 直接返回实例引用,避免类型匹配问题
|
||||
const pluginAPI: IPluginAPI = {
|
||||
getScene: () => Core.scene,
|
||||
getEntityStore: () => {
|
||||
if (!entityStoreInstance) {
|
||||
throw new Error('[PluginAPI] EntityStoreService not initialized');
|
||||
}
|
||||
return entityStoreInstance;
|
||||
},
|
||||
getMessageHub: () => {
|
||||
if (!messageHubInstance) {
|
||||
throw new Error('[PluginAPI] MessageHub not initialized');
|
||||
}
|
||||
return messageHubInstance;
|
||||
},
|
||||
resolveService: <T>(serviceType: any): T => Core.services.resolve(serviceType) as T,
|
||||
getCore: () => Core,
|
||||
};
|
||||
|
||||
// 创建全局命名空间
|
||||
window.__ESENGINE__ = {
|
||||
editorRuntime,
|
||||
ecsFramework,
|
||||
behaviorTree,
|
||||
ecsComponents,
|
||||
require: this.requireModule.bind(this),
|
||||
api: pluginAPI,
|
||||
};
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态获取模块(用于 CommonJS 风格的插件)
|
||||
*/
|
||||
private static requireModule(moduleName: string): any {
|
||||
const module = SDK_MODULES[moduleName as keyof typeof SDK_MODULES];
|
||||
if (!module) {
|
||||
throw new Error(`[PluginSDKRegistry] Unknown module: ${moduleName}`);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的 SDK 模块名称
|
||||
*/
|
||||
static getAvailableModules(): string[] {
|
||||
return Object.keys(SDK_MODULES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局变量映射(用于生成插件构建配置)
|
||||
*/
|
||||
static getGlobalsConfig(): Record<string, string> {
|
||||
return { ...SDK_GLOBALS };
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,11 @@ export class ProfilerService {
|
||||
await invoke<string>('start_profiler_server', { port });
|
||||
this.isServerRunning = true;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
// Ignore "already running" error - it's expected in some cases
|
||||
const errorMessage = String(error);
|
||||
if (!errorMessage.includes('already running')) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*
|
||||
* Resolves runtime module paths based on environment and configuration
|
||||
* 根据环境和配置解析运行时模块路径
|
||||
*
|
||||
* 运行时文件打包在编辑器内,离线可用
|
||||
*/
|
||||
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
@@ -14,14 +16,18 @@ const sanitizePath = (path: string): string => {
|
||||
const segments = path.split(/[/\\]/).filter((segment) =>
|
||||
segment !== '..' && segment !== '.' && segment !== ''
|
||||
);
|
||||
return segments.join('/');
|
||||
// Use Windows backslash for consistency
|
||||
return segments.join('\\');
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = (): boolean => {
|
||||
try {
|
||||
// Vite environment variable
|
||||
return (import.meta as any).env?.DEV === true;
|
||||
// Vite environment variable - this is the most reliable check
|
||||
const viteDev = (import.meta as any).env?.DEV === true;
|
||||
// Also check if MODE is 'development'
|
||||
const viteMode = (import.meta as any).env?.MODE === 'development';
|
||||
return viteDev || viteMode;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -44,6 +50,7 @@ export class RuntimeResolver {
|
||||
private static instance: RuntimeResolver;
|
||||
private config: RuntimeConfig | null = null;
|
||||
private baseDir: string = '';
|
||||
private isDev: boolean = false; // Store dev mode state at initialization time
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -70,19 +77,35 @@ export class RuntimeResolver {
|
||||
}
|
||||
this.config = await response.json();
|
||||
|
||||
// Determine base directory based on environment
|
||||
if (isDevelopment()) {
|
||||
// In development, use the project root
|
||||
// We need to go up from src-tauri to get the actual project root
|
||||
const currentDir = await TauriAPI.getCurrentDir();
|
||||
// currentDir might be src-tauri, so we need to find the actual workspace root
|
||||
this.baseDir = await this.findWorkspaceRoot(currentDir);
|
||||
// 查找 workspace 根目录
|
||||
const currentDir = await TauriAPI.getCurrentDir();
|
||||
const workspaceRoot = await this.findWorkspaceRoot(currentDir);
|
||||
|
||||
// 优先使用 workspace 中的开发文件(如果存在)
|
||||
// Prefer workspace dev files if they exist
|
||||
if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) {
|
||||
this.baseDir = workspaceRoot;
|
||||
this.isDev = true;
|
||||
console.log(`[RuntimeResolver] Using workspace dev files: ${this.baseDir}`);
|
||||
} else {
|
||||
// In production, use the resource directory
|
||||
// 回退到打包的资源目录(生产模式)
|
||||
this.baseDir = await TauriAPI.getAppResourceDir();
|
||||
this.isDev = false;
|
||||
console.log(`[RuntimeResolver] Using bundled resource dir: ${this.baseDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime files exist in workspace
|
||||
* 检查 workspace 中是否存在运行时文件
|
||||
*/
|
||||
private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise<boolean> {
|
||||
const runtimePath = `${workspaceRoot}\\packages\\platform-web\\dist\\runtime.browser.js`;
|
||||
const exists = await TauriAPI.pathExists(runtimePath);
|
||||
console.log(`[RuntimeResolver] Checking workspace runtime: ${runtimePath} -> ${exists}`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find workspace root by looking for package.json or specific markers
|
||||
* 通过查找 package.json 或特定标记来找到工作区根目录
|
||||
@@ -140,14 +163,14 @@ export class RuntimeResolver {
|
||||
throw new Error(`Runtime module ${moduleName} not found in configuration`);
|
||||
}
|
||||
|
||||
const isDev = isDevelopment();
|
||||
const files: string[] = [];
|
||||
let sourcePath: string;
|
||||
|
||||
if (isDev) {
|
||||
if (this.isDev) {
|
||||
// Development mode - use relative paths from workspace root
|
||||
const devPath = moduleConfig.development.path;
|
||||
sourcePath = `${this.baseDir}\\packages\\${sanitizePath(devPath)}`;
|
||||
const sanitizedPath = sanitizePath(devPath);
|
||||
sourcePath = `${this.baseDir}\\packages\\${sanitizedPath}`;
|
||||
|
||||
if (moduleConfig.main) {
|
||||
files.push(`${sourcePath}\\${moduleConfig.main}`);
|
||||
@@ -181,8 +204,14 @@ export class RuntimeResolver {
|
||||
/**
|
||||
* Prepare runtime files for browser preview
|
||||
* 为浏览器预览准备运行时文件
|
||||
*
|
||||
* 开发模式:从本地 workspace 复制
|
||||
* 生产模式:从编辑器内置资源复制
|
||||
*/
|
||||
async prepareRuntimeFiles(targetDir: string): Promise<void> {
|
||||
console.log(`[RuntimeResolver] Preparing runtime files to: ${targetDir}`);
|
||||
console.log(`[RuntimeResolver] isDev: ${this.isDev}, baseDir: ${this.baseDir}`);
|
||||
|
||||
// Ensure target directory exists
|
||||
const dirExists = await TauriAPI.pathExists(targetDir);
|
||||
if (!dirExists) {
|
||||
@@ -191,12 +220,16 @@ export class RuntimeResolver {
|
||||
|
||||
// Copy platform-web runtime
|
||||
const platformWeb = await this.getModuleFiles('platform-web');
|
||||
console.log(`[RuntimeResolver] platform-web files:`, platformWeb.files);
|
||||
for (const srcFile of platformWeb.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
||||
console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`);
|
||||
if (srcExists) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
console.log(`[RuntimeResolver] Copied ${filename}`);
|
||||
} else {
|
||||
throw new Error(`Runtime file not found: ${srcFile}`);
|
||||
}
|
||||
@@ -204,16 +237,22 @@ export class RuntimeResolver {
|
||||
|
||||
// Copy engine WASM files
|
||||
const engine = await this.getModuleFiles('engine');
|
||||
console.log(`[RuntimeResolver] engine files:`, engine.files);
|
||||
for (const srcFile of engine.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
||||
console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`);
|
||||
if (srcExists) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
console.log(`[RuntimeResolver] Copied ${filename}`);
|
||||
} else {
|
||||
throw new Error(`Engine file not found: ${srcFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[RuntimeResolver] Runtime files prepared successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
/* ============================================
|
||||
About Dialog Styles
|
||||
关于对话框样式
|
||||
============================================ */
|
||||
|
||||
/* Modal Overlay / 模态遮罩层 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Dialog Container / 对话框容器 */
|
||||
.about-dialog {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
width: 500px;
|
||||
background-color: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 460px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -33,115 +43,112 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Header / 标题栏 */
|
||||
.about-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.about-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.about-header .close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.about-header .close-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Content / 内容区域 */
|
||||
.about-content {
|
||||
padding: 32px 24px;
|
||||
padding: var(--spacing-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Logo / Logo 区域 */
|
||||
.about-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #569CD6, #4EC9B0);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(86, 156, 214, 0.3);
|
||||
}
|
||||
|
||||
/* Info Section / 信息区域 */
|
||||
.about-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.about-version {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.about-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Update Section / 更新区域 */
|
||||
.about-update {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--color-bg-overlay);
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.update-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
background-color: var(--color-bg-inset);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -151,47 +158,48 @@
|
||||
}
|
||||
|
||||
.update-btn.install-btn {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
background-color: var(--color-success, #22c55e);
|
||||
border-color: var(--color-success, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-btn.install-btn:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
background-color: var(--color-success-hover, #16a34a);
|
||||
border-color: var(--color-success-hover, #16a34a);
|
||||
}
|
||||
|
||||
/* Update Status / 更新状态 */
|
||||
.update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.update-status.status-checking {
|
||||
background: rgba(86, 156, 214, 0.1);
|
||||
background-color: rgba(86, 156, 214, 0.1);
|
||||
color: #569CD6;
|
||||
}
|
||||
|
||||
.update-status.status-available {
|
||||
background: rgba(78, 201, 176, 0.1);
|
||||
background-color: rgba(78, 201, 176, 0.1);
|
||||
color: #4EC9B0;
|
||||
}
|
||||
|
||||
.update-status.status-latest {
|
||||
background: rgba(78, 201, 176, 0.1);
|
||||
background-color: rgba(78, 201, 176, 0.1);
|
||||
color: #4EC9B0;
|
||||
}
|
||||
|
||||
.update-status.status-error {
|
||||
background: rgba(206, 145, 120, 0.1);
|
||||
background-color: rgba(206, 145, 120, 0.1);
|
||||
color: #CE9178;
|
||||
}
|
||||
|
||||
.update-status.status-installing {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
@@ -207,17 +215,17 @@
|
||||
color: #CE9178;
|
||||
}
|
||||
|
||||
/* Links / 链接区域 */
|
||||
.about-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.about-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.about-link:hover {
|
||||
@@ -225,41 +233,49 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer / 页脚 */
|
||||
.about-footer {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-footer p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Actions / 操作区域 */
|
||||
.about-actions {
|
||||
padding: 16px 24px;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
background-color: var(--color-bg-elevated);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 24px;
|
||||
background: var(--color-primary);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Animation / 动画 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
z-index: var(--z-index-dropdown);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
}
|
||||
|
||||
.locale-menu-item {
|
||||
@@ -281,7 +281,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
z-index: var(--z-index-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-modal);
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.compile-dialog {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.compiler-dialog {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 10;
|
||||
z-index: var(--z-index-above);
|
||||
}
|
||||
|
||||
.console-scroll-to-bottom:hover {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-popover);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
z-index: var(--z-index-sticky);
|
||||
}
|
||||
|
||||
.component-dropdown {
|
||||
@@ -522,7 +522,7 @@
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -358,3 +358,15 @@
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 持久化面板占位符 */
|
||||
.persistent-panel-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 持久化面板容器 */
|
||||
.persistent-panel-container {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
z-index: var(--z-index-above);
|
||||
}
|
||||
|
||||
.game-view-toolbar-left {
|
||||
@@ -89,7 +89,7 @@
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
z-index: var(--z-index-dropdown);
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
@@ -178,7 +178,7 @@
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
z-index: var(--z-index-above);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.github-login-dialog {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
.plugin-list-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-list-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-warning-bg, #3d3520);
|
||||
border-radius: 4px;
|
||||
color: var(--color-warning, #ffb74d);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.plugin-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.plugin-category-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #999);
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plugin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border, #2a2a2a);
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.plugin-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plugin-item:hover {
|
||||
background: var(--color-bg-hover, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.plugin-item.core {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.plugin-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--color-border, #555);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
transition: all 0.1s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plugin-item:hover .plugin-checkbox {
|
||||
border-color: var(--color-text-tertiary, #777);
|
||||
}
|
||||
|
||||
.plugin-item.enabled .plugin-checkbox {
|
||||
background: var(--color-primary, #4a9eff);
|
||||
border-color: var(--color-primary, #4a9eff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-item.core .plugin-checkbox {
|
||||
background: var(--color-text-tertiary, #555);
|
||||
border-color: var(--color-text-tertiary, #555);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary, #666);
|
||||
}
|
||||
|
||||
.plugin-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-modules {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-module-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plugin-module-badge.runtime {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.plugin-module-badge.editor {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.plugin-module-badge.core {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.plugin-module-badge.engine {
|
||||
background: rgba(156, 39, 176, 0.12);
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.plugin-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-list-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.plugin-publish-wizard {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.plugin-update-dialog {
|
||||
|
||||
@@ -471,7 +471,7 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-bg-overlay);
|
||||
z-index: 1;
|
||||
z-index: var(--z-index-base);
|
||||
}
|
||||
|
||||
.profiler-table th {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -327,7 +327,7 @@
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
}
|
||||
|
||||
.color-picker-saturation {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.qrcode-dialog {
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--spacing-xs);
|
||||
min-width: 150px;
|
||||
z-index: 1001;
|
||||
z-index: var(--z-index-popover);
|
||||
}
|
||||
|
||||
.context-submenu button {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #1e1e1e;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-max);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
}
|
||||
|
||||
.startup-locale-item {
|
||||
@@ -257,7 +257,7 @@
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-sticky);
|
||||
}
|
||||
|
||||
.update-banner-content {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 99999;
|
||||
z-index: var(--z-index-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.user-dashboard {
|
||||
@@ -455,7 +455,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10001;
|
||||
z-index: var(--z-index-popover);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
z-index: var(--z-index-above);
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
z-index: var(--z-index-above);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,14 +91,31 @@
|
||||
--transition-slow: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Z-index 层级 */
|
||||
/* Z-index 层级 / Z-index Layers
|
||||
* 层级规范 / Layer specification:
|
||||
* - base (1): 基础元素 / Base elements
|
||||
* - above (5): 略高于基础 / Slightly above base
|
||||
* - dropdown (100): 下拉菜单 / Dropdown menus
|
||||
* - sticky (200): 固定定位元素 / Sticky positioned elements
|
||||
* - header (300): 标题栏、工具栏 / Header, toolbar
|
||||
* - overlay (500): 遮罩层 / Overlay backdrop
|
||||
* - modal (600): 模态框 / Modal dialogs
|
||||
* - popover (700): 弹出框、上下文菜单 / Popover, context menu
|
||||
* - tooltip (800): 提示框 / Tooltips
|
||||
* - toast (900): 通知提示 / Toast notifications
|
||||
* - max (1000): 最高层级(启动画面等)/ Maximum (startup screen)
|
||||
*/
|
||||
--z-index-base: 1;
|
||||
--z-index-dropdown: 10;
|
||||
--z-index-sticky: 20;
|
||||
--z-index-overlay: 30;
|
||||
--z-index-modal: 40;
|
||||
--z-index-popover: 50;
|
||||
--z-index-tooltip: 60;
|
||||
--z-index-above: 5;
|
||||
--z-index-dropdown: 100;
|
||||
--z-index-sticky: 200;
|
||||
--z-index-header: 300;
|
||||
--z-index-overlay: 500;
|
||||
--z-index-modal: 600;
|
||||
--z-index-popover: 700;
|
||||
--z-index-tooltip: 800;
|
||||
--z-index-toast: 900;
|
||||
--z-index-max: 1000;
|
||||
|
||||
/* 尺寸 */
|
||||
--size-icon-sm: 14px;
|
||||
|
||||
Reference in New Issue
Block a user