feat: 预制体系统与架构改进 (#303)

* feat(prefab): 实现预制体系统和编辑器 UX 改进

## 预制体系统
- 新增 PrefabSerializer: 预制体序列化/反序列化
- 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改
- 新增 PrefabService: 预制体核心服务
- 新增 PrefabLoader: 预制体资产加载器
- 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink

## 预制体编辑模式
- 支持双击 .prefab 文件进入编辑模式
- 预制体编辑模式工具栏 (保存/退出)
- 预制体实例指示器和操作菜单

## 编辑器 UX 改进
- SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航
- 支持双击实体名称内联编辑
- 删除实体时显示子节点数量警告
- 右键菜单添加重命名/复制选项及快捷键提示
- 布局持久化和重置功能

## Bug 修复
- 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题
- 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见
- 修复 Inspector 资源字段高度不正确问题

* feat(editor): 改进编辑器 UX 交互体验

- ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计
- SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮
- PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理
- EntityInspector: 组件折叠状态持久化、属性搜索清除按钮
- Viewport: 变换操作实时数值显示
- 国际化: 添加相关文本 (en/zh)

* fix(build): 修复 Web 构建资产加载和编辑器 UX 改进

构建系统修复:
- 修复 asset-catalog.json 字段名不匹配 (entries vs assets)
- 修复 BrowserFileSystemService 支持两种目录格式
- 修复 bundle 策略检测逻辑 (空对象判断)
- 修复 module.json 中 assetExtensions 声明和类型推断

行为树修复:
- 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath
- 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree)

编辑器 UX 改进:
- 构建完成对话框添加"打开文件夹"按钮
- 构建完成对话框样式优化 (圆形图标背景、按钮布局)
- SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列)
- SceneHierarchy 隐藏滚动条

错误追踪:
- 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log)
- 添加 append_to_log Tauri 命令

* feat(render): 修复 UI 渲染和点击特效系统

## UI 渲染修复
- 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数
- 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap
- Web 运行时添加 assetPathResolver 支持 GUID 解析
- UIInteractableComponent.blockEvents 默认值改为 false

## 点击特效系统
- 新增 ClickFxComponent 和 ClickFxSystem
- 支持在点击位置播放粒子效果
- 支持多种触发模式和粒子轮换

## Camera 系统重构
- CameraSystem 从 ecs-engine-bindgen 移至 camera 包
- 新增 CameraManager 统一管理相机

## 编辑器改进
- 改进属性面板 UI 交互
- 粒子编辑器面板优化
- Transform 命令系统

* feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层

- 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay)
- 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性
- 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染
- 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer
- 更新粒子编辑器面板支持新的排序属性
- 优化 UI 渲染系统使用新的排序层级

* feat(ci): 集成 SignPath 代码签名服务

- 添加 SignPath 自动签名工作流(Windows)
- 配置 release-editor.yml 支持代码签名
- 将构建改为草稿模式,等待签名完成后发布
- 添加证书文件到 .gitignore 防止泄露

* fix(asset): 修复 Web 构建资产路径解析和全局单例移除

## 资产路径修复
- 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接
- BrowserPathResolver 支持两种模式:
  - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser)
  - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建)
- BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理

## 架构改进 - 移除全局单例
- 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入
- 移除 globalPathResolver 导出,改用 PathResolutionService
- 移除 globalPathResolutionService 导出
- ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖
- EngineService 使用 new AssetManager() 替代全局实例

## 新增服务
- PathResolutionService: 统一路径解析接口
- RuntimeModeService: 运行时模式查询服务
- SerializationContext: EntityRef 序列化上下文

## 其他改进
- 完善 ServiceToken 注释说明本地定义的意图
- 导出 BrowserPathResolveMode 类型

* fix(build): 添加 world-streaming composite 设置修复类型检查

* fix(build): 移除 world-streaming 引用避免 composite 冲突

* fix(build): 将 const enum 改为 enum 兼容 isolatedModules

* fix(build): 添加缺失的 IAssetManager 导入
This commit is contained in:
YHH
2025-12-13 19:44:08 +08:00
committed by GitHub
parent a716d8006c
commit beaa1d09de
258 changed files with 17725 additions and 3030 deletions

View File

