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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">📦</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}
|
||||
>
|
||||
⛓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { ExtensionInspector } from './ExtensionInspector';
|
||||
export { AssetFileInspector } from './AssetFileInspector';
|
||||
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||
export { EntityInspector } from './EntityInspector';
|
||||
export { PrefabInspector } from './PrefabInspector';
|
||||
|
||||
Reference in New Issue
Block a user