@@ -5,44 +5,33 @@
* Provides build settings interface for managing platform builds,
* scenes, and player settings.
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
*
* 使用 Zustand store 管理状态,避免 useEffect 过多导致的重渲染问题
* Uses Zustand store for state management to avoid re-render issues from too many useEffects
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
Plus, Minus, ChevronDown, ChevronRight, Settings,
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check, FolderOpen
} from 'lucide-react';
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService, ProjectService, BuildSettingsConfig } from '@esengine/editor-core';
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
import { invoke } from '@tauri-apps/api/core';
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
import { BuildStatus } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { useShallow } from 'zustand/react/shallow';
import {
useBuildSettingsStore,
type PlatformType,
type BuildProfile,
type BuildSettings,
} from '../stores/BuildSettingsStore';
import '../styles/BuildSettingsPanel.css';
// ==================== Types | 类型定义 ====================
/** Platform type | 平台类型 */
type PlatformType =
| 'windows'
| 'macos'
| 'linux'
| 'android'
| 'ios'
| 'web'
| 'wechat-minigame';
/** Build profile | 构建配置 */
interface BuildProfile {
id: string;
name: string;
platform: PlatformType;
isActive?: boolean;
}
/** Scene entry | 场景条目 */
interface SceneEntry {
path: string;
enabled: boolean;
}
// 类型定义已移至 BuildSettingsStore.ts
// Type definitions moved to BuildSettingsStore.ts
/** Platform configuration | 平台配置 */
interface PlatformConfig {
@@ -52,21 +41,6 @@ interface PlatformConfig {
available: boolean;
}
/** Build settings | 构建设置 */
interface BuildSettings {
scenes: SceneEntry[];
scriptingDefines: string[];
companyName: string;
productName: string;
version: string;
// Platform-specific | 平台特定
developmentBuild: boolean;
sourceMap: boolean;
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
/** Web build mode | Web 构建模式 */
buildMode: 'split-bundles' | 'single-bundle' | 'single-file';
}
// ==================== Constants | 常量 ====================
const PLATFORMS: PlatformConfig[] = [
@@ -79,18 +53,6 @@ const PLATFORMS: PlatformConfig[] = [
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
];
const DEFAULT_SETTINGS: BuildSettings = {
scenes: [],
scriptingDefines: [],
companyName: 'DefaultCompany',
productName: 'MyGame',
version: '0.1.0',
developmentBuild: false,
sourceMap: false,
compressionMethod: 'Default',
buildMode: 'split-bundles',
};
// ==================== Status Key Mapping | 状态键映射 ====================
/** Map BuildStatus to translation key | 将 BuildStatus 映射到翻译键 */
@@ -202,269 +164,81 @@ export function BuildSettingsPanel({
}: BuildSettingsPanelProps) {
const { t } = useLocale();
// State | 状态
const [profiles, setProfiles] = useState<BuildProfile[]>([
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
]);
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
sceneList: true,
scriptingDefines: true,
platformSettings: true,
playerSettings: true,
});
// 使用 Zustand store 替代本地状态(使用 useShallow 避免不必要的重渲染)
// Use Zustand store instead of local state (use useShallow to avoid unnecessary re-renders)
const {
profiles,
selectedPlatform,
selectedProfile,
settings,
expandedSections,
isBuilding,
buildProgress,
buildResult,
showBuildProgress,
} = useBuildSettingsStore(useShallow(state => ({
profiles: state.profiles,
selectedPlatform: state.selectedPlatform,
selectedProfile: state.selectedProfile,
settings: state.settings,
expandedSections: state.expandedSections,
isBuilding: state.isBuilding,
buildProgress: state.buildProgress,
buildResult: state.buildResult,
showBuildProgress: state.showBuildProgress,
})));
// Build state | 构建状态
const [isBuilding, setIsBuilding] = useState(false);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [buildResult, setBuildResult] = useState<{
success: boolean;
outputPath: string;
duration: number;
warnings: string[];
error?: string;
} | null>(null);
const [showBuildProgress, setShowBuildProgress] = useState(false);
const buildAbortRef = useRef<AbortController | null>(null);
// 获取 store actions通过 getState 获取,这些不会触发重渲染)
// Get store actions via getState (these don't trigger re-renders)
const store = useBuildSettingsStore.getState();
const {
setSelectedPlatform: handlePlatformSelect,
setSelectedProfile: handleProfileSelect,
addProfile: handleAddProfile,
updateSettings,
setSceneEnabled,
addDefine,
removeDefine: handleRemoveDefine,
toggleSection,
cancelBuild: handleCancelBuild,
closeBuildProgress: handleCloseBuildProgress,
} = store;
// Handlers | 处理函数
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
}, []);
const handlePlatformSelect = useCallback((platform: PlatformType) => {
setSelectedPlatform(platform);
// Find first profile for this platform | 查找此平台的第一个配置
const profile = profiles.find(p => p.platform === platform);
setSelectedProfile(profile || null);
}, [profiles]);
const handleProfileSelect = useCallback((profile: BuildProfile) => {
setSelectedProfile(profile);
setSelectedPlatform(profile.platform);
}, []);
const handleAddProfile = useCallback(() => {
const newProfile: BuildProfile = {
id: `profile-${Date.now()}`,
name: `${selectedPlatform} - New Profile`,
platform: selectedPlatform,
};
setProfiles(prev => [...prev, newProfile]);
setSelectedProfile(newProfile);
}, [selectedPlatform]);
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
const platformMap: Record<PlatformType, BuildPlatform> = {
'web': BuildPlatform.Web,
'wechat-minigame': BuildPlatform.WeChatMiniGame,
'windows': BuildPlatform.Desktop,
'macos': BuildPlatform.Desktop,
'linux': BuildPlatform.Desktop,
'android': BuildPlatform.Android,
'ios': BuildPlatform.iOS
};
return platformMap[platformType];
}, []);
const handleBuild = useCallback(async () => {
if (!selectedProfile || !projectPath) {
return;
// 初始化 store仅在 mount 时)
// Initialize store (only on mount)
useEffect(() => {
if (projectPath) {
useBuildSettingsStore.getState().initialize({
projectPath,
buildService,
projectService,
availableScenes,
});
}
return () => useBuildSettingsStore.getState().cleanup();
}, [projectPath]); // 只依赖 projectPath避免频繁重初始化
// Call external handler if provided | 如果提供了外部处理程序则调用
// 当前平台的配置列表(使用 useMemo 避免每次重新过滤)
// Profiles for current platform (use useMemo to avoid re-filtering every time)
const platformProfiles = useMemo(
() => profiles.filter(p => p.platform === selectedPlatform),
[profiles, selectedPlatform]
);
// 构建处理 | Build handler
const handleBuild = useCallback(async () => {
if (!selectedProfile || !projectPath) return;
// Call external handler if provided
if (onBuild) {
onBuild(selectedProfile, settings);
}
// Use BuildService if available | 如果可用则使用 BuildService
if (buildService) {
setIsBuilding(true);
setBuildProgress(null);
setBuildResult(null);
setShowBuildProgress(true);
try {
const platform = getPlatformEnum(selectedProfile.platform);
const baseConfig = {
platform,
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
isRelease: !settings.developmentBuild,
sourceMap: settings.sourceMap,
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
};
// Build platform-specific config | 构建平台特定配置
let buildConfig: BuildConfig;
if (platform === BuildPlatform.Web) {
const webConfig: WebBuildConfig = {
...baseConfig,
platform: BuildPlatform.Web,
buildMode: settings.buildMode,
generateHtml: true,
minify: !settings.developmentBuild,
generateAssetCatalog: true,
assetLoadingStrategy: 'on-demand'
};
buildConfig = webConfig;
} else if (platform === BuildPlatform.WeChatMiniGame) {
const wechatConfig: WeChatBuildConfig = {
...baseConfig,
platform: BuildPlatform.WeChatMiniGame,
appId: '',
useSubpackages: false,
mainPackageLimit: 4096,
usePlugins: false
};
buildConfig = wechatConfig;
} else {
buildConfig = baseConfig;
}
// Execute build with progress callback | 执行构建并传入进度回调
const result = await buildService.build(buildConfig, (progress) => {
setBuildProgress(progress);
});
// Set result | 设置结果
setBuildResult({
success: result.success,
outputPath: result.outputPath,
duration: result.duration,
warnings: result.warnings,
error: result.error
});
} catch (error) {
console.error('Build failed:', error);
setBuildResult({
success: false,
outputPath: '',
duration: 0,
warnings: [],
error: error instanceof Error ? error.message : String(error)
});
} finally {
setIsBuilding(false);
}
}
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
// Load saved build settings from project config
// 从项目配置加载已保存的构建设置
useEffect(() => {
if (!projectService) return;
const savedSettings = projectService.getBuildSettings();
if (savedSettings) {
setSettings(prev => ({
...prev,
scriptingDefines: savedSettings.scriptingDefines || [],
companyName: savedSettings.companyName || prev.companyName,
productName: savedSettings.productName || prev.productName,
version: savedSettings.version || prev.version,
developmentBuild: savedSettings.developmentBuild ?? prev.developmentBuild,
sourceMap: savedSettings.sourceMap ?? prev.sourceMap,
compressionMethod: savedSettings.compressionMethod || prev.compressionMethod,
buildMode: savedSettings.buildMode || prev.buildMode
}));
}
}, [projectService]);
// Initialize scenes from availableScenes prop and saved settings
// 从 availableScenes prop 和已保存设置初始化场景列表
useEffect(() => {
if (availableScenes && availableScenes.length > 0) {
const savedSettings = projectService?.getBuildSettings();
const savedScenes = savedSettings?.scenes || [];
setSettings(prev => ({
...prev,
scenes: availableScenes.map(path => ({
path,
enabled: savedScenes.length > 0 ? savedScenes.includes(path) : true
}))
}));
}
}, [availableScenes, projectService]);
// Auto-save build settings when changed
// 设置变化时自动保存
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!projectService) return;
// Debounce save to avoid too many writes
// 防抖保存,避免频繁写入
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
const configToSave: BuildSettingsConfig = {
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path),
scriptingDefines: settings.scriptingDefines,
companyName: settings.companyName,
productName: settings.productName,
version: settings.version,
developmentBuild: settings.developmentBuild,
sourceMap: settings.sourceMap,
compressionMethod: settings.compressionMethod,
buildMode: settings.buildMode
};
projectService.updateBuildSettings(configToSave);
}, 500);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [settings, projectService]);
// Monitor build progress from service | 从服务监控构建进度
useEffect(() => {
if (!buildService || !isBuilding) {
return;
}
const interval = setInterval(() => {
const task = buildService.getCurrentTask();
if (task) {
setBuildProgress(task.progress);
}
}, 100);
return () => clearInterval(interval);
}, [buildService, isBuilding]);
const handleCancelBuild = useCallback(() => {
if (buildService) {
buildService.cancelBuild();
}
}, [buildService]);
const handleCloseBuildProgress = useCallback(() => {
if (!isBuilding) {
setShowBuildProgress(false);
setBuildProgress(null);
setBuildResult(null);
}
}, [isBuilding]);
// Get status message | 获取状态消息
const getStatusMessage = useCallback((status: BuildStatus): string => {
return t(buildStatusKeys[status]) || status;
}, [t]);
// 使用 store 的构建操作 | Use store's build action
await useBuildSettingsStore.getState().startBuild();
}, [selectedProfile, projectPath, onBuild, settings]);
// 添加当前场景 | Add current scene
const handleAddScene = useCallback(() => {
if (!sceneManager) {
console.warn('SceneManagerService not available');
@@ -479,36 +253,29 @@ export function BuildSettingsPanel({
return;
}
// Check if scene is already in the list | 检查场景是否已在列表中
// 检查场景是否已在列表中 | Check if scene is already in the list
const exists = settings.scenes.some(s => s.path === currentScenePath);
if (exists) {
console.log('Scene already in list:', currentScenePath);
return;
}
// Add current scene to the list | 将当前场景添加到列表中
setSettings(prev => ({
...prev,
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
}));
// 使用 store 添加场景 | Use store to add scene
useBuildSettingsStore.getState().addScene(currentScenePath);
}, [sceneManager, settings.scenes]);
// 添加脚本定义(带 prompt| Add scripting define (with prompt)
const handleAddDefine = useCallback(() => {
const define = prompt('Enter scripting define:');
if (define) {
setSettings(prev => ({
...prev,
scriptingDefines: [...prev.scriptingDefines, define]
}));
addDefine(define);
}
}, []);
}, [addDefine]);
const handleRemoveDefine = useCallback((index: number) => {
setSettings(prev => ({
...prev,
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
}));
}, []);
// 获取状态消息 | Get status message
const getStatusMessage = useCallback((status: BuildStatus): string => {
return t(buildStatusKeys[status]) || status;
}, [t]);
// Get platform config | 获取平台配置
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
@@ -634,14 +401,7 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={scene.enabled}
onChange={e => {
setSettings(prev => ({
...prev,
scenes: prev.scenes.map((s, i) =>
i === index ? { ...s, enabled: e.target.checked } : s
)
}));
}}
onChange={e => setSceneEnabled(index, e.target.checked)}
/>
<span>{scene.path}</span>
</div>
@@ -713,10 +473,7 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={settings.developmentBuild}
onChange={e => setSettings(prev => ({
...prev,
developmentBuild: e.target.checked
}))}
onChange={e => updateSettings({ developmentBuild: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
@@ -724,20 +481,14 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={settings.sourceMap}
onChange={e => setSettings(prev => ({
...prev,
sourceMap: e.target.checked
}))}
onChange={e => updateSettings({ sourceMap: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.compressionMethod')}</label>
<select
value={settings.compressionMethod}
onChange={e => setSettings(prev => ({
...prev,
compressionMethod: e.target.value as any
}))}
onChange={e => updateSettings({ compressionMethod: e.target.value as 'Default' | 'LZ4' | 'LZ4HC' })}
>
<option value="Default">Default</option>
<option value="LZ4">LZ4</option>
@@ -749,10 +500,7 @@ export function BuildSettingsPanel({
<div className="build-settings-toggle-group">
<select
value={settings.buildMode}
onChange={e => setSettings(prev => ({
...prev,
buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file'
}))}
onChange={e => updateSettings({ buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file' })}
>
<option value="split-bundles">{t('buildSettings.splitBundles')}</option>
<option value="single-bundle">{t('buildSettings.singleBundle')}</option>
@@ -798,10 +546,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.companyName}
onChange={e => setSettings(prev => ({
...prev,
companyName: e.target.value
}))}
onChange={e => updateSettings({ companyName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -809,10 +554,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.productName}
onChange={e => setSettings(prev => ({
...prev,
productName: e.target.value
}))}
onChange={e => updateSettings({ productName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -820,10 +562,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.version}
onChange={e => setSettings(prev => ({
...prev,
version: e.target.value
}))}
onChange={e => updateSettings({ version: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -867,11 +606,11 @@ export function BuildSettingsPanel({
{/* Status Icon | 状态图标 */}
<div className="build-progress-status-icon">
{isBuilding ? (
<Loader2 size={48} className="build-progress-spinner" />
<Loader2 size={36} className="build-progress-spinner" />
) : buildResult?.success ? (
<CheckCircle size={48} className="build-progress-success" />
<CheckCircle size={40} className="build-progress-success" />
) : (
<XCircle size={48} className="build-progress-error" />
<XCircle size={40} className="build-progress-error" />
)}
</div>
@@ -950,12 +689,29 @@ export function BuildSettingsPanel({
{t('buildSettings.cancel')}
</button>
) : (
<button
className="build-settings-btn primary"
onClick={handleCloseBuildProgress}
>
{t('buildSettings.close')}
</button>
<>
<button
className="build-settings-btn secondary"
onClick={handleCloseBuildProgress}
>
{t('buildSettings.close')}
</button>
{buildResult?.success && buildResult.outputPath && (
<button
className="build-settings-btn primary"
onClick={() => {
// 使用 Tauri 打开文件夹
// Use Tauri to open folder
invoke('open_folder', { path: buildResult.outputPath }).catch(e => {
console.error('Failed to open folder:', e);
});
}}
>
<FolderOpen size={14} />
{t('buildSettings.openFolder')}
</button>
)}
</>
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@
* 用于浏览和管理项目资产
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import {
@@ -38,10 +38,13 @@ import {
RefreshCw,
Settings,
Database,
AlertTriangle
AlertTriangle,
X,
FolderPlus,
Inbox
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate } from '@esengine/editor-core';
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { SettingsService } from '../services/SettingsService';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
@@ -126,6 +129,32 @@ function isRootManagedDirectory(folderPath: string, projectPath: string | null):
return false;
}
/**
* 高亮搜索文本
* Highlight search text in a string
*/
function highlightSearchText(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return text;
const before = text.substring(0, index);
const match = text.substring(index, index + query.length);
const after = text.substring(index + query.length);
return (
<>
{before}
<span className="search-highlight">{match}</span>
{after}
</>
);
}
// 获取资产类型显示名称
function getAssetTypeName(asset: AssetItem): string {
if (asset.type === 'folder') return 'Folder';
@@ -179,6 +208,10 @@ export function ContentBrowser({
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
// 隐藏的文件扩展名(默认隐藏 .meta| Hidden file extensions (hide .meta by default)
const [hiddenExtensions, setHiddenExtensions] = useState<Set<string>>(new Set(['meta']));
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
// Folder tree state
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
@@ -474,11 +507,33 @@ export class ${className} {
setDeleteConfirmDialog(asset);
}
}
// Ctrl+A - 全选 | Select all
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// 计算当前过滤后的资产 | Calculate currently filtered assets
const currentFiltered = searchQuery.trim()
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: assets;
const allPaths = new Set(currentFiltered.map(a => a.path));
setSelectedPaths(allPaths);
const lastItem = currentFiltered[currentFiltered.length - 1];
if (lastItem) {
setLastSelectedPath(lastItem.path);
}
}
// Escape - 取消选择 | Deselect all
if (e.key === 'Escape') {
e.preventDefault();
setSelectedPaths(new Set());
setLastSelectedPath(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]);
}, [selectedPaths, assets, searchQuery, renameDialog, deleteConfirmDialog, createFileDialog]);
const getTemplateLabel = (label: string): string => {
// Map template labels to translation keys
@@ -582,6 +637,21 @@ export class ${className} {
}
}, [currentPath, projectPath, loadAssets, buildFolderTree]);
// 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside
useEffect(() => {
if (!showFilterDropdown) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.cb-filter-wrapper')) {
setShowFilterDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterDropdown]);
// Initialize on mount
useEffect(() => {
if (projectPath) {
@@ -618,6 +688,44 @@ export class ${className} {
}
}, [expandedFolders, projectPath, buildFolderTree]);
// Subscribe to asset change events to refresh content
// 订阅资产变化事件以刷新内容
useEffect(() => {
if (!messageHub) return;
const handleAssetChange = (data: { type: string; path: string; relativePath: string; guid: string }) => {
// Check if the changed file is in the current directory
// 检查变化的文件是否在当前目录中
if (!currentPath || !data.path) return;
const normalizedPath = data.path.replace(/\\/g, '/');
const normalizedCurrentPath = currentPath.replace(/\\/g, '/');
const parentDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
if (parentDir === normalizedCurrentPath) {
// Refresh current directory
// 刷新当前目录
loadAssets(currentPath);
}
};
const handleAssetsRefresh = () => {
// Refresh current directory when generic refresh is requested
// 当请求通用刷新时刷新当前目录
if (currentPath) {
loadAssets(currentPath);
}
};
const unsubChange = messageHub.subscribe('assets:changed', handleAssetChange);
const unsubRefresh = messageHub.subscribe('assets:refresh', handleAssetsRefresh);
return () => {
unsubChange();
unsubRefresh();
};
}, [messageHub, currentPath, loadAssets]);
// Handle reveal path - navigate to folder and select file
const prevRevealPath = useRef<string | null>(null);
useEffect(() => {
@@ -788,7 +896,13 @@ export class ${className} {
const handleFolderDragOver = useCallback((e: React.DragEvent, folderPath: string) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(folderPath);
// 支持资产拖放和实体拖放 | Support asset drag and entity drag
const hasAsset = e.dataTransfer.types.includes('asset-path');
const hasEntity = e.dataTransfer.types.includes('entity-id');
if (hasAsset || hasEntity) {
e.dataTransfer.dropEffect = hasEntity ? 'copy' : 'move';
setDragOverFolder(folderPath);
}
}, []);
const handleFolderDragLeave = useCallback((e: React.DragEvent) => {
@@ -802,11 +916,75 @@ export class ${className} {
e.stopPropagation();
setDragOverFolder(null);
// 检查是否是资产移动 | Check if it's asset move
const sourcePath = e.dataTransfer.getData('asset-path');
if (sourcePath) {
await handleMoveAsset(sourcePath, targetFolderPath);
return;
}
}, [handleMoveAsset]);
// 检查是否是实体拖放(创建预制体)| Check if it's entity drop (create prefab)
const entityIdStr = e.dataTransfer.getData('entity-id');
if (entityIdStr) {
const entityId = parseInt(entityIdStr, 10);
if (isNaN(entityId)) return;
const scene = Core.scene;
if (!scene) return;
const entity = scene.findEntityById(entityId);
if (!entity) return;
// 获取层级系统 | Get hierarchy system
const hierarchySystem = scene.getSystem(HierarchySystem);
// 创建预制体数据 | Create prefab data
const prefabData = PrefabSerializer.createPrefab(
entity,
{
name: entity.name,
includeChildren: true
},
hierarchySystem ?? undefined
);
// 序列化为 JSON | Serialize to JSON
const prefabJson = PrefabSerializer.serialize(prefabData, true);
// 保存到目标文件夹 | Save to target folder
const sep = targetFolderPath.includes('\\') ? '\\' : '/';
const filePath = `${targetFolderPath}${sep}${entity.name}.prefab`;
try {
await TauriAPI.writeFileContent(filePath, prefabJson);
console.log(`[ContentBrowser] Prefab created: ${filePath}`);
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
let guid: string | null = null;
if (assetRegistry) {
guid = await assetRegistry.registerAsset(filePath);
console.log(`[ContentBrowser] Registered prefab asset with GUID: ${guid}`);
}
// 刷新目录 | Refresh directory
if (currentPath === targetFolderPath) {
await loadAssets(targetFolderPath);
}
// 发布事件 | Publish event
messageHub.publish('prefab:created', {
path: filePath,
guid,
name: entity.name,
sourceEntityId: entity.id,
sourceEntityName: entity.name
});
} catch (error) {
console.error('[ContentBrowser] Failed to create prefab:', error);
}
}
}, [handleMoveAsset, currentPath, loadAssets, messageHub]);
// Handle asset click
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
@@ -859,6 +1037,22 @@ export class ${className} {
return;
}
// 预制体文件进入预制体编辑模式
// Open prefab file in prefab edit mode
if (ext === 'prefab') {
try {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager) {
await sceneManager.enterPrefabEditMode(asset.path);
} else {
console.error('SceneManagerService not available');
}
} catch (error) {
console.error('Failed to open prefab:', error);
}
return;
}
// 脚本文件使用配置的编辑器打开
// Open script files with configured editor
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
@@ -1092,9 +1286,10 @@ export class ${className} {
onClick: async () => {
if (currentPath) {
try {
console.log('[ContentBrowser] showInFolder (empty area) - currentPath:', currentPath);
await TauriAPI.showInFolder(currentPath);
} catch (error) {
console.error('Failed to show in folder:', error);
console.error('Failed to show in folder:', error, 'Path:', currentPath);
}
}
setContextMenu(null);
@@ -1301,8 +1496,17 @@ export class ${className} {
icon: <ExternalLink size={16} />,
onClick: async () => {
try {
console.log('[ContentBrowser] showInFolder path:', asset.path);
await TauriAPI.showInFolder(asset.path);
// Ensure we use absolute path
// 确保使用绝对路径
const absolutePath = asset.path.includes(':') || asset.path.startsWith('\\\\')
? asset.path
: (projectPath ? `${projectPath}/${asset.path}`.replace(/\//g, '\\') : asset.path);
console.log('[ContentBrowser] showInFolder - asset.path:', asset.path);
console.log('[ContentBrowser] showInFolder - projectPath:', projectPath);
console.log('[ContentBrowser] showInFolder - absolutePath:', absolutePath);
await TauriAPI.showInFolder(absolutePath);
} catch (error) {
console.error('Failed to show in folder:', error, 'Path:', asset.path);
}
@@ -1405,9 +1609,10 @@ export class ${className} {
icon: <ExternalLink size={16} />,
onClick: async () => {
try {
console.log('[ContentBrowser] showInFolder (folder tree) - node.path:', node.path);
await TauriAPI.showInFolder(node.path);
} catch (error) {
console.error('Failed to show in explorer:', error);
console.error('Failed to show in explorer:', error, 'Path:', node.path);
}
}
});
@@ -1466,10 +1671,51 @@ export class ${className} {
);
}, [currentPath, expandedFolders, handleFolderSelect, handleFolderTreeContextMenu, toggleFolderExpand, projectPath, t, dragOverFolder, handleFolderDragOver, handleFolderDragLeave, handleFolderDrop]);
// Filter assets by search
const filteredAssets = searchQuery.trim()
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: assets;
// 收集当前目录所有唯一扩展名 | Collect all unique extensions in current directory
const allExtensions = useMemo(() => {
const exts = new Set<string>();
assets.forEach(a => {
if (a.extension) {
exts.add(a.extension.toLowerCase());
}
});
return Array.from(exts).sort();
}, [assets]);
// 切换扩展名隐藏状态 | Toggle extension hidden state
const toggleExtensionHidden = useCallback((ext: string) => {
setHiddenExtensions(prev => {
const newSet = new Set(prev);
if (newSet.has(ext)) {
newSet.delete(ext);
} else {
newSet.add(ext);
}
return newSet;
});
}, []);
// Filter assets by search and hidden extensions
// 按搜索词和隐藏扩展名过滤资产
const filteredAssets = useMemo(() => {
let result = assets;
// 过滤隐藏的扩展名 | Filter hidden extensions
if (hiddenExtensions.size > 0) {
result = result.filter(a => {
if (a.type === 'folder') return true;
const ext = a.extension?.toLowerCase();
return !ext || !hiddenExtensions.has(ext);
});
}
// 搜索过滤 | Search filter
if (searchQuery.trim()) {
result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()));
}
return result;
}, [assets, hiddenExtensions, searchQuery]);
const breadcrumbs = getBreadcrumbs();
@@ -1601,10 +1847,55 @@ export class ${className} {
{/* Search Bar */}
<div className="cb-search-bar">
<button className="cb-filter-btn">
<SlidersHorizontal size={14} />
<ChevronDown size={10} />
</button>
<div className="cb-filter-wrapper">
<button
className={`cb-filter-btn ${hiddenExtensions.size > 0 ? 'has-filter' : ''}`}
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
title={hiddenExtensions.size > 0 ? `${hiddenExtensions.size} hidden` : 'Filter'}
>
<SlidersHorizontal size={14} />
<ChevronDown size={10} />
{hiddenExtensions.size > 0 && (
<span className="cb-filter-badge">{hiddenExtensions.size}</span>
)}
</button>
{showFilterDropdown && (
<div className="cb-filter-dropdown">
<div className="cb-filter-header">
<span>{t('contentBrowser.hiddenExtensions') || 'Hidden Extensions'}</span>
{hiddenExtensions.size > 0 && (
<button
className="cb-filter-clear"
onClick={() => setHiddenExtensions(new Set())}
>
{t('common.clearAll') || 'Clear All'}
</button>
)}
</div>
<div className="cb-filter-list">
{allExtensions.length === 0 ? (
<div className="cb-filter-empty">
{t('contentBrowser.noExtensions') || 'No file types'}
</div>
) : (
allExtensions.map(ext => (
<label key={ext} className="cb-filter-item">
<input
type="checkbox"
checked={hiddenExtensions.has(ext)}
onChange={() => toggleExtensionHidden(ext)}
/>
<span className="cb-filter-ext">.{ext}</span>
<span className="cb-filter-count">
({assets.filter(a => a.extension?.toLowerCase() === ext).length})
</span>
</label>
))
)}
</div>
</div>
)}
</div>
<div className="cb-search-input-wrapper">
<Search size={14} className="cb-search-icon" />
<input
@@ -1613,7 +1904,23 @@ export class ${className} {
placeholder={`${t('contentBrowser.search')} ${breadcrumbs[breadcrumbs.length - 1]?.name || ''}`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && searchQuery) {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}
}}
/>
{searchQuery && (
<button
className="cb-search-clear"
onClick={() => setSearchQuery('')}
title={t('common.clear') || 'Clear'}
>
<X size={12} />
</button>
)}
</div>
<div className="cb-view-options">
<button
@@ -1635,11 +1942,52 @@ export class ${className} {
<div
className={`cb-asset-grid ${viewMode}`}
onContextMenu={(e) => handleContextMenu(e)}
onDragOver={(e) => {
// 允许实体拖放到当前目录 | Allow entity drop to current directory
if (e.dataTransfer.types.includes('entity-id') && currentPath) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDrop={(e) => {
// 在当前目录创建预制体 | Create prefab in current directory
if (currentPath && e.dataTransfer.types.includes('entity-id')) {
handleFolderDrop(e, currentPath);
}
}}
>
{loading ? (
<div className="cb-loading">Loading...</div>
<div className="cb-loading">
<div className="cb-loading-spinner" />
<span>{t('contentBrowser.loading') || 'Loading...'}</span>
</div>
) : filteredAssets.length === 0 ? (
<div className="cb-empty">{t('contentBrowser.empty')}</div>
<div className="cb-empty">
<Inbox size={48} className="cb-empty-icon" />
<span className="cb-empty-title">
{searchQuery.trim()
? t('contentBrowser.noSearchResults')
: t('contentBrowser.empty')}
</span>
<span className="cb-empty-hint">
{searchQuery.trim()
? t('contentBrowser.noSearchResultsHint')
: t('contentBrowser.emptyHint')}
</span>
{!searchQuery.trim() && (
<button
className="cb-empty-action"
onClick={() => setContextMenu({
position: { x: window.innerWidth / 2, y: window.innerHeight / 2 },
asset: null,
isBackground: true
})}
>
<Plus size={12} style={{ marginRight: 4 }} />
{t('contentBrowser.createNew') || 'Create New'}
</button>
)}
</div>
) : (
filteredAssets.map(asset => {
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
@@ -1692,7 +2040,7 @@ export class ${className} {
</div>
<div className="cb-asset-info">
<div className="cb-asset-name" title={asset.name}>
{asset.name}
{highlightSearchText(asset.name, searchQuery)}
</div>
<div className="cb-asset-type">
{getAssetTypeName(asset)}
@@ -1706,7 +2054,23 @@ export class ${className} {
{/* Status Bar */}
<div className="cb-status-bar">
<span>{filteredAssets.length} {t('contentBrowser.items')}</span>
<span>
{searchQuery.trim() ? (
// 搜索模式:显示找到的结果数 | Search mode: show found results
t('contentBrowser.searchResults', {
found: filteredAssets.length,
total: assets.length
})
) : (
// 正常模式 | Normal mode
`${filteredAssets.length} ${t('contentBrowser.items')}`
)}
</span>
{selectedPaths.size > 1 && (
<span className="cb-status-selected">
{t('contentBrowser.selectedCount', { count: selectedPaths.size })}
</span>
)}
</div>
</div>
@@ -1730,8 +2094,8 @@ export class ${className} {
{/* Rename Dialog */}
{renameDialog && (
<div className="cb-dialog-overlay" onClick={() => setRenameDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-overlay">
<div className="cb-dialog">
<div className="cb-dialog-header">
<h3>{t('contentBrowser.dialogs.renameTitle')}</h3>
</div>
@@ -1764,8 +2128,8 @@ export class ${className} {
{/* Delete Confirm Dialog */}
{deleteConfirmDialog && (
<div className="cb-dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-overlay">
<div className="cb-dialog">
<div className="cb-dialog-header">
<h3>{t('contentBrowser.deleteConfirmTitle')}</h3>
</div>

View File

@@ -8,9 +8,9 @@ export interface ContextMenuItem {
onClick: () => void;
disabled?: boolean;
separator?: boolean;
/** 快捷键提示文本 */
/** 快捷键提示文本 | Shortcut hint text */
shortcut?: string;
/** 子菜单项 */
/** 子菜单项 | Submenu items */
children?: ContextMenuItem[];
}
@@ -24,43 +24,94 @@ interface SubMenuProps {
items: ContextMenuItem[];
parentRect: DOMRect;
onClose: () => void;
level: number;
}
/**
* 计算子菜单位置,处理屏幕边界
* Calculate submenu position, handle screen boundaries
*/
function calculateSubmenuPosition(
parentRect: DOMRect,
menuWidth: number,
menuHeight: number
): { x: number; y: number; flipHorizontal: boolean } {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 10;
let x = parentRect.right;
let y = parentRect.top;
let flipHorizontal = false;
// 检查右侧空间是否足够 | Check if there's enough space on the right
if (x + menuWidth > viewportWidth - padding) {
// 尝试显示在左侧 | Try to show on the left side
const leftPosition = parentRect.left - menuWidth;
if (leftPosition >= padding) {
x = leftPosition;
flipHorizontal = true;
} else {
// 两侧都不够,选择空间更大的一侧 | Neither side has enough space, choose the larger one
if (parentRect.left > viewportWidth - parentRect.right) {
x = padding;
flipHorizontal = true;
} else {
x = viewportWidth - menuWidth - padding;
}
}
}
// 检查底部空间是否足够 | Check if there's enough space at the bottom
if (y + menuHeight > viewportHeight - padding) {
y = Math.max(padding, viewportHeight - menuHeight - padding);
}
// 确保不超出顶部 | Ensure it doesn't go above the top
if (y < padding) {
y = padding;
}
return { x, y, flipHorizontal };
}
/**
* 子菜单组件
* SubMenu component
*/
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
function SubMenu({ items, parentRect, onClose, level }: SubMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算位置 | Calculate position
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 默认在父菜单右侧显示
let x = parentRect.right;
let y = parentRect.top;
// 如果右侧空间不足,显示在左侧
if (x + rect.width > viewportWidth) {
x = parentRect.left - rect.width;
}
// 如果底部空间不足,向上调整
if (y + rect.height > viewportHeight) {
y = Math.max(0, viewportHeight - rect.height - 10);
}
const { x, y } = calculateSubmenuPosition(parentRect, rect.width, rect.height);
setPosition({ x, y });
}
}, [parentRect]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -71,14 +122,38 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = position
? { left: `${position.x}px`, top: `${position.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu submenu"
style={{
left: `${position.x}px`,
top: `${position.y}px`
}}
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{items.map((item, index) => {
if (item.separator) {
@@ -90,19 +165,16 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
@@ -113,6 +185,7 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
items={item.children}
parentRect={submenuRect}
onClose={onClose}
level={level + 1}
/>
)}
</div>
@@ -124,10 +197,12 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算调整后的位置 | Calculate adjusted position
useEffect(() => {
const adjustPosition = () => {
if (menuRef.current) {
@@ -138,24 +213,29 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const STATUS_BAR_HEIGHT = 28;
const TITLE_BAR_HEIGHT = 32;
const padding = 10;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth - 10) {
x = Math.max(10, viewportWidth - rect.width - 10);
// 检查右边界 | Check right boundary
if (x + rect.width > viewportWidth - padding) {
x = Math.max(padding, viewportWidth - rect.width - padding);
}
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - 10) {
y = Math.max(TITLE_BAR_HEIGHT + 10, viewportHeight - STATUS_BAR_HEIGHT - rect.height - 10);
// 检查下边界 | Check bottom boundary
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - padding) {
y = Math.max(TITLE_BAR_HEIGHT + padding, viewportHeight - STATUS_BAR_HEIGHT - rect.height - padding);
}
if (x < 10) {
x = 10;
// 确保不超出左边界 | Ensure not beyond left boundary
if (x < padding) {
x = padding;
}
if (y < TITLE_BAR_HEIGHT + 10) {
y = TITLE_BAR_HEIGHT + 10;
// 确保不超出上边界 | Ensure not beyond top boundary
if (y < TITLE_BAR_HEIGHT + padding) {
y = TITLE_BAR_HEIGHT + padding;
}
setAdjustedPosition({ x, y });
@@ -168,6 +248,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
return () => cancelAnimationFrame(rafId);
}, [position]);
// 点击外部关闭 | Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
@@ -181,6 +262,8 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
}
};
// 使用 mousedown 而不是 click以便更快响应
// Use mousedown instead of click for faster response
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
@@ -190,7 +273,22 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
};
}, [onClose]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -201,14 +299,38 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = adjustedPosition
? { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu"
style={{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}}
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{items.map((item, index) => {
if (item.separator) {
@@ -220,19 +342,16 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
@@ -243,6 +362,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
items={item.children}
parentRect={submenuRect}
onClose={onClose}
level={1}
/>
)}
</div>

View File

@@ -3,7 +3,7 @@
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
*/
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { useCallback, useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
@@ -11,6 +11,81 @@ import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
export type { FlexDockPanel };
/** LocalStorage key for persisting layout | 持久化布局的 localStorage 键 */
const LAYOUT_STORAGE_KEY = 'esengine-editor-layout';
/** Layout version for migration | 布局版本用于迁移 */
const LAYOUT_VERSION = 1;
/** Saved layout data structure | 保存的布局数据结构 */
interface SavedLayoutData {
version: number;
layout: IJsonModel;
timestamp: number;
}
/**
* Save layout to localStorage.
* 保存布局到 localStorage。
*/
function saveLayoutToStorage(layout: IJsonModel): void {
try {
const data: SavedLayoutData = {
version: LAYOUT_VERSION,
layout,
timestamp: Date.now()
};
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save layout to localStorage:', error);
}
}
/**
* Load layout from localStorage.
* 从 localStorage 加载布局。
*/
function loadLayoutFromStorage(): IJsonModel | null {
try {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (!saved) return null;
const data: SavedLayoutData = JSON.parse(saved);
// Version check for future migrations
if (data.version !== LAYOUT_VERSION) {
console.info('Layout version mismatch, using default layout');
return null;
}
return data.layout;
} catch (error) {
console.warn('Failed to load layout from localStorage:', error);
return null;
}
}
/**
* Clear saved layout from localStorage.
* 从 localStorage 清除保存的布局。
*/
function clearLayoutStorage(): void {
try {
localStorage.removeItem(LAYOUT_STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear layout from localStorage:', error);
}
}
/**
* Public handle for FlexLayoutDockContainer.
* FlexLayoutDockContainer 的公开句柄。
*/
export interface FlexLayoutDockContainerHandle {
/** Reset layout to default | 重置布局到默认状态 */
resetLayout: () => void;
}
/**
* Panel IDs that should persist in DOM when switching tabs.
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
@@ -94,11 +169,14 @@ interface FlexLayoutDockContainerProps {
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
}
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) {
export const FlexLayoutDockContainer = forwardRef<FlexLayoutDockContainerHandle, FlexLayoutDockContainerProps>(
function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) {
const layoutRef = useRef<Layout>(null);
const previousLayoutJsonRef = useRef<string | null>(null);
const previousPanelIdsRef = useRef<string>('');
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
/** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */
const skipNextSaveRef = useRef(false);
// Persistent panel state | 持久化面板状态
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
@@ -116,14 +194,52 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
}, [panels, activePanelId]);
/**
* Try to load saved layout and merge with current panels.
* 尝试加载保存的布局并与当前面板合并。
*/
const loadSavedLayoutOrDefault = useCallback((): IJsonModel => {
const savedLayout = loadLayoutFromStorage();
if (savedLayout) {
try {
// Merge saved layout with current panels (handle new/removed panels)
const defaultLayout = createDefaultLayout();
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
return mergedLayout;
} catch (error) {
console.warn('Failed to merge saved layout, using default:', error);
}
}
return createDefaultLayout();
}, [createDefaultLayout, panels]);
const [model, setModel] = useState<Model>(() => {
try {
return Model.fromJson(createDefaultLayout());
return Model.fromJson(loadSavedLayoutOrDefault());
} catch (error) {
throw new Error(`Failed to create layout model: ${error instanceof Error ? error.message : String(error)}`);
console.warn('Failed to load saved layout, using default:', error);
return Model.fromJson(createDefaultLayout());
}
});
/**
* Reset layout to default and clear saved layout.
* 重置布局到默认状态并清除保存的布局。
*/
const resetLayout = useCallback(() => {
clearLayoutStorage();
skipNextSaveRef.current = true;
previousLayoutJsonRef.current = null;
previousPanelIdsRef.current = '';
const defaultLayout = createDefaultLayout();
setModel(Model.fromJson(defaultLayout));
}, [createDefaultLayout]);
// Expose resetLayout method via ref | 通过 ref 暴露 resetLayout 方法
useImperativeHandle(ref, () => ({
resetLayout
}), [resetLayout]);
useEffect(() => {
try {
// 检查面板ID列表是否真的变化了而不只是标题等属性变化
@@ -168,26 +284,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
previousPanelIdsRef.current = currentPanelIds;
// 如果已经有布局且只是添加新面板使用Action动态添加
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
// 检查新面板是否需要独立 tabset如 bottom 位置的面板)
// Check if new panels require separate tabset (e.g., bottom position panels)
const newPanelsWithConfig = panels.filter((p) => newPanelIds.includes(p.id));
const hasSpecialLayoutPanels = newPanelsWithConfig.some((p) =>
p.layout?.requiresSeparateTabset || p.layout?.position === 'bottom'
);
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds && !hasSpecialLayoutPanels) {
// 找到要添加的面板
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
// 找到中心区域的tabset ID
// 构建面板位置映射 | Build panel position map
const panelPositionMap = new Map(panels.map((p) => [p.id, p.layout?.position || 'center']));
// 找到中心区域的tabset ID | Find center tabset ID
let centerTabsetId: string | null = null;
model.visitNodes((node: any) => {
if (node.getType() === 'tabset') {
const tabset = node as any;
// 检查是否是中心tabset
// 检查是否是中心tabset(包含 center 位置的面板)
// Check if this is center tabset (contains center position panels)
const children = tabset.getChildren();
const hasNonSidePanel = children.some((child: any) => {
const hasCenterPanel = children.some((child: any) => {
const id = child.getId();
return !id.includes('hierarchy') &&
!id.includes('asset') &&
!id.includes('inspector') &&
!id.includes('console');
const position = panelPositionMap.get(id);
return position === 'center' || position === undefined;
});
if (hasNonSidePanel && !centerTabsetId) {
if (hasCenterPanel && !centerTabsetId) {
centerTabsetId = tabset.getId();
}
}
@@ -229,7 +353,9 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
const defaultLayout = createDefaultLayout();
// 如果有保存的布局,尝试合并
if (previousLayoutJsonRef.current && previousIds) {
// 注意:如果新面板需要特殊布局(独立 tabset直接使用默认布局
// Note: If new panels need special layout (separate tabset), use default layout directly
if (previousLayoutJsonRef.current && previousIds && !hasSpecialLayoutPanels) {
try {
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
@@ -340,6 +466,13 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
const layoutJson = newModel.toJson();
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
// Save to localStorage (unless skipped) | 保存到 localStorage除非跳过
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
} else {
saveLayoutToStorage(layoutJson);
}
// Check if any tabset is maximized
let hasMaximized = false;
newModel.visitNodes((node) => {
@@ -390,7 +523,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
))}
</div>
);
}
});
/**
* Container for persistent panel content.

View File

@@ -1,18 +1,19 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Component, Core, getComponentInstanceTypeName, PrefabInstanceComponent, Entity } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry, PrefabService } 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 { AssetField } from './inspectors/fields/AssetField';
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
import { useLocale } from '../hooks/useLocale';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
interface PropertyInspectorProps {
component: Component;
entity?: any;
entity?: Entity;
version?: number;
onChange?: (propertyName: string, value: any) => void;
onAction?: (actionId: string, propertyName: string, component: Component) => void;
@@ -21,9 +22,47 @@ interface PropertyInspectorProps {
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
void version;
// 获取预制体服务和组件名称 | Get prefab service and component name
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
// 获取预制体实例组件 | Get prefab instance component
const prefabInstanceComp = useMemo(() => {
return entity?.getComponent(PrefabInstanceComponent) ?? null;
}, [entity, version]);
// 检查属性是否被覆盖 | Check if property is overridden
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
if (!prefabInstanceComp) return false;
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
}, [prefabInstanceComp, componentTypeName]);
// 处理属性右键菜单 | Handle property context menu
const handlePropertyContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
if (!isPropertyOverridden(propertyName)) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
}, [isPropertyOverridden]);
// 还原属性 | Revert property
const handleRevertProperty = useCallback(async () => {
if (!contextMenu || !prefabService || !entity) return;
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
setContextMenu(null);
}, [contextMenu, prefabService, entity, componentTypeName]);
// 关闭右键菜单 | Close context menu
useEffect(() => {
const handleClick = () => setContextMenu(null);
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
// Scan entity for components that control this component's properties
useEffect(() => {
if (!entity) return;
@@ -236,7 +275,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
const canCreate = creationMapping !== null;
return (
<div key={propertyName} className="property-field">
<div key={propertyName} className="property-field property-field-asset">
<label className="property-label">
{label}
{controlledBy && (
@@ -300,6 +339,28 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
/>
);
case 'array': {
const arrayMeta = metadata as {
itemType?: { type: string; extensions?: string[]; assetType?: string };
minLength?: number;
maxLength?: number;
reorderable?: boolean;
};
return (
<ArrayField
key={propertyName}
label={label}
value={value ?? []}
itemType={arrayMeta.itemType}
minLength={arrayMeta.minLength}
maxLength={arrayMeta.maxLength}
reorderable={arrayMeta.reorderable}
readOnly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
}
default:
return null;
}
@@ -307,8 +368,36 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
return (
<div className="property-inspector">
{Object.entries(properties).map(([propertyName, metadata]) =>
renderProperty(propertyName, metadata)
{Object.entries(properties).map(([propertyName, metadata]) => {
const overridden = isPropertyOverridden(propertyName);
return (
<div
key={propertyName}
className={`property-row ${overridden ? 'overridden' : ''}`}
onContextMenu={(e) => handlePropertyContextMenu(e, propertyName)}
>
{renderProperty(propertyName, metadata)}
{overridden && (
<span className="property-override-indicator" title="Modified from prefab" />
)}
</div>
);
})}
{/* 右键菜单 | Context Menu */}
{contextMenu && (
<div
className="property-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="property-context-menu-item"
onClick={handleRevertProperty}
>
<span></span>
<span>Revert to Prefab</span>
</button>
</div>
)}
</div>
);
@@ -331,8 +420,17 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const [localValue, setLocalValue] = useState(String(value ?? 0));
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 同步外部值 | Sync external value
useEffect(() => {
if (!isFocused && !isDragging) {
setLocalValue(String(value ?? 0));
}
}, [value, isFocused, isDragging]);
const renderActionButton = (action: PropertyAction) => {
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
return (
@@ -389,6 +487,33 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setLocalValue(String(value ?? 0));
e.currentTarget.blur();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
e.target.select();
};
const handleBlur = () => {
setIsFocused(false);
let val = parseFloat(localValue) || 0;
if (min !== undefined) val = Math.max(min, val);
if (max !== undefined) val = Math.min(max, val);
if (isInteger) val = Math.round(val);
onChange(val);
};
return (
<div className="property-field">
<label
@@ -402,16 +527,15 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
ref={inputRef}
type="number"
className="property-input property-input-number"
value={value}
value={localValue}
min={min}
max={max}
step={step}
disabled={readOnly}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
onChange(isInteger ? Math.round(val) : val);
}}
onFocus={(e) => e.target.select()}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
{actions && actions.length > 0 && (
<div className="property-actions">
@@ -430,16 +554,42 @@ interface StringFieldProps {
}
function StringField({ label, value, readOnly, onChange }: StringFieldProps) {
const [localValue, setLocalValue] = useState(value ?? '');
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) {
setLocalValue(value ?? '');
}
}, [value, isFocused]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setLocalValue(value ?? '');
e.currentTarget.blur();
}
};
return (
<div className="property-field">
<label className="property-label">{label}</label>
<input
type="text"
className="property-input property-input-text"
value={value}
value={localValue}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => e.target.select()}
onChange={(e) => setLocalValue(e.target.value)}
onFocus={(e) => {
setIsFocused(true);
e.target.select();
}}
onBlur={() => {
setIsFocused(false);
onChange(localValue);
}}
onKeyDown={handleKeyDown}
/>
</div>
);
@@ -695,7 +845,17 @@ interface DraggableAxisInputProps {
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [localValue, setLocalValue] = useState(String(value ?? 0));
const [isFocused, setIsFocused] = useState(false);
const dragStartRef = useRef({ x: 0, value: 0 });
const inputRef = useRef<HTMLInputElement>(null);
// 同步外部值(不在聚焦或拖动时)| Sync external value (not when focused or dragging)
useEffect(() => {
if (!isFocused && !isDragging) {
setLocalValue(String(value ?? 0));
}
}, [value, isFocused, isDragging]);
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
@@ -730,6 +890,37 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
const axisClass = `property-vector-axis-${axis}`;
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// 确认输入并失焦 | Confirm input and blur
e.currentTarget.blur();
} else if (e.key === 'Escape') {
// 取消输入,恢复原值 | Cancel input, restore original value
setLocalValue(String(value ?? 0));
e.currentTarget.blur();
}
// Tab 键使用浏览器默认行为 | Tab uses browser default behavior
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
e.target.select();
};
const handleBlur = () => {
setIsFocused(false);
const parsed = parseFloat(localValue);
if (!isNaN(parsed)) {
onChange(Math.round(parsed * 1000) / 1000);
} else {
setLocalValue(String(value ?? 0));
}
};
return (
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
<span
@@ -740,13 +931,16 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
{axis.toUpperCase()}
</span>
<input
ref={inputRef}
type="number"
className={inputClass}
value={value ?? 0}
value={localValue}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</div>
);
@@ -954,3 +1148,158 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
);
}
// ============= ArrayField 数组字段组件 =============
interface ArrayFieldProps {
label: string;
value: any[];
itemType?: { type: string; extensions?: string[]; assetType?: string };
minLength?: number;
maxLength?: number;
reorderable?: boolean;
readOnly?: boolean;
onChange: (value: any[]) => void;
}
function ArrayField({
label,
value,
itemType,
minLength = 0,
maxLength = 100,
reorderable = true,
readOnly,
onChange
}: ArrayFieldProps) {
const { t } = useLocale();
const [isExpanded, setIsExpanded] = useState(true);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const safeValue = Array.isArray(value) ? value : [];
const canAdd = !readOnly && safeValue.length < maxLength;
const canRemove = !readOnly && safeValue.length > minLength;
const handleAdd = () => {
if (!canAdd) return;
let defaultValue: any = '';
if (itemType?.type === 'number') defaultValue = 0;
if (itemType?.type === 'boolean') defaultValue = false;
onChange([...safeValue, defaultValue]);
};
const handleRemove = (index: number) => {
if (!canRemove) return;
const newValue = [...safeValue];
newValue.splice(index, 1);
onChange(newValue);
};
const handleItemChange = (index: number, newItemValue: any) => {
const newValue = [...safeValue];
newValue[index] = newItemValue;
onChange(newValue);
};
const handleDragStart = (index: number) => {
if (!reorderable || readOnly) return;
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
const newValue = [...safeValue];
const [removed] = newValue.splice(dragIndex, 1);
newValue.splice(index, 0, removed);
onChange(newValue);
setDragIndex(index);
};
const handleDragEnd = () => {
setDragIndex(null);
};
// 渲染数组项 | Render array item
const renderItem = (item: any, index: number) => {
const isAsset = itemType?.type === 'asset';
return (
<div
key={index}
className={`array-field-item ${dragIndex === index ? 'dragging' : ''}`}
draggable={reorderable && !readOnly}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
>
{reorderable && !readOnly && (
<span className="array-field-drag-handle" title={t('inspector.array.dragToReorder')}></span>
)}
<span className="array-field-index">[{index}]</span>
<div className="array-field-value">
{isAsset ? (
<AssetField
value={item ?? null}
onChange={(newValue) => handleItemChange(index, newValue || '')}
fileExtension={itemType?.extensions?.[0] || ''}
placeholder={t('inspector.array.dropAsset')}
readonly={readOnly}
/>
) : (
<input
type="text"
className="property-input property-input-text"
value={item ?? ''}
disabled={readOnly}
onChange={(e) => handleItemChange(index, e.target.value)}
/>
)}
</div>
{canRemove && (
<button
className="array-field-remove"
onClick={() => handleRemove(index)}
title={t('inspector.array.remove')}
>
×
</button>
)}
</div>
);
};
return (
<div className="property-field property-field-array">
<div className="array-field-header">
<button
className="property-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<label className="property-label">{label}</label>
<span className="array-field-count">[{safeValue.length}]</span>
{canAdd && (
<button
className="array-field-add"
onClick={handleAdd}
title={t('inspector.array.add')}
>
+
</button>
)}
</div>
{isExpanded && (
<div className="array-field-items">
{safeValue.length === 0 ? (
<div className="array-field-empty">{t('inspector.array.empty')}</div>
) : (
safeValue.map((item, index) => renderItem(item, index))
)}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle, Terminal } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo';
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
@@ -35,6 +36,10 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
const [isInstalling, setIsInstalling] = useState(false);
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
const [showEnvStatus, setShowEnvStatus] = useState(false);
const [showEsbuildInstall, setShowEsbuildInstall] = useState(false);
const [isInstallingEsbuild, setIsInstallingEsbuild] = useState(false);
const [installProgress, setInstallProgress] = useState('');
const [installError, setInstallError] = useState('');
const langMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -70,15 +75,74 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
console.log('[Environment] Ready ✓');
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
} else {
// 环境有问题,显示提示
setShowEnvStatus(true);
// esbuild 未安装,显示安装对话框
console.warn('[Environment] Not ready:', result.esbuild.error);
setShowEsbuildInstall(true);
}
}).catch((error) => {
console.error('[Environment] Check failed:', error);
});
}, []);
// 监听 esbuild 安装进度事件
useEffect(() => {
let unlisten: UnlistenFn | undefined;
const setupListeners = async () => {
// 监听安装进度
unlisten = await listen<string>('esbuild-install:progress', (event) => {
setInstallProgress(event.payload);
});
// 监听安装成功
const unlistenSuccess = await listen('esbuild-install:success', async () => {
// 重新检测环境
const result = await TauriAPI.checkEnvironment();
setEnvCheck(result);
if (result.ready) {
setShowEsbuildInstall(false);
setIsInstallingEsbuild(false);
setInstallProgress('');
setInstallError('');
}
});
// 监听安装错误
const unlistenError = await listen<string>('esbuild-install:error', (event) => {
setInstallError(event.payload);
setIsInstallingEsbuild(false);
});
return () => {
unlisten?.();
unlistenSuccess();
unlistenError();
};
};
setupListeners();
return () => {
unlisten?.();
};
}, []);
// 处理 esbuild 安装
const handleInstallEsbuild = async () => {
setIsInstallingEsbuild(true);
setInstallProgress(t('startup.installingEsbuild'));
setInstallError('');
try {
await TauriAPI.installEsbuild();
// 成功会通过事件处理
} catch (error) {
console.error('[Environment] Failed to install esbuild:', error);
setInstallError(String(error));
setIsInstallingEsbuild(false);
}
};
const handleInstallUpdate = async () => {
setIsInstalling(true);
const success = await installUpdate();
@@ -343,6 +407,57 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div>
</div>
)}
{/* esbuild 安装对话框 | esbuild Installation Dialog */}
{showEsbuildInstall && (
<div className="startup-dialog-overlay">
<div className="startup-dialog">
<div className="startup-dialog-header">
<Terminal size={20} className="dialog-icon-info" />
<h3>{t('startup.esbuildNotInstalled')}</h3>
</div>
<div className="startup-dialog-body">
<p>{t('startup.esbuildRequired')}</p>
<p className="startup-dialog-info">{t('startup.esbuildInstallPrompt')}</p>
{/* 安装进度 | Installation Progress */}
{isInstallingEsbuild && (
<div className="startup-dialog-progress">
<Loader2 size={16} className="animate-spin" />
<span>{installProgress}</span>
</div>
)}
{/* 错误信息 | Error Message */}
{installError && (
<div className="startup-dialog-error">
<AlertCircle size={16} />
<span>{installError}</span>
</div>
)}
</div>
<div className="startup-dialog-footer">
<button
className="startup-dialog-btn primary"
onClick={handleInstallEsbuild}
disabled={isInstallingEsbuild}
>
{isInstallingEsbuild ? (
<>
<Loader2 size={14} className="animate-spin" />
{t('startup.installing')}
</>
) : (
<>
<Download size={14} />
{t('startup.installNow')}
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X } from 'lucide-react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
import type { MessageHub, LogService } from '@esengine/editor-core';
import { ContentBrowser } from './ContentBrowser';
import { OutputLogPanel } from './OutputLogPanel';
@@ -14,6 +14,10 @@ interface StatusBarProps {
locale?: string;
projectPath?: string | null;
onOpenScene?: (scenePath: string) => void;
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
onDockContentBrowser?: () => void;
/** 重置布局回调 | Callback to reset layout */
onResetLayout?: () => void;
}
type ActiveTab = 'output' | 'cmd';
@@ -25,7 +29,9 @@ export function StatusBar({
logService,
locale = 'en',
projectPath,
onOpenScene
onOpenScene,
onDockContentBrowser,
onResetLayout
}: StatusBarProps) {
const { t } = useLocale();
const [consoleInput, setConsoleInput] = useState('');
@@ -224,6 +230,11 @@ export function StatusBar({
onOpenScene={onOpenScene}
isDrawer={true}
revealPath={revealPath}
onDockInLayout={() => {
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
setContentDrawerOpen(false);
onDockContentBrowser?.();
}}
/>
</div>
</div>
@@ -303,6 +314,13 @@ export function StatusBar({
<div className="status-bar-divider" />
<div className="status-bar-icon-group">
<button
className="status-bar-icon-btn"
title={t('statusBar.resetLayout')}
onClick={onResetLayout}
>
<LayoutGrid size={14} />
</button>
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
<Wifi size={14} />
</button>

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
import { UIRegistry, MessageHub, PluginManager, CommandManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
@@ -21,6 +21,7 @@ interface TitleBarProps {
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: PluginManager;
commandManager?: CommandManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
@@ -44,6 +45,7 @@ export function TitleBar({
uiRegistry,
messageHub,
pluginManager,
commandManager,
onNewScene,
onOpenScene,
onSaveScene,
@@ -65,9 +67,42 @@ export function TitleBar({
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
const [isMaximized, setIsMaximized] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const appWindow = getCurrentWindow();
// Update undo/redo state | 更新撤销/重做状态
const updateUndoRedoState = useCallback(() => {
if (commandManager) {
setCanUndo(commandManager.canUndo());
setCanRedo(commandManager.canRedo());
}
}, [commandManager]);
// Handle undo | 处理撤销
const handleUndo = useCallback(() => {
if (commandManager && commandManager.canUndo()) {
commandManager.undo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Handle redo | 处理重做
const handleRedo = useCallback(() => {
if (commandManager && commandManager.canRedo()) {
commandManager.redo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Update undo/redo state periodically | 定期更新撤销/重做状态
useEffect(() => {
updateUndoRedoState();
const interval = setInterval(updateUndoRedoState, 100);
return () => clearInterval(interval);
}, [updateUndoRedoState]);
const updateMenuItems = () => {
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
@@ -135,8 +170,8 @@ export function TitleBar({
{ label: t('menu.file.exit'), onClick: onExit }
],
edit: [
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: true },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: true },
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: !canUndo, onClick: handleUndo },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: !canRedo, onClick: handleRedo },
{ separator: true },
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },

View File

@@ -2,14 +2,17 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import {
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
Magnet, ZoomIn
Magnet, ZoomIn, Save, X, PackageOpen
} from 'lucide-react';
import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { useLocale } from '../hooks/useLocale';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { UITransformComponent } from '@esengine/ui';
@@ -17,6 +20,7 @@ import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
import { QRCodeDialog } from './QRCodeDialog';
import { collectAssetReferences } from '@esengine/asset-system';
import type { ModuleManifest } from '../services/RuntimeResolver';
@@ -52,39 +56,53 @@ function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleM
// Generate user runtime loading code
// 生成用户运行时加载代码
// Now we only load @esengine/sdk as a single global
// 现在只加载 @esengine/sdk 作为单一全局变量
const userRuntimeCode = hasUserRuntime ? `
updateLoading('Loading user scripts...');
try {
// Import ECS framework and set up global for user-runtime.js shim
// 导入 ECS 框架并为 user-runtime.js 设置全局变量
const ecsFramework = await import('@esengine/ecs-framework');
window.__ESENGINE__ = window.__ESENGINE__ || {};
window.__ESENGINE__.ecsFramework = ecsFramework;
// Load unified SDK and set global
// 加载统一 SDK 并设置全局变量
console.log('[Preview] Loading @esengine/sdk...');
const sdk = await import('@esengine/sdk');
window.__ESENGINE_SDK__ = sdk;
console.log('[Preview] SDK loaded successfully');
// Check SDK is valid
// 检查 SDK 是否有效
if (!sdk.Component || !sdk.ComponentRegistry) {
throw new Error('SDK missing critical exports (Component, ComponentRegistry)');
}
// Load user-runtime.js which contains compiled user components
// 加载 user-runtime.js其中包含编译的用户组件
console.log('[Preview] Loading user-runtime.js...');
const userRuntimeScript = document.createElement('script');
userRuntimeScript.src = './user-runtime.js?_=' + Date.now();
await new Promise((resolve, reject) => {
userRuntimeScript.onload = resolve;
userRuntimeScript.onerror = reject;
userRuntimeScript.onerror = (e) => reject(new Error('Failed to load user-runtime.js: ' + e.message));
document.head.appendChild(userRuntimeScript);
});
console.log('[Preview] user-runtime.js loaded successfully');
// Register user components to ComponentRegistry
// 将用户组件注册到 ComponentRegistry
if (window.__USER_RUNTIME_EXPORTS__) {
const { ComponentRegistry, Component } = ecsFramework;
const { ComponentRegistry, Component } = window.__ESENGINE_SDK__;
const exports = window.__USER_RUNTIME_EXPORTS__;
for (const [name, exported] of Object.entries(exports)) {
if (typeof exported === 'function' && exported.prototype instanceof Component) {
ComponentRegistry.register(exported);
console.log('[Preview] Registered user component:', name);
if (ComponentRegistry && Component) {
for (const [name, exported] of Object.entries(exports)) {
if (typeof exported === 'function' && exported.prototype instanceof Component) {
ComponentRegistry.register(exported);
console.log('[Preview] Registered user component:', name);
}
}
}
}
} catch (e) {
console.warn('[Preview] Failed to load user scripts:', e.message);
console.error('[Preview] Failed to load user scripts:', e.message, e);
throw e; // Re-throw to show error in UI
}
` : '';
@@ -146,12 +164,13 @@ ${importMapScript}
const errorTitle = document.getElementById('error-title');
const errorMessage = document.getElementById('error-message');
function showError(title, msg) {
function showError(title, msg, error) {
loading.style.display = 'none';
errorTitle.textContent = title || 'Failed to start';
errorMessage.textContent = msg;
const stack = error?.stack || '';
errorMessage.textContent = msg + (stack ? '\\n\\nStack:\\n' + stack : '');
errorDiv.classList.add('show');
console.error('[Preview]', msg);
console.error('[Preview]', msg, error || '');
}
function updateLoading(msg) {
@@ -191,7 +210,7 @@ ${userRuntimeCode}
});
console.log('[Preview] Started successfully');
} catch (error) {
showError(null, error.message || String(error));
showError(null, error.message || String(error), error);
}
</script>
</body>
@@ -205,9 +224,10 @@ export type PlayState = 'stopped' | 'playing' | 'paused';
interface ViewportProps {
locale?: string;
messageHub?: MessageHub;
commandManager?: CommandManager;
}
export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
export function Viewport({ locale = 'en', messageHub, commandManager }: ViewportProps) {
const { t } = useLocale();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -221,6 +241,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
const runMenuRef = useRef<HTMLDivElement>(null);
// Prefab edit mode state | 预制体编辑模式状态
const [prefabEditMode, setPrefabEditMode] = useState<{
isActive: boolean;
prefabName: string;
prefabPath: string;
} | null>(null);
// Snap settings
const [snapEnabled, setSnapEnabled] = useState(true);
const [gridSnapValue, setGridSnapValue] = useState(10);
@@ -237,10 +264,15 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const playStateRef = useRef<PlayState>('stopped');
// Keep ref in sync with state
useEffect(() => {
playStateRef.current = playState;
}, [playState]);
// Live transform display state | 实时变换显示状态
const [liveTransform, setLiveTransform] = useState<{
type: 'move' | 'rotate' | 'scale';
x: number;
y: number;
rotation?: number;
scaleX?: number;
scaleY?: number;
} | null>(null);
// Rust engine hook with multi-viewport support
const engine = useEngine({
@@ -261,40 +293,28 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const lastMousePosRef = useRef({ x: 0, y: 0 });
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const commandManagerRef = useRef<CommandManager | null>(null);
const transformModeRef = useRef<TransformMode>('select');
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
const initialTransformStateRef = useRef<TransformState | null>(null);
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
const scaleSnapRef = useRef(0.25);
// Keep refs in sync with state
// Keep refs in sync with state for stable event handler closures
// 保持 refs 与 state 同步,以便事件处理器闭包稳定
useEffect(() => {
playStateRef.current = playState;
camera2DZoomRef.current = camera2DZoom;
}, [camera2DZoom]);
useEffect(() => {
camera2DOffsetRef.current = camera2DOffset;
}, [camera2DOffset]);
useEffect(() => {
transformModeRef.current = transformMode;
}, [transformMode]);
useEffect(() => {
snapEnabledRef.current = snapEnabled;
}, [snapEnabled]);
useEffect(() => {
gridSnapRef.current = gridSnapValue;
}, [gridSnapValue]);
useEffect(() => {
rotationSnapRef.current = rotationSnapValue;
}, [rotationSnapValue]);
useEffect(() => {
scaleSnapRef.current = scaleSnapValue;
}, [scaleSnapValue]);
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
@@ -351,6 +371,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}, []);
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
useEffect(() => {
commandManagerRef.current = commandManager ?? null;
}, [commandManager]);
// Canvas setup and input handling
useEffect(() => {
const canvas = canvasRef.current;
@@ -415,6 +440,21 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// In transform mode, left click transforms entity
isDraggingTransformRef.current = true;
canvas.style.cursor = 'move';
// Capture initial transform state for undo/redo
// 捕获初始变换状态用于撤销/重做
const entity = selectedEntityRef.current;
if (entity) {
const transform = entity.getComponent(TransformComponent);
const uiTransform = entity.getComponent(UITransformComponent);
if (transform) {
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
transformComponentRef.current = transform;
} else if (uiTransform) {
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
transformComponentRef.current = uiTransform;
}
}
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
@@ -468,6 +508,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Update live transform display | 更新实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: transform.position.x,
y: transform.position.y,
rotation: transform.rotation.z * 180 / Math.PI,
scaleX: transform.scale.x,
scaleY: transform.scale.y
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
const value = propertyName === 'position' ? transform.position :
@@ -517,6 +567,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Update live transform display for UI | 更新 UI 的实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation * 180 / Math.PI,
scaleX: uiTransform.scaleX,
scaleY: uiTransform.scaleY
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
messageHubRef.current.publish('component:property:changed', {
@@ -542,6 +602,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
if (isDraggingTransformRef.current) {
isDraggingTransformRef.current = false;
canvas.style.cursor = 'grab';
// Clear live transform display | 清除实时变换显示
setLiveTransform(null);
// Apply snap on mouse up
const entity = selectedEntityRef.current;
@@ -574,6 +636,36 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
const initialState = initialTransformStateRef.current;
const component = transformComponentRef.current;
const hub = messageHubRef.current;
const cmdManager = commandManagerRef.current;
if (entity && initialState && component && hub && cmdManager) {
const mode = transformModeRef.current as TransformOperationType;
let newState: TransformState;
if (component instanceof TransformComponent) {
newState = TransformCommand.captureTransformState(component);
} else {
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
}
// Only create command if state actually changed | 只有状态实际改变时才创建命令
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
if (hasChanged) {
const cmd = new TransformCommand(hub, entity, component, mode, initialState, newState);
// Push to undo stack without re-executing (already applied during drag)
// 推入撤销栈但不重新执行(拖动时已应用)
cmdManager.pushWithoutExecute(cmd);
}
}
// Clear refs | 清除引用
initialTransformStateRef.current = null;
transformComponentRef.current = null;
// Notify Inspector to refresh after transform change
if (messageHubRef.current && selectedEntityRef.current) {
messageHubRef.current.publish('entity:selected', {
@@ -839,8 +931,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
await TauriAPI.createDirectory(assetsDir);
}
// Collect all asset paths from scene
// 从场景中收集所有资产路径
// Collect all asset references from scene using generic collector
// 使用通用收集器从场景中收集所有资产引用
const sceneObj = JSON.parse(sceneData);
const assetPaths = new Set<string>();
// GUID 到路径的映射,用于需要通过 GUID 加载的资产
@@ -850,69 +942,65 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// Get asset registry for resolving GUIDs
const assetRegistry = Core.services.tryResolve(AssetRegistryService);
// Scan all components for asset references
if (sceneObj.entities) {
for (const entity of sceneObj.entities) {
if (entity.components) {
for (const comp of entity.components) {
// Sprite textures
if (comp.type === 'Sprite' && comp.data?.texture) {
assetPaths.add(comp.data.texture);
}
// Behavior tree assets
if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) {
assetPaths.add(comp.data.treeAssetId);
}
// Tilemap assets
if (comp.type === 'Tilemap' && comp.data?.tmxPath) {
assetPaths.add(comp.data.tmxPath);
}
// Audio assets
if (comp.type === 'AudioSource' && comp.data?.clip) {
assetPaths.add(comp.data.clip);
}
// Particle assets - resolve GUID to path
if (comp.type === 'ParticleSystem' && comp.data?.particleAssetGuid) {
const guid = comp.data.particleAssetGuid;
if (assetRegistry) {
const relativePath = assetRegistry.getPathByGuid(guid);
if (relativePath && projectPath) {
// Convert relative path to absolute path
// 将相对路径转换为绝对路径
const absolutePath = `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
assetPaths.add(absolutePath);
guidToPath.set(guid, absolutePath);
// Use generic asset collector to find all asset references
// 使用通用资产收集器找到所有资产引用
const assetReferences = collectAssetReferences(sceneObj);
// Also check for texture referenced in particle asset
// 同时检查粒子资产中引用的纹理
try {
const particleContent = await TauriAPI.readFileContent(absolutePath);
const particleData = JSON.parse(particleContent);
const textureRef = particleData.textureGuid || particleData.texturePath;
if (textureRef) {
// Check if it's a GUID or a path
if (textureRef.includes('-') && textureRef.length > 30) {
// Looks like a GUID
const textureRelPath = assetRegistry.getPathByGuid(textureRef);
if (textureRelPath && projectPath) {
const textureAbsPath = `${projectPath}\\${textureRelPath.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
guidToPath.set(textureRef, textureAbsPath);
}
} else {
// It's a path
const textureAbsPath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
}
}
} catch {
// Ignore parse errors
}
}
}
// Helper: check if value looks like a GUID
const isGuidLike = (value: string) =>
value.includes('-') && value.length >= 30 && value.length <= 40;
// Helper: resolve GUID to absolute path
const resolveGuidToPath = (guid: string): string | null => {
if (!assetRegistry || !projectPath) return null;
const relativePath = assetRegistry.getPathByGuid(guid);
if (!relativePath) return null;
return `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
};
// Helper: load particle asset and extract texture references
const loadParticleTextures = async (particlePath: string) => {
try {
const particleContent = await TauriAPI.readFileContent(particlePath);
const particleData = JSON.parse(particleContent);
const textureRef = particleData.textureGuid || particleData.texturePath;
if (textureRef) {
if (isGuidLike(textureRef)) {
const texturePath = resolveGuidToPath(textureRef);
if (texturePath) {
assetPaths.add(texturePath);
guidToPath.set(textureRef, texturePath);
}
} else if (projectPath) {
const texturePath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
assetPaths.add(texturePath);
}
}
} catch {
// Ignore parse errors
}
};
// Process collected asset references
// 处理收集的资产引用
for (const ref of assetReferences) {
const value = ref.guid;
// Check if it's a GUID that needs resolution
if (isGuidLike(value)) {
const absolutePath = resolveGuidToPath(value);
if (absolutePath) {
assetPaths.add(absolutePath);
guidToPath.set(value, absolutePath);
// If it's a particle asset, also load its texture references
if (absolutePath.endsWith('.particle') || absolutePath.endsWith('.particle.json')) {
await loadParticleTextures(absolutePath);
}
}
} else {
// It's a direct path
assetPaths.add(value);
}
}
@@ -931,9 +1019,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
// Get filename and determine relative path
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
// Path format: relative to assets directory, without 'assets/' prefix
const filename = assetPath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const relativePath = `assets/${filename}`;
const relativePath = filename;
// Copy file
await TauriAPI.copyFile(assetPath, destPath);
@@ -1200,6 +1290,68 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
};
}, [messageHub]);
// Subscribe to prefab edit mode changes | 监听预制体编辑模式变化
useEffect(() => {
if (!messageHub) return;
const unsubscribePrefabEditMode = messageHub.subscribe('prefab:editMode:changed', (data: {
isActive: boolean;
prefabPath?: string;
prefabName?: string;
}) => {
if (data.isActive && data.prefabName && data.prefabPath) {
setPrefabEditMode({
isActive: true,
prefabName: data.prefabName,
prefabPath: data.prefabPath
});
} else {
setPrefabEditMode(null);
}
});
// Check initial prefab edit mode state | 检查初始预制体编辑模式状态
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager) {
const prefabState = sceneManager.getPrefabEditModeState?.();
if (prefabState?.isActive) {
setPrefabEditMode({
isActive: true,
prefabName: prefabState.prefabName,
prefabPath: prefabState.prefabPath
});
}
}
return () => {
unsubscribePrefabEditMode();
};
}, [messageHub]);
// Handle prefab save | 处理预制体保存
const handleSavePrefab = useCallback(async () => {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager?.isPrefabEditMode?.()) {
try {
await sceneManager.savePrefab();
} catch (error) {
console.error('Failed to save prefab:', error);
}
}
}, []);
// Handle exit prefab edit mode | 处理退出预制体编辑模式
const handleExitPrefabEditMode = useCallback(async (save: boolean = false) => {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager?.isPrefabEditMode?.()) {
try {
await sceneManager.exitPrefabEditMode(save);
} catch (error) {
console.error('Failed to exit prefab edit mode:', error);
}
}
}, []);
const handleFullscreen = () => {
if (containerRef.current) {
if (document.fullscreenElement) {
@@ -1271,8 +1423,110 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
/**
* 处理视口拖放(用于预制体实例化)
* Handle viewport drag-drop (for prefab instantiation)
*/
const handleViewportDragOver = useCallback((e: React.DragEvent) => {
const hasAssetPath = e.dataTransfer.types.includes('asset-path');
if (hasAssetPath) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}, []);
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
const assetPath = e.dataTransfer.getData('asset-path');
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
return;
}
e.preventDefault();
try {
// 读取预制体文件 | Read prefab file
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
if (!entityStore || !messageHub || !commandManager) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
// 创建实例化命令 | Create instantiate command
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
} catch (error) {
console.error('[Viewport] Failed to instantiate prefab:', error);
}
}, [messageHub, commandManager]);
return (
<div className="viewport" ref={containerRef}>
<div
className={`viewport ${prefabEditMode?.isActive ? 'prefab-edit-mode' : ''}`}
ref={containerRef}
onDragOver={handleViewportDragOver}
onDrop={handleViewportDrop}
>
{/* Prefab Edit Mode Toolbar | 预制体编辑模式工具栏 */}
{prefabEditMode?.isActive && (
<div className="viewport-prefab-toolbar">
<div className="viewport-prefab-toolbar-left">
<PackageOpen size={14} />
<span className="prefab-name">{t('viewport.prefab.editing') || 'Editing'}: {prefabEditMode.prefabName}</span>
</div>
<div className="viewport-prefab-toolbar-right">
<button
className="viewport-prefab-btn save"
onClick={handleSavePrefab}
title={t('viewport.prefab.save') || 'Save Prefab'}
>
<Save size={14} />
<span>{t('viewport.prefab.save') || 'Save'}</span>
</button>
<button
className="viewport-prefab-btn exit"
onClick={() => handleExitPrefabEditMode(false)}
title={t('viewport.prefab.exit') || 'Exit Edit Mode'}
>
<X size={14} />
<span>{t('viewport.prefab.exit') || 'Exit'}</span>
</button>
</div>
</div>
)}
{/* Internal Overlay Toolbar */}
<div className="viewport-internal-toolbar">
<div className="viewport-internal-toolbar-left">
@@ -1505,6 +1759,34 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
</div>
)}
{/* Live Transform Display | 实时变换显示 */}
{liveTransform && (
<div className="viewport-live-transform">
{liveTransform.type === 'move' && (
<>
<span className="live-transform-label">X:</span>
<span className="live-transform-value">{liveTransform.x.toFixed(1)}</span>
<span className="live-transform-label">Y:</span>
<span className="live-transform-value">{liveTransform.y.toFixed(1)}</span>
</>
)}
{liveTransform.type === 'rotate' && (
<>
<span className="live-transform-label">R:</span>
<span className="live-transform-value">{liveTransform.rotation?.toFixed(1)}°</span>
</>
)}
{liveTransform.type === 'scale' && (
<>
<span className="live-transform-label">SX:</span>
<span className="live-transform-value">{liveTransform.scaleX?.toFixed(2)}</span>
<span className="live-transform-label">SY:</span>
<span className="live-transform-value">{liveTransform.scaleY?.toFixed(2)}</span>
</>
)}
</div>
)}
<QRCodeDialog
url={devicePreviewUrl}
isOpen={showQRDialog}

View File

@@ -1,164 +1,41 @@
import { useState, useEffect, useRef } from 'react';
import { Entity } from '@esengine/ecs-framework';
import { TauriAPI } from '../../api/tauri';
import { SettingsService } from '../../services/SettingsService';
import { InspectorProps, InspectorTarget, AssetFileInfo, RemoteEntity } from './types';
/**
* 检查器面板组件
* Inspector panel component
*
* 使用 InspectorStore 管理状态,减少 useEffect 数量
* Uses InspectorStore for state management to reduce useEffect count
*/
import { useEffect, useRef } from 'react';
import { useInspectorStore } from '../../stores';
import { InspectorProps } from './types';
import { getProfilerService } from './utils';
import {
EmptyInspector,
ExtensionInspector,
AssetFileInspector,
RemoteEntityInspector,
EntityInspector
EntityInspector,
PrefabInspector
} from './views';
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) {
const [target, setTarget] = useState<InspectorTarget>(null);
const [componentVersion, setComponentVersion] = useState(0);
const [autoRefresh, setAutoRefresh] = useState(true);
const [decimalPlaces, setDecimalPlaces] = useState(() => {
const settings = SettingsService.getInstance();
return settings.get<number>('inspector.decimalPlaces', 4);
});
const targetRef = useRef<InspectorTarget>(null);
// ===== 从 InspectorStore 获取状态 | Get state from InspectorStore =====
const {
target,
componentVersion,
autoRefresh,
setAutoRefresh,
isLocked,
setIsLocked,
decimalPlaces,
} = useInspectorStore();
useEffect(() => {
targetRef.current = target;
}, [target]);
useEffect(() => {
const handleSettingsChanged = (event: Event) => {
const customEvent = event as CustomEvent;
const changedSettings = customEvent.detail;
if ('inspector.decimalPlaces' in changedSettings) {
setDecimalPlaces(changedSettings['inspector.decimalPlaces']);
}
};
window.addEventListener('settings:changed', handleSettingsChanged);
return () => {
window.removeEventListener('settings:changed', handleSettingsChanged);
};
}, []);
useEffect(() => {
const handleEntitySelection = (data: { entity: Entity | null }) => {
if (data.entity) {
setTarget({ type: 'entity', data: data.entity });
} else {
setTarget(null);
}
setComponentVersion(0);
};
const handleRemoteEntitySelection = (data: { entity: RemoteEntity }) => {
setTarget({ type: 'remote-entity', data: data.entity });
const profilerService = getProfilerService();
if (profilerService && data.entity?.id !== undefined) {
profilerService.requestEntityDetails(data.entity.id);
}
};
const handleEntityDetails = (event: Event) => {
const customEvent = event as CustomEvent;
const details = customEvent.detail;
const currentTarget = targetRef.current;
if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) {
setTarget({ ...currentTarget, details });
}
};
const handleExtensionSelection = (data: { data: unknown }) => {
setTarget({ type: 'extension', data: data.data as Record<string, any> });
};
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
const fileInfo = data.fileInfo;
if (fileInfo.isDirectory) {
setTarget({ type: 'asset-file', data: fileInfo });
return;
}
const textExtensions = [
'txt',
'json',
'md',
'ts',
'tsx',
'js',
'jsx',
'css',
'html',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'conf',
'log',
'btree',
'ecs',
'mat',
'shader',
'tilemap',
'tileset'
];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase());
if (isTextFile) {
try {
const content = await TauriAPI.readFileContent(fileInfo.path);
setTarget({ type: 'asset-file', data: fileInfo, content });
} catch (error) {
console.error('Failed to read file content:', error);
setTarget({ type: 'asset-file', data: fileInfo });
}
} else if (isImageFile) {
setTarget({ type: 'asset-file', data: fileInfo, isImage: true });
} else {
setTarget({ type: 'asset-file', data: fileInfo });
}
};
const handleComponentChange = () => {
setComponentVersion((prev) => prev + 1);
};
const handleSceneRestored = () => {
// 场景恢复后,清除当前选中的实体(因为旧引用已无效)
// 用户需要重新选择实体
setTarget(null);
setComponentVersion(0);
};
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
return () => {
unsubEntitySelect();
unsubSceneRestored();
unsubRemoteSelect();
unsubNodeSelect();
unsubAssetFileSelect();
unsubComponentAdded();
unsubComponentRemoved();
unsubPropertyChanged();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub]);
// Ref 用于 profiler 回调访问最新状态 | Ref for profiler callback to access latest state
const targetRef = useRef(target);
targetRef.current = target;
// 自动刷新远程实体详情 | Auto-refresh remote entity details
useEffect(() => {
if (!autoRefresh || target?.type !== 'remote-entity') {
return;
@@ -183,6 +60,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
};
}, [autoRefresh, target?.type]);
// ===== 渲染 | Render =====
if (!target) {
return <EmptyInspector />;
}
@@ -192,12 +70,17 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
}
if (target.type === 'asset-file') {
// Check if a plugin provides a custom inspector for this asset type
// 预制体文件使用专用检查器 | Prefab files use dedicated inspector
if (target.data.extension?.toLowerCase() === 'prefab') {
return <PrefabInspector fileInfo={target.data} messageHub={messageHub} commandManager={commandManager} />;
}
// 检查插件是否提供自定义检查器 | Check if a plugin provides a custom inspector
const customInspector = inspectorRegistry.render(target, { target, projectPath });
if (customInspector) {
return customInspector;
}
// Fall back to default asset file inspector
// 回退到默认资产文件检查器 | Fall back to default asset file inspector
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
}
@@ -217,7 +100,16 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
}
if (target.type === 'entity') {
return <EntityInspector entity={target.data} messageHub={messageHub} commandManager={commandManager} componentVersion={componentVersion} />;
return (
<EntityInspector
entity={target.data}
messageHub={messageHub}
commandManager={commandManager}
componentVersion={componentVersion}
isLocked={isLocked}
onLockChange={setIsLocked}
/>
);
}
return null;

View File

@@ -0,0 +1,180 @@
/**
* 预制体实例信息组件
* Prefab instance info component
*
* 显示预制体实例状态和操作按钮Open, Select, Revert, Apply
* Displays prefab instance status and action buttons.
*/
import { useState, useCallback } from 'react';
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService, CommandManager } from '@esengine/editor-core';
import { ApplyPrefabCommand, RevertPrefabCommand, BreakPrefabLinkCommand } from '../../../application/commands/prefab';
import { useLocale } from '../../../hooks/useLocale';
import '../../../styles/PrefabInstanceInfo.css';
interface PrefabInstanceInfoProps {
entity: Entity;
prefabService: PrefabService;
messageHub: MessageHub;
commandManager?: CommandManager;
}
/**
* 预制体实例信息组件
* Prefab instance info component
*/
export function PrefabInstanceInfo({
entity,
prefabService,
messageHub,
commandManager
}: PrefabInstanceInfoProps) {
const { t } = useLocale();
const [isProcessing, setIsProcessing] = useState(false);
// 获取预制体实例组件 | Get prefab instance component
const prefabComp = entity.getComponent(PrefabInstanceComponent);
if (!prefabComp) return null;
// 只显示根实例的完整信息 | Only show full info for root instances
if (!prefabComp.isRoot) return null;
// 提取预制体名称 | Extract prefab name
const prefabPath = prefabComp.sourcePrefabPath;
const prefabName = prefabPath
? prefabPath.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab'
: 'Unknown';
// 修改数量 | Modification count
const modificationCount = prefabComp.modifiedProperties.length;
const hasModifications = modificationCount > 0;
// 打开预制体编辑模式 | Open prefab edit mode
const handleOpen = useCallback(() => {
messageHub.publish('prefab:editMode:enter', {
prefabPath: prefabComp.sourcePrefabPath
});
}, [messageHub, prefabComp.sourcePrefabPath]);
// 在内容浏览器中选择 | Select in content browser
const handleSelect = useCallback(() => {
messageHub.publish('content-browser:select', {
path: prefabComp.sourcePrefabPath
});
}, [messageHub, prefabComp.sourcePrefabPath]);
// 还原所有修改 | Revert all modifications
const handleRevert = useCallback(async () => {
if (!hasModifications) return;
const confirmed = window.confirm(t('inspector.prefab.revertConfirm'));
if (!confirmed) return;
setIsProcessing(true);
try {
if (commandManager) {
const command = new RevertPrefabCommand(prefabService, messageHub, entity);
await commandManager.execute(command);
} else {
await prefabService.revertInstance(entity);
}
} catch (error) {
console.error('Revert failed:', error);
} finally {
setIsProcessing(false);
}
}, [hasModifications, commandManager, prefabService, messageHub, entity, t]);
// 应用修改到预制体 | Apply modifications to prefab
const handleApply = useCallback(async () => {
if (!hasModifications) return;
const confirmed = window.confirm(t('inspector.prefab.applyConfirm', { name: prefabName }));
if (!confirmed) return;
setIsProcessing(true);
try {
if (commandManager) {
const command = new ApplyPrefabCommand(prefabService, messageHub, entity);
await commandManager.execute(command);
} else {
await prefabService.applyToPrefab(entity);
}
} catch (error) {
console.error('Apply failed:', error);
} finally {
setIsProcessing(false);
}
}, [hasModifications, commandManager, prefabService, messageHub, entity, prefabName, t]);
// 解包预制体(断开链接)| Unpack prefab (break link)
const handleUnpack = useCallback(() => {
const confirmed = window.confirm(t('inspector.prefab.unpackConfirm'));
if (!confirmed) return;
if (commandManager) {
const command = new BreakPrefabLinkCommand(prefabService, messageHub, entity);
commandManager.execute(command);
} else {
prefabService.breakPrefabLink(entity);
}
}, [commandManager, prefabService, messageHub, entity, t]);
return (
<div className="prefab-instance-info">
<div className="prefab-instance-header">
<span className="prefab-icon">&#x1F4E6;</span>
<span className="prefab-label">{t('inspector.prefab.source')}:</span>
<span className="prefab-name" title={prefabPath}>{prefabName}</span>
{hasModifications && (
<span className="prefab-modified-badge" title={t('inspector.prefab.modifications', { count: modificationCount })}>
{modificationCount}
</span>
)}
</div>
<div className="prefab-instance-actions">
<button
className="prefab-action-btn"
onClick={handleOpen}
title={t('inspector.prefab.open')}
disabled={isProcessing}
>
{t('inspector.prefab.open')}
</button>
<button
className="prefab-action-btn"
onClick={handleSelect}
title={t('inspector.prefab.select')}
disabled={isProcessing}
>
{t('inspector.prefab.select')}
</button>
<button
className="prefab-action-btn prefab-action-revert"
onClick={handleRevert}
title={t('inspector.prefab.revertAll')}
disabled={isProcessing || !hasModifications}
>
{t('inspector.prefab.revert')}
</button>
<button
className="prefab-action-btn prefab-action-apply"
onClick={handleApply}
title={t('inspector.prefab.applyAll')}
disabled={isProcessing || !hasModifications}
>
{t('inspector.prefab.apply')}
</button>
<button
className="prefab-action-btn prefab-action-unpack"
onClick={handleUnpack}
title={t('inspector.prefab.unpack')}
disabled={isProcessing}
>
&#x26D3;
</button>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.asset-field__label {

View File

@@ -119,18 +119,18 @@ export function AssetField({
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readonly) return;
if (readonly || !assetRegistry) return;
// Try to get GUID from drag data first
const assetGuid = e.dataTransfer.getData('asset-guid');
if (assetGuid && isGUID(assetGuid)) {
// Validate extension if needed
if (fileExtension && assetRegistry) {
if (fileExtension) {
const path = assetRegistry.getPathByGuid(assetGuid);
if (path && !path.endsWith(fileExtension)) {
return; // Extension mismatch
@@ -140,50 +140,63 @@ export function AssetField({
return;
}
// Fallback: handle asset-path and convert to GUID
// Handle asset-path: convert to GUID or register
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
// Try to get GUID from path
if (assetRegistry) {
// Path might be absolute, convert to relative first
let relativePath = assetPath;
if (assetPath.includes(':') || assetPath.startsWith('/')) {
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
}
const guid = assetRegistry.getGuidByPath(relativePath);
// Path might be absolute, convert to relative first
let relativePath = assetPath;
if (assetPath.includes(':') || assetPath.startsWith('/')) {
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
}
// 尝试多种路径格式 | Try multiple path formats
const pathVariants = [relativePath, relativePath.replace(/\\/g, '/')];
for (const variant of pathVariants) {
const guid = assetRegistry.getGuidByPath(variant);
if (guid) {
onChange(guid);
return;
}
}
// Fallback to path if GUID not found (backward compatibility)
onChange(assetPath);
return;
}
// Handle file drops
const files = Array.from(e.dataTransfer.files);
const file = files.find((f) =>
!fileExtension || f.name.endsWith(fileExtension)
);
if (file) {
// For file drops, we still use filename (need to register first)
onChange(file.name);
// GUID 不存在,尝试注册 | GUID not found, try to register
const absolutePath = assetPath.includes(':') ? assetPath : null;
if (absolutePath) {
try {
const newGuid = await assetRegistry.registerAsset(absolutePath);
if (newGuid) {
console.log(`[AssetField] Registered dropped asset with GUID: ${newGuid}`);
onChange(newGuid);
return;
}
} catch (error) {
console.error(`[AssetField] Failed to register dropped asset:`, error);
}
}
console.error(`[AssetField] Cannot use dropped asset without GUID: "${assetPath}"`);
return;
}
// Handle text/plain drops (might be GUID or path)
const text = e.dataTransfer.getData('text/plain');
if (text && (!fileExtension || text.endsWith(fileExtension))) {
// Try to convert to GUID if it's a path
if (assetRegistry && !isGUID(text)) {
const guid = assetRegistry.getGuidByPath(text);
if (isGUID(text)) {
onChange(text);
return;
}
// Try to get GUID from path
const pathVariants = [text, text.replace(/\\/g, '/')];
for (const variant of pathVariants) {
const guid = assetRegistry.getGuidByPath(variant);
if (guid) {
onChange(guid);
return;
}
}
onChange(text);
console.error(`[AssetField] Cannot use dropped text without GUID: "${text}"`);
}
}, [onChange, fileExtension, readonly, assetRegistry]);
@@ -192,23 +205,60 @@ export function AssetField({
setShowPicker(true);
}, [readonly]);
const handlePickerSelect = useCallback((path: string) => {
// Convert path to GUID if possible
if (assetRegistry) {
// Path might be absolute, convert to relative first
let relativePath = path;
if (path.includes(':') || path.startsWith('/')) {
relativePath = assetRegistry.absoluteToRelative(path) || path;
}
const guid = assetRegistry.getGuidByPath(relativePath);
const handlePickerSelect = useCallback(async (path: string) => {
// Convert path to GUID - 必须使用 GUID不能使用路径
// Must use GUID, cannot use path!
if (!assetRegistry) {
console.error(`[AssetField] AssetRegistry not available, cannot select asset`);
setShowPicker(false);
return;
}
// Path might be absolute, convert to relative first
let relativePath = path;
if (path.includes(':') || path.startsWith('/')) {
relativePath = assetRegistry.absoluteToRelative(path) || path;
}
// 尝试多种路径格式 | Try multiple path formats
const pathVariants = [
relativePath,
relativePath.replace(/\\/g, '/'), // 统一为正斜杠
];
for (const variant of pathVariants) {
const guid = assetRegistry.getGuidByPath(variant);
if (guid) {
console.log(`[AssetField] Found GUID for path "${path}": ${guid}`);
onChange(guid);
setShowPicker(false);
return;
}
}
// Fallback to path if GUID not found
onChange(path);
// GUID 不存在,尝试注册资产(创建 .meta 文件)
// GUID not found, try to register asset (create .meta file)
console.warn(`[AssetField] GUID not found for path "${path}", registering asset...`);
try {
// 使用绝对路径注册 | Register using absolute path
const absolutePath = path.includes(':') ? path : null;
if (absolutePath) {
const newGuid = await assetRegistry.registerAsset(absolutePath);
if (newGuid) {
console.log(`[AssetField] Registered new asset with GUID: ${newGuid}`);
onChange(newGuid);
setShowPicker(false);
return;
}
}
} catch (error) {
console.error(`[AssetField] Failed to register asset:`, error);
}
// 注册失败,不能使用路径(会导致打包后找不到)
// Registration failed, cannot use path (will fail after build)
console.error(`[AssetField] Cannot use asset without GUID: "${path}". Please ensure the asset is in a managed directory (assets/, scripts/, scenes/).`);
setShowPicker(false);
}, [onChange, assetRegistry]);

View File

@@ -3,7 +3,7 @@ import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Setting
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { AssetRegistryService } from '@esengine/editor-core';
import { assetManager as globalAssetManager } from '@esengine/asset-system';
import { EngineService } from '../../../services/EngineService';
import { AssetFileInfo } from '../types';
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
import '../../../styles/EntityInspector.css';
@@ -77,7 +77,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
setDetectedType(meta.type);
// Get available loader types from assetManager
const loaderFactory = globalAssetManager.getLoaderFactory();
const assetManager = EngineService.getInstance().getAssetManager();
const loaderFactory = assetManager?.getLoaderFactory();
const registeredTypes = loaderFactory?.getRegisteredTypes() || [];
// Combine built-in types with registered types (deduplicated)

View File

@@ -1,10 +1,11 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector, PrefabInstanceComponent } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry, PrefabService } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
import { PrefabInstanceInfo } from '../common/PrefabInstanceInfo';
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
@@ -35,19 +36,49 @@ interface EntityInspectorProps {
messageHub: MessageHub;
commandManager: CommandManager;
componentVersion: number;
/** 是否锁定检视器 | Whether inspector is locked */
isLocked?: boolean;
/** 锁定状态变化回调 | Lock state change callback */
onLockChange?: (locked: boolean) => void;
}
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
// 默认展开所有组件
return new Set(entity.components.map((_, index) => index));
export function EntityInspector({
entity,
messageHub,
commandManager,
componentVersion,
isLocked = false,
onLockChange
}: EntityInspectorProps) {
// 使用组件类型名追踪折叠状态(持久化到 localStorage
// Use component type names to track collapsed state (persisted to localStorage)
const [collapsedComponentTypes, setCollapsedComponentTypes] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem('inspector-collapsed-components');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 保存折叠状态到 localStorage | Save collapsed state to localStorage
useEffect(() => {
try {
localStorage.setItem(
'inspector-collapsed-components',
JSON.stringify([...collapsedComponentTypes])
);
} catch {
// Ignore localStorage errors
}
}, [collapsedComponentTypes]);
const [showComponentMenu, setShowComponentMenu] = useState(false);
const [localVersion, setLocalVersion] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [isLocked, setIsLocked] = useState(false);
const [selectedComponentIndex, setSelectedComponentIndex] = useState(-1);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [propertySearchQuery, setPropertySearchQuery] = useState('');
const addButtonRef = useRef<HTMLButtonElement>(null);
@@ -56,29 +87,13 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
// 注意:不要依赖 componentVersion否则每次属性变化都会重置展开状态
useEffect(() => {
setExpandedComponents((prev) => {
const newSet = new Set(prev);
// 只添加新增组件的索引(保留已有的展开/收缩状态)
entity.components.forEach((_, index) => {
// 只有当索引不在集合中时才添加(即新组件)
if (!prev.has(index) && index >= prev.size) {
newSet.add(index);
}
});
// 移除不存在的索引(组件被删除的情况)
for (const idx of prev) {
if (idx >= entity.components.length) {
newSet.delete(idx);
}
}
return newSet;
});
}, [entity, entity.components.length]);
// 检查实体是否为预制体实例 | Check if entity is a prefab instance
const isPrefabInstance = useMemo(() => {
return entity.hasComponent(PrefabInstanceComponent);
}, [entity, componentVersion]);
useEffect(() => {
if (showComponentMenu && addButtonRef.current) {
@@ -121,6 +136,46 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
return grouped;
}, [availableComponents, searchQuery]);
// 创建扁平化的可见组件列表(用于键盘导航)
// Create flat list of visible components for keyboard navigation
const flatVisibleComponents = useMemo(() => {
const result: ComponentInfo[] = [];
for (const [category, components] of filteredAndGroupedComponents.entries()) {
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
if (!isCollapsed) {
result.push(...components);
}
}
return result;
}, [filteredAndGroupedComponents, collapsedCategories, searchQuery]);
// 重置选中索引当搜索变化时 | Reset selected index when search changes
useEffect(() => {
setSelectedComponentIndex(searchQuery ? 0 : -1);
}, [searchQuery]);
// 处理组件搜索的键盘导航 | Handle keyboard navigation for component search
const handleComponentSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedComponentIndex(prev =>
prev < flatVisibleComponents.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedComponentIndex(prev => prev > 0 ? prev - 1 : 0);
} else if (e.key === 'Enter' && selectedComponentIndex >= 0) {
e.preventDefault();
const selectedComponent = flatVisibleComponents[selectedComponentIndex];
if (selectedComponent?.type) {
handleAddComponent(selectedComponent.type);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowComponentMenu(false);
}
}, [flatVisibleComponents, selectedComponentIndex]);
const toggleCategory = (category: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev);
@@ -130,13 +185,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
});
};
const toggleComponentExpanded = (index: number) => {
setExpandedComponents((prev) => {
const toggleComponentExpanded = (componentTypeName: string) => {
setCollapsedComponentTypes((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
if (newSet.has(componentTypeName)) {
// 已折叠,展开它 | Was collapsed, expand it
newSet.delete(componentTypeName);
} else {
newSet.add(index);
// 已展开,折叠它 | Was expanded, collapse it
newSet.add(componentTypeName);
}
return newSet;
});
@@ -244,6 +301,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const filteredComponents = useMemo(() => {
return entity.components.filter((component: Component) => {
// 过滤掉标记为隐藏的组件(如 Hierarchy, PrefabInstance
// Filter out components marked as hidden (e.g., Hierarchy, PrefabInstance)
if (isComponentInstanceHiddenInInspector(component)) {
return false;
}
const componentName = getComponentInstanceTypeName(component);
if (categoryFilter !== 'all') {
@@ -271,7 +334,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
<div className="inspector-header-left">
<button
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => setIsLocked(!isLocked)}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? '解锁检视器' : '锁定检视器'}
>
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
@@ -282,6 +345,16 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
<span className="inspector-object-count">1 object</span>
</div>
{/* Prefab Instance Info | 预制体实例信息 */}
{isPrefabInstance && prefabService && (
<PrefabInstanceInfo
entity={entity}
prefabService={prefabService}
messageHub={messageHub}
commandManager={commandManager}
/>
)}
{/* Search Box */}
<div className="inspector-search">
<Search size={14} />
@@ -290,7 +363,27 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
placeholder="Search..."
value={propertySearchQuery}
onChange={(e) => setPropertySearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && propertySearchQuery) {
e.preventDefault();
setPropertySearchQuery('');
}
}}
/>
{propertySearchQuery && (
<button
className="inspector-search-clear"
onClick={() => setPropertySearchQuery('')}
title="Clear"
>
<X size={12} />
</button>
)}
{propertySearchQuery && (
<span className="inspector-search-count">
{filteredComponents.length} / {entity.components.length}
</span>
)}
</div>
{/* Category Tabs */}
@@ -335,6 +428,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
placeholder="搜索组件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleComponentSearchKeyDown}
/>
</div>
{filteredAndGroupedComponents.size === 0 ? (
@@ -343,35 +437,45 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
</div>
) : (
<div className="component-dropdown-list">
{Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
const label = categoryLabels[category] || category;
return (
<div key={category} className="component-category-group">
<button
className="component-category-header"
onClick={() => toggleCategory(category)}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span>{label}</span>
<span className="component-category-count">{components.length}</span>
</button>
{!isCollapsed && components.map((info) => {
const IconComp = info.icon && (LucideIcons as any)[info.icon];
return (
<button
key={info.name}
className="component-dropdown-item"
onClick={() => info.type && handleAddComponent(info.type)}
>
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
<span className="component-dropdown-item-name">{info.name}</span>
</button>
);
})}
</div>
);
})}
{(() => {
let globalIndex = 0;
return Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
const label = categoryLabels[category] || category;
const startIndex = globalIndex;
if (!isCollapsed) {
globalIndex += components.length;
}
return (
<div key={category} className="component-category-group">
<button
className="component-category-header"
onClick={() => toggleCategory(category)}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span>{label}</span>
<span className="component-category-count">{components.length}</span>
</button>
{!isCollapsed && components.map((info, idx) => {
const IconComp = info.icon && (LucideIcons as any)[info.icon];
const itemIndex = startIndex + idx;
const isSelected = itemIndex === selectedComponentIndex;
return (
<button
key={info.name}
className={`component-dropdown-item ${isSelected ? 'selected' : ''}`}
onClick={() => info.type && handleAddComponent(info.type)}
onMouseEnter={() => setSelectedComponentIndex(itemIndex)}
>
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
<span className="component-dropdown-item-name">{info.name}</span>
</button>
);
})}
</div>
);
});
})()}
</div>
)}
</div>
@@ -386,8 +490,9 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
) : (
filteredComponents.map((component: Component) => {
const originalIndex = entity.components.indexOf(component);
const isExpanded = expandedComponents.has(originalIndex);
const componentName = getComponentInstanceTypeName(component);
// 使用组件类型名判断展开状态(未在折叠集合中 = 展开)
const isExpanded = !collapsedComponentTypes.has(componentName);
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
@@ -399,7 +504,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
>
<div
className="component-item-header"
onClick={() => toggleComponentExpanded(originalIndex)}
onClick={() => toggleComponentExpanded(componentName)}
>
<span className="component-expand-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}

View File

@@ -0,0 +1,374 @@
/**
* 预制体检查器
* Prefab Inspector
*
* 显示预制体文件的信息、实体层级预览和实例化功能。
* Displays prefab file information, entity hierarchy preview, and instantiation features.
*/
import { useState, useEffect, useCallback } from 'react';
import {
PackageOpen, Box, Layers, Clock, HardDrive, Tag, Play, ChevronRight, ChevronDown
} from 'lucide-react';
import { Core, PrefabSerializer } from '@esengine/ecs-framework';
import type { PrefabData, SerializedPrefabEntity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, CommandManager } from '@esengine/editor-core';
import { TauriAPI } from '../../../api/tauri';
import { InstantiatePrefabCommand } from '../../../application/commands/prefab/InstantiatePrefabCommand';
import { AssetFileInfo } from '../types';
import '../../../styles/EntityInspector.css';
interface PrefabInspectorProps {
fileInfo: AssetFileInfo;
messageHub?: MessageHub;
commandManager?: CommandManager;
}
function formatFileSize(bytes?: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
function formatDate(timestamp?: number): string {
if (!timestamp) return '未知';
// 如果是毫秒级时间戳,不需要转换 | If millisecond timestamp, no conversion needed
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000;
const date = new Date(ts);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* 实体层级节点组件
* Entity hierarchy node component
*/
function EntityNode({ entity, depth = 0 }: { entity: SerializedPrefabEntity; depth?: number }) {
const [expanded, setExpanded] = useState(depth < 2);
const hasChildren = entity.children && entity.children.length > 0;
const componentCount = entity.components?.length || 0;
return (
<div className="prefab-entity-node">
<div
className="prefab-entity-row"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => hasChildren && setExpanded(!expanded)}
>
<span className="prefab-entity-expand">
{hasChildren ? (
expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
) : (
<span style={{ width: 12 }} />
)}
</span>
<Box size={14} className="prefab-entity-icon" />
<span className="prefab-entity-name">{entity.name}</span>
<span className="prefab-entity-components">
({componentCount} )
</span>
</div>
{expanded && hasChildren && (
<div className="prefab-entity-children">
{entity.children.map((child, index) => (
<EntityNode
key={child.id || index}
entity={child as SerializedPrefabEntity}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
export function PrefabInspector({ fileInfo, messageHub, commandManager }: PrefabInspectorProps) {
const [prefabData, setPrefabData] = useState<PrefabData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [instantiating, setInstantiating] = useState(false);
// 加载预制体数据 | Load prefab data
useEffect(() => {
let cancelled = false;
async function loadPrefab() {
setLoading(true);
setError(null);
try {
const content = await TauriAPI.readFileContent(fileInfo.path);
const data = PrefabSerializer.deserialize(content);
// 验证预制体数据 | Validate prefab data
const validation = PrefabSerializer.validate(data);
if (!validation.valid) {
throw new Error(`无效的预制体: ${validation.errors?.join(', ')}`);
}
if (!cancelled) {
setPrefabData(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '加载预制体失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadPrefab();
return () => {
cancelled = true;
};
}, [fileInfo.path]);
// 实例化预制体 | Instantiate prefab
const handleInstantiate = useCallback(async () => {
if (!prefabData || instantiating) return;
setInstantiating(true);
try {
// 从 Core.services 获取服务,使用 tryResolve 避免类型问题
// Get services from Core.services, use tryResolve to avoid type issues
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
const hub = messageHub || Core.services.tryResolve(MessageHub) as MessageHub | null;
const cmdManager = commandManager;
if (!entityStore || !hub || !cmdManager) {
throw new Error('必要的服务未初始化 | Required services not initialized');
}
const command = new InstantiatePrefabCommand(
entityStore,
hub,
prefabData,
{ trackInstance: true }
);
cmdManager.execute(command);
console.log(`[PrefabInspector] Prefab instantiated: ${prefabData.metadata.name}`);
} catch (err) {
console.error('[PrefabInspector] Failed to instantiate prefab:', err);
} finally {
setInstantiating(false);
}
}, [prefabData, instantiating, messageHub, commandManager]);
// 统计实体和组件数量 | Count entities and components
const countEntities = useCallback((entity: SerializedPrefabEntity): { entities: number; components: number } => {
let entities = 1;
let components = entity.components?.length || 0;
if (entity.children) {
for (const child of entity.children) {
const childCounts = countEntities(child as SerializedPrefabEntity);
entities += childCounts.entities;
components += childCounts.components;
}
}
return { entities, components };
}, []);
const counts = prefabData ? countEntities(prefabData.root) : { entities: 0, components: 0 };
if (loading) {
return (
<div className="entity-inspector">
<div className="inspector-header">
<PackageOpen size={16} style={{ color: '#4ade80' }} />
<span className="entity-name">{fileInfo.name}</span>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
...
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="entity-inspector">
<div className="inspector-header">
<PackageOpen size={16} style={{ color: '#f87171' }} />
<span className="entity-name">{fileInfo.name}</span>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div style={{ padding: '20px', textAlign: 'center', color: '#f87171' }}>
{error}
</div>
</div>
</div>
</div>
);
}
return (
<div className="entity-inspector prefab-inspector">
<div className="inspector-header">
<PackageOpen size={16} style={{ color: '#4ade80' }} />
<span className="entity-name">{prefabData?.metadata.name || fileInfo.name}</span>
</div>
<div className="inspector-content">
{/* 预制体信息 | Prefab Information */}
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text">v{prefabData?.version}</span>
</div>
<div className="property-field">
<label className="property-label">
<Layers size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">{counts.entities}</span>
</div>
<div className="property-field">
<label className="property-label">
<Box size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">{counts.components}</span>
</div>
{prefabData?.metadata.description && (
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text">{prefabData.metadata.description}</span>
</div>
)}
{prefabData?.metadata.tags && prefabData.metadata.tags.length > 0 && (
<div className="property-field">
<label className="property-label">
<Tag size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">
{prefabData.metadata.tags.join(', ')}
</span>
</div>
)}
</div>
{/* 文件信息 | File Information */}
<div className="inspector-section">
<div className="section-title"></div>
{fileInfo.size !== undefined && (
<div className="property-field">
<label className="property-label">
<HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
</div>
)}
{prefabData?.metadata.createdAt && (
<div className="property-field">
<label className="property-label">
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">
{formatDate(prefabData.metadata.createdAt)}
</span>
</div>
)}
{prefabData?.metadata.modifiedAt && (
<div className="property-field">
<label className="property-label">
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
</label>
<span className="property-value-text">
{formatDate(prefabData.metadata.modifiedAt)}
</span>
</div>
)}
</div>
{/* 组件类型 | Component Types */}
{prefabData?.metadata.componentTypes && prefabData.metadata.componentTypes.length > 0 && (
<div className="inspector-section">
<div className="section-title"></div>
<div className="prefab-component-types">
{prefabData.metadata.componentTypes.map((type) => (
<span key={type} className="prefab-component-type-tag">
{type}
</span>
))}
</div>
</div>
)}
{/* 实体层级 | Entity Hierarchy */}
{prefabData?.root && (
<div className="inspector-section">
<div className="section-title"></div>
<div className="prefab-hierarchy">
<EntityNode entity={prefabData.root} />
</div>
</div>
)}
{/* 操作按钮 | Action Buttons */}
<div className="inspector-section">
<button
className="prefab-instantiate-btn"
onClick={handleInstantiate}
disabled={instantiating}
style={{
width: '100%',
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
backgroundColor: '#4ade80',
color: '#1a1a1a',
border: 'none',
borderRadius: '4px',
fontSize: '13px',
fontWeight: 500,
cursor: instantiating ? 'wait' : 'pointer',
opacity: instantiating ? 0.7 : 1
}}
>
<Play size={14} />
{instantiating ? '实例化中...' : '实例化到场景'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,3 +3,4 @@ export { ExtensionInspector } from './ExtensionInspector';
export { AssetFileInspector } from './AssetFileInspector';
export { RemoteEntityInspector } from './RemoteEntityInspector';
export { EntityInspector } from './EntityInspector';
export { PrefabInspector } from './PrefabInspector';