Feature/physics and tilemap enhancement (#247)
* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统 * feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统 * feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能 * fix: 修复CodeQL安全警告和CI类型检查错误 * fix: 修复CodeQL安全警告和CI类型检查错误 * fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
@@ -1,968 +1,22 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw, Plus } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { FileTree, FileTreeHandle } from './FileTree';
|
||||
import { ResizablePanel } from './ResizablePanel';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
import '../styles/AssetBrowser.css';
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
* Asset Browser - 资产浏览器
|
||||
* 包装 ContentBrowser 组件,保持向后兼容
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <Plus size={size} />;
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
return <Plus size={size} />;
|
||||
}
|
||||
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
import { ContentBrowser } from './ContentBrowser';
|
||||
|
||||
interface AssetBrowserProps {
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
}
|
||||
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDetailView, setShowDetailView] = useState(() => {
|
||||
const saved = localStorage.getItem('asset-browser-detail-view');
|
||||
return saved !== null ? saved === 'true' : false;
|
||||
});
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
asset: AssetItem;
|
||||
} | null>(null);
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
asset: AssetItem;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'Content Browser',
|
||||
noProject: 'No project loaded',
|
||||
loading: 'Loading...',
|
||||
empty: 'No assets found',
|
||||
search: 'Search...',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
file: 'File',
|
||||
folder: 'Folder'
|
||||
},
|
||||
zh: {
|
||||
title: '内容浏览器',
|
||||
noProject: '没有加载项目',
|
||||
loading: '加载中...',
|
||||
empty: '没有找到资产',
|
||||
search: '搜索...',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
file: '文件',
|
||||
folder: '文件夹'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
setCurrentPath(projectPath);
|
||||
loadAssets(projectPath);
|
||||
} else {
|
||||
setAssets([]);
|
||||
setCurrentPath(null);
|
||||
setSelectedPaths(new Set());
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
// Listen for asset reveal requests
|
||||
useEffect(() => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (!filePath || !projectPath) return;
|
||||
|
||||
// Convert relative path to absolute path if needed
|
||||
let absoluteFilePath = filePath;
|
||||
if (!filePath.includes(':') && !filePath.startsWith('/')) {
|
||||
absoluteFilePath = `${projectPath}/${filePath}`.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
const lastSlashIndex = Math.max(absoluteFilePath.lastIndexOf('/'), absoluteFilePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? absoluteFilePath.substring(0, lastSlashIndex) : null;
|
||||
|
||||
if (dirPath) {
|
||||
try {
|
||||
const dirExists = await TauriAPI.pathExists(dirPath);
|
||||
if (!dirExists) return;
|
||||
|
||||
setCurrentPath(dirPath);
|
||||
await loadAssets(dirPath);
|
||||
setSelectedPaths(new Set([absoluteFilePath]));
|
||||
|
||||
// Expand tree to reveal the file
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.revealPath(absoluteFilePath);
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.revealPath(absoluteFilePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetBrowser] Failed to reveal asset: ${absoluteFilePath}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [showDetailView, projectPath]);
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
|
||||
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => {
|
||||
const extension = entry.is_dir ? undefined :
|
||||
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension,
|
||||
size: entry.size,
|
||||
modified: entry.modified
|
||||
};
|
||||
});
|
||||
|
||||
setAssets(assetItems.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const searchProjectRecursively = async (rootPath: string, query: string): Promise<AssetItem[]> => {
|
||||
const results: AssetItem[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const searchDirectory = async (dirPath: string) => {
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(dirPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
if (entry.name.toLowerCase().includes(lowerQuery)) {
|
||||
const extension = entry.is_dir ? undefined :
|
||||
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension,
|
||||
size: entry.size,
|
||||
modified: entry.modified
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.is_dir) {
|
||||
await searchDirectory(entry.path);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to search directory ${dirPath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(rootPath);
|
||||
return results.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectPath) return;
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await searchProjectRecursively(projectPath, searchQuery);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(performSearch, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery, projectPath]);
|
||||
|
||||
const handleFolderSelect = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => {
|
||||
if (paths.length === 0) return;
|
||||
const path = paths[0];
|
||||
if (!path) return;
|
||||
|
||||
if (modifiers.shiftKey && paths.length > 1) {
|
||||
// Range select - paths already contains the range from FileTree
|
||||
setSelectedPaths(new Set(paths));
|
||||
} else if (modifiers.ctrlKey) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(path)) {
|
||||
newSelected.delete(path);
|
||||
} else {
|
||||
newSelected.add(path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(path);
|
||||
} else {
|
||||
setSelectedPaths(new Set([path]));
|
||||
setLastSelectedPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => {
|
||||
const filteredAssets = searchQuery.trim() ? searchResults : assets;
|
||||
|
||||
if (e.shiftKey && lastSelectedPath) {
|
||||
// Range select with Shift
|
||||
const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath);
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path);
|
||||
setSelectedPaths(new Set(rangePaths));
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Multi-select with Ctrl/Cmd
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(asset.path)) {
|
||||
newSelected.delete(asset.path);
|
||||
} else {
|
||||
newSelected.add(asset.path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(asset.path);
|
||||
} else {
|
||||
// Single select
|
||||
setSelectedPaths(new Set([asset.path]));
|
||||
setLastSelectedPath(asset.path);
|
||||
}
|
||||
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
name: asset.name,
|
||||
path: asset.path,
|
||||
extension: asset.extension,
|
||||
size: asset.size,
|
||||
modified: asset.modified,
|
||||
isDirectory: asset.type === 'folder'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
} else if (asset.type === 'file') {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
onOpenScene(asset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileActionRegistry) {
|
||||
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await TauriAPI.openFileWithSystemApp(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (asset: AssetItem, newName: string) => {
|
||||
if (!newName.trim() || newName === asset.name) {
|
||||
setRenameDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
|
||||
const parentPath = asset.path.substring(0, lastSlash);
|
||||
const newPath = `${parentPath}/${newName}`;
|
||||
|
||||
await TauriAPI.renameFileOrFolder(asset.path, newPath);
|
||||
|
||||
// 刷新当前目录
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// 更新选中路径
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
newSelected.add(newPath);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setRenameDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error);
|
||||
alert(`重命名失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (asset: AssetItem) => {
|
||||
try {
|
||||
if (asset.type === 'folder') {
|
||||
await TauriAPI.deleteFolder(asset.path);
|
||||
} else {
|
||||
await TauriAPI.deleteFile(asset.path);
|
||||
}
|
||||
|
||||
// 刷新当前目录
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
// 清除选中状态
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
alert(`删除失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
asset
|
||||
});
|
||||
};
|
||||
|
||||
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
|
||||
if (fileActionRegistry) {
|
||||
const handlers = fileActionRegistry.getHandlersForFile(asset.path);
|
||||
for (const handler of handlers) {
|
||||
if (handler.getContextMenuItems) {
|
||||
const parentPath = asset.path.substring(0, asset.path.lastIndexOf('/'));
|
||||
const pluginItems = handler.getContextMenuItems(asset.path, parentPath);
|
||||
for (const pluginItem of pluginItems) {
|
||||
items.push({
|
||||
label: pluginItem.label,
|
||||
icon: pluginItem.icon,
|
||||
onClick: () => pluginItem.onClick(asset.path, parentPath),
|
||||
disabled: pluginItem.disabled,
|
||||
separator: pluginItem.separator
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
}
|
||||
|
||||
if (asset.type === 'folder' && fileActionRegistry) {
|
||||
const templates = fileActionRegistry.getCreationTemplates();
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
items.push({
|
||||
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: async () => {
|
||||
const fileName = `new_${template.id}.${template.extension}`;
|
||||
const filePath = `${asset.path}/${fileName}`;
|
||||
await template.create(filePath);
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在文件管理器中显示
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
icon: <FolderOpen size={16} />,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await TauriAPI.showInFolder(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in folder:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 复制路径
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
setRenameDialog({
|
||||
asset,
|
||||
newName: asset.name
|
||||
});
|
||||
setContextMenu(null);
|
||||
},
|
||||
disabled: false
|
||||
});
|
||||
|
||||
// 删除
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
},
|
||||
disabled: false
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
if (!currentPath || !projectPath) return [];
|
||||
|
||||
const relative = currentPath.replace(projectPath, '');
|
||||
const parts = relative.split(/[/\\]/).filter((p) => p);
|
||||
|
||||
const crumbs = [{ name: 'Content', path: projectPath }];
|
||||
let accPath = projectPath;
|
||||
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const filteredAssets = searchQuery.trim() ? searchResults : assets;
|
||||
|
||||
const getRelativePath = (fullPath: string): string => {
|
||||
if (!projectPath) return fullPath;
|
||||
const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, '');
|
||||
const parts = relativePath.split(/[/\\]/);
|
||||
return parts.slice(0, -1).join('/');
|
||||
};
|
||||
|
||||
const getFileIcon = (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
// 检查是否为框架专用文件夹
|
||||
const folderName = asset.name.toLowerCase();
|
||||
if (folderName === 'plugins' || folderName === '.ecs') {
|
||||
return <Folder className="asset-icon system-folder" style={{ color: '#42a5f5' }} size={20} />;
|
||||
}
|
||||
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
}
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs':
|
||||
return <File className="asset-icon" style={{ color: '#66bb6a' }} size={20} />;
|
||||
case 'btree':
|
||||
return <FileText className="asset-icon" style={{ color: '#ab47bc' }} size={20} />;
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return <FileCode className="asset-icon" style={{ color: '#42a5f5' }} size={20} />;
|
||||
case 'json':
|
||||
return <FileJson className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
return <FileImage className="asset-icon" style={{ color: '#ec407a' }} size={20} />;
|
||||
default:
|
||||
return <File className="asset-icon" size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<h3>{t.title}</h3>
|
||||
</div>
|
||||
<div className="asset-browser-empty">
|
||||
<p>{t.noProject}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-content">
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
borderBottom: '1px solid #3e3e3e',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
background: '#252526',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div className="view-mode-buttons">
|
||||
<button
|
||||
className={`view-mode-btn ${showDetailView ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setShowDetailView(true);
|
||||
localStorage.setItem('asset-browser-detail-view', 'true');
|
||||
}}
|
||||
title="显示详细视图(树形图 + 资产列表)"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
<span className="view-mode-text">详细视图</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${!showDetailView ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setShowDetailView(false);
|
||||
localStorage.setItem('asset-browser-detail-view', 'false');
|
||||
}}
|
||||
title="仅显示树形图(查看完整路径)"
|
||||
>
|
||||
<List size={14} />
|
||||
<span className="view-mode-text">树形图</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.collapseAll();
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.collapseAll();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #3e3e3e',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '3px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2d2e';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
title="收起所有文件夹"
|
||||
>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPath) {
|
||||
loadAssets(currentPath);
|
||||
}
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.refresh();
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.refresh();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #3e3e3e',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '3px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2d2e';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
title="刷新资产列表"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
background: '#3c3c3c',
|
||||
border: '1px solid #3e3e3e',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showDetailView ? (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
ref={detailViewFileTreeRef}
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
messageHub={messageHub}
|
||||
searchQuery={searchQuery}
|
||||
showFiles={false}
|
||||
onOpenScene={onOpenScene}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(loading || isSearching) ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => {
|
||||
const relativePath = getRelativePath(asset.path);
|
||||
const showPath = searchQuery.trim() && relativePath;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Get all selected file assets
|
||||
const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1
|
||||
? Array.from(selectedPaths)
|
||||
.filter((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return a && a.type === 'file';
|
||||
})
|
||||
.map((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return { type: 'file', path: p, name: a?.name, extension: a?.extension };
|
||||
})
|
||||
: [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }];
|
||||
|
||||
// Set drag data as JSON array for multi-file support
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('asset-name', asset.name);
|
||||
e.dataTransfer.setData('asset-extension', asset.extension || '');
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
|
||||
// 设置拖拽时的视觉效果
|
||||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-9999px';
|
||||
dragImage.style.opacity = '0.8';
|
||||
if (selectedFiles.length > 1) {
|
||||
dragImage.textContent = `${selectedFiles.length} files`;
|
||||
}
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: asset.type === 'file' ? 'grab' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.path}>
|
||||
{asset.name}
|
||||
</div>
|
||||
{showPath && (
|
||||
<div className="asset-path" style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{relativePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="asset-browser-tree-only">
|
||||
<FileTree
|
||||
ref={treeOnlyViewFileTreeRef}
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
onSelectFiles={handleTreeMultiSelect}
|
||||
selectedPath={Array.from(selectedPaths)[0] || currentPath}
|
||||
selectedPaths={selectedPaths}
|
||||
messageHub={messageHub}
|
||||
searchQuery={searchQuery}
|
||||
showFiles={true}
|
||||
onOpenScene={onOpenScene}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
items={getContextMenuItems(contextMenu.asset)}
|
||||
position={contextMenu.position}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
{renameDialog && (
|
||||
<div className="dialog-overlay" onClick={() => setRenameDialog(null)}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<input
|
||||
type="text"
|
||||
value={renameDialog.newName}
|
||||
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(renameDialog.asset, renameDialog.newName);
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenameDialog(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3e3e3e',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
onClick={() => setRenameDialog(null)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#3e3e3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '确定' : 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{deleteConfirmDialog && (
|
||||
<div className="dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<p style={{ margin: 0, color: '#cccccc' }}>
|
||||
{locale === 'zh'
|
||||
? `确定要删除 "${deleteConfirmDialog.name}" 吗?此操作不可撤销。`
|
||||
: `Are you sure you want to delete "${deleteConfirmDialog.name}"? This action cannot be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmDialog(null)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#3e3e3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmDialog)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: '#c53030',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{locale === 'zh' ? '删除' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContentBrowser
|
||||
projectPath={projectPath}
|
||||
locale={locale}
|
||||
onOpenScene={onOpenScene}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
957
packages/editor-app/src/components/ContentBrowser.tsx
Normal file
957
packages/editor-app/src/components/ContentBrowser.tsx
Normal file
@@ -0,0 +1,957 @@
|
||||
/**
|
||||
* Content Browser - 内容浏览器
|
||||
* 用于浏览和管理项目资产
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Download,
|
||||
Save,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
List,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
Folder,
|
||||
File,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileImage,
|
||||
FileText,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
PanelRightClose
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
import { PromptDialog } from './PromptDialog';
|
||||
import '../styles/ContentBrowser.css';
|
||||
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
interface ContentBrowserProps {
|
||||
projectPath: string | null;
|
||||
locale?: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
isDrawer?: boolean;
|
||||
onDockInLayout?: () => void;
|
||||
revealPath?: string | null;
|
||||
}
|
||||
|
||||
// 获取资产类型显示名称
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
|
||||
// Check for compound extensions first
|
||||
const name = asset.name.toLowerCase();
|
||||
if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap';
|
||||
if (name.endsWith('.tileset.json') || name.endsWith('.tileset')) return 'Tileset';
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs': return 'Scene';
|
||||
case 'btree': return 'Behavior Tree';
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'webp': return 'Texture';
|
||||
case 'ts':
|
||||
case 'tsx': return 'TypeScript';
|
||||
case 'js':
|
||||
case 'jsx': return 'JavaScript';
|
||||
case 'json': return 'JSON';
|
||||
case 'prefab': return 'Prefab';
|
||||
case 'mat': return 'Material';
|
||||
case 'anim': return 'Animation';
|
||||
default: return ext?.toUpperCase() || 'File';
|
||||
}
|
||||
}
|
||||
|
||||
export function ContentBrowser({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
onOpenScene,
|
||||
isDrawer = false,
|
||||
onDockInLayout,
|
||||
revealPath
|
||||
}: ContentBrowserProps) {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
|
||||
// State
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
// Folder tree state
|
||||
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Sections collapse state
|
||||
const [favoritesExpanded, setFavoritesExpanded] = useState(true);
|
||||
const [collectionsExpanded, setCollectionsExpanded] = useState(true);
|
||||
|
||||
// Favorites (stored paths)
|
||||
const [favorites] = useState<string[]>([]);
|
||||
|
||||
// Dialog states
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
asset: AssetItem | null;
|
||||
isBackground?: boolean;
|
||||
} | null>(null);
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
asset: AssetItem;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
|
||||
const [createFileDialog, setCreateFileDialog] = useState<{
|
||||
parentPath: string;
|
||||
template: FileCreationTemplate;
|
||||
} | null>(null);
|
||||
|
||||
const t = {
|
||||
en: {
|
||||
favorites: 'Favorites',
|
||||
collections: 'Collections',
|
||||
add: 'Add',
|
||||
import: 'Import',
|
||||
saveAll: 'Save All',
|
||||
search: 'Search',
|
||||
items: 'items',
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
},
|
||||
zh: {
|
||||
favorites: '收藏夹',
|
||||
collections: '收藏集',
|
||||
add: '添加',
|
||||
import: '导入',
|
||||
saveAll: '全部保存',
|
||||
search: '搜索',
|
||||
items: '项',
|
||||
dockInLayout: '停靠到布局',
|
||||
noProject: '未加载项目',
|
||||
empty: '文件夹为空',
|
||||
newFolder: '新建文件夹'
|
||||
}
|
||||
}[locale] || {
|
||||
favorites: 'Favorites',
|
||||
collections: 'Collections',
|
||||
add: 'Add',
|
||||
import: 'Import',
|
||||
saveAll: 'Save All',
|
||||
search: 'Search',
|
||||
items: 'items',
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
};
|
||||
|
||||
// Build folder tree - use ref to avoid dependency cycle
|
||||
const expandedFoldersRef = useRef(expandedFolders);
|
||||
expandedFoldersRef.current = expandedFolders;
|
||||
|
||||
const buildFolderTree = useCallback(async (rootPath: string): Promise<FolderNode> => {
|
||||
const currentExpanded = expandedFoldersRef.current;
|
||||
|
||||
const buildNode = async (path: string, name: string): Promise<FolderNode> => {
|
||||
const node: FolderNode = {
|
||||
name,
|
||||
path,
|
||||
children: [],
|
||||
isExpanded: currentExpanded.has(path)
|
||||
};
|
||||
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const folders = entries
|
||||
.filter((e: DirectoryEntry) => e.is_dir && !e.name.startsWith('.'))
|
||||
.sort((a: DirectoryEntry, b: DirectoryEntry) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const folder of folders) {
|
||||
if (currentExpanded.has(path)) {
|
||||
node.children.push(await buildNode(folder.path, folder.name));
|
||||
} else {
|
||||
node.children.push({
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
children: [],
|
||||
isExpanded: false
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build folder tree:', error);
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return buildNode(rootPath, 'All');
|
||||
}, []);
|
||||
|
||||
// Load assets
|
||||
const loadAssets = useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension: entry.is_dir ? undefined : entry.name.split('.').pop(),
|
||||
size: entry.size,
|
||||
modified: entry.modified
|
||||
}));
|
||||
|
||||
setAssets(assetItems.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
setCurrentPath(projectPath);
|
||||
setExpandedFolders(new Set([projectPath]));
|
||||
loadAssets(projectPath);
|
||||
buildFolderTree(projectPath).then(setFolderTree);
|
||||
}
|
||||
// Only run on mount, not on every projectPath change
|
||||
}, []);
|
||||
|
||||
// Handle projectPath change after initial mount
|
||||
const prevProjectPath = useRef(projectPath);
|
||||
useEffect(() => {
|
||||
if (projectPath && projectPath !== prevProjectPath.current) {
|
||||
prevProjectPath.current = projectPath;
|
||||
setCurrentPath(projectPath);
|
||||
setExpandedFolders(new Set([projectPath]));
|
||||
loadAssets(projectPath);
|
||||
buildFolderTree(projectPath).then(setFolderTree);
|
||||
}
|
||||
}, [projectPath, loadAssets, buildFolderTree]);
|
||||
|
||||
// Rebuild tree when expanded folders change
|
||||
const expandedFoldersVersion = useRef(0);
|
||||
useEffect(() => {
|
||||
// Skip first render (handled by initialization)
|
||||
if (expandedFoldersVersion.current === 0) {
|
||||
expandedFoldersVersion.current = 1;
|
||||
return;
|
||||
}
|
||||
if (projectPath) {
|
||||
buildFolderTree(projectPath).then(setFolderTree);
|
||||
}
|
||||
}, [expandedFolders, projectPath, buildFolderTree]);
|
||||
|
||||
// Handle reveal path - navigate to folder and select file
|
||||
const prevRevealPath = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (revealPath && revealPath !== prevRevealPath.current && projectPath) {
|
||||
prevRevealPath.current = revealPath;
|
||||
|
||||
// Remove timestamp query if present
|
||||
const cleanPath = revealPath.split('?')[0] || revealPath;
|
||||
|
||||
// Get full path
|
||||
const fullPath = cleanPath.startsWith('/') || cleanPath.includes(':')
|
||||
? cleanPath
|
||||
: `${projectPath}/${cleanPath}`;
|
||||
|
||||
// Get parent directory
|
||||
const pathParts = fullPath.replace(/\\/g, '/').split('/');
|
||||
pathParts.pop(); // Remove filename
|
||||
const parentDir = pathParts.join('/');
|
||||
|
||||
// Expand all parent folders
|
||||
const foldersToExpand = new Set<string>();
|
||||
let currentFolder = parentDir;
|
||||
while (currentFolder && currentFolder.length >= (projectPath?.length || 0)) {
|
||||
foldersToExpand.add(currentFolder);
|
||||
const parts = currentFolder.split('/');
|
||||
parts.pop();
|
||||
currentFolder = parts.join('/');
|
||||
}
|
||||
|
||||
// Update expanded folders and navigate
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
foldersToExpand.forEach((f) => next.add(f));
|
||||
return next;
|
||||
});
|
||||
|
||||
// Navigate to parent folder and select the file
|
||||
setCurrentPath(parentDir);
|
||||
loadAssets(parentDir).then(() => {
|
||||
// Select the file after assets are loaded
|
||||
setSelectedPaths(new Set([fullPath]));
|
||||
setLastSelectedPath(fullPath);
|
||||
});
|
||||
}
|
||||
}, [revealPath, projectPath, loadAssets]);
|
||||
|
||||
// Handle folder selection in tree
|
||||
const handleFolderSelect = useCallback((path: string) => {
|
||||
setCurrentPath(path);
|
||||
loadAssets(path);
|
||||
}, [loadAssets]);
|
||||
|
||||
// Toggle folder expansion
|
||||
const toggleFolderExpand = useCallback((path: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle asset click
|
||||
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
|
||||
if (e.shiftKey && lastSelectedPath) {
|
||||
const lastIndex = assets.findIndex(a => a.path === lastSelectedPath);
|
||||
const currentIndex = assets.findIndex(a => a.path === asset.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = assets.slice(start, end + 1).map(a => a.path);
|
||||
setSelectedPaths(new Set(rangePaths));
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(asset.path)) {
|
||||
newSelected.delete(asset.path);
|
||||
} else {
|
||||
newSelected.add(asset.path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(asset.path);
|
||||
} else {
|
||||
setSelectedPaths(new Set([asset.path]));
|
||||
setLastSelectedPath(asset.path);
|
||||
}
|
||||
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
name: asset.name,
|
||||
path: asset.path,
|
||||
extension: asset.extension,
|
||||
isDirectory: asset.type === 'folder'
|
||||
}
|
||||
});
|
||||
}, [assets, lastSelectedPath, selectedPaths, messageHub]);
|
||||
|
||||
// Handle asset double click
|
||||
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
||||
} else {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
onOpenScene(asset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileActionRegistry) {
|
||||
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await TauriAPI.openFileWithSystemApp(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error);
|
||||
}
|
||||
}
|
||||
}, [loadAssets, onOpenScene, fileActionRegistry]);
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
asset: asset || null,
|
||||
isBackground: !asset
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle rename
|
||||
const handleRename = useCallback(async (asset: AssetItem, newName: string) => {
|
||||
if (!newName.trim() || newName === asset.name) {
|
||||
setRenameDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
|
||||
const parentPath = asset.path.substring(0, lastSlash);
|
||||
const newPath = `${parentPath}/${newName}`;
|
||||
|
||||
await TauriAPI.renameFileOrFolder(asset.path, newPath);
|
||||
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
setRenameDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error);
|
||||
}
|
||||
}, [currentPath, loadAssets]);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(async (asset: AssetItem) => {
|
||||
try {
|
||||
if (asset.type === 'folder') {
|
||||
await TauriAPI.deleteFolder(asset.path);
|
||||
} else {
|
||||
await TauriAPI.deleteFile(asset.path);
|
||||
}
|
||||
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
}
|
||||
}, [currentPath, loadAssets]);
|
||||
|
||||
// Get breadcrumbs
|
||||
const getBreadcrumbs = useCallback(() => {
|
||||
if (!currentPath || !projectPath) return [];
|
||||
|
||||
const relative = currentPath.replace(projectPath, '');
|
||||
const parts = relative.split(/[/\\]/).filter(p => p);
|
||||
|
||||
const crumbs = [{ name: 'All', path: projectPath }];
|
||||
crumbs.push({ name: 'Content', path: projectPath });
|
||||
|
||||
let accPath = projectPath;
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}/${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}, [currentPath, projectPath]);
|
||||
|
||||
// Get file icon
|
||||
const getFileIcon = useCallback((asset: AssetItem, size: number = 48) => {
|
||||
if (asset.type === 'folder') {
|
||||
return <Folder size={size} className="asset-thumbnail-icon folder" />;
|
||||
}
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs':
|
||||
return <File size={size} className="asset-thumbnail-icon scene" />;
|
||||
case 'btree':
|
||||
return <FileText size={size} className="asset-thumbnail-icon btree" />;
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return <FileCode size={size} className="asset-thumbnail-icon code" />;
|
||||
case 'json':
|
||||
return <FileJson size={size} className="asset-thumbnail-icon json" />;
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return <FileImage size={size} className="asset-thumbnail-icon image" />;
|
||||
default:
|
||||
return <File size={size} className="asset-thumbnail-icon" />;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get context menu items
|
||||
const getContextMenuItems = useCallback((asset: AssetItem | null): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
if (!asset) {
|
||||
// Background context menu
|
||||
items.push({
|
||||
label: t.newFolder,
|
||||
icon: <FolderClosed size={16} />,
|
||||
onClick: async () => {
|
||||
if (!currentPath) return;
|
||||
const folderName = `New Folder`;
|
||||
const folderPath = `${currentPath}/${folderName}`;
|
||||
try {
|
||||
await TauriAPI.createDirectory(folderPath);
|
||||
await loadAssets(currentPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to create folder:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (fileActionRegistry) {
|
||||
const templates = fileActionRegistry.getCreationTemplates();
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
items.push({
|
||||
label: `New ${template.label}`,
|
||||
onClick: () => {
|
||||
setContextMenu(null);
|
||||
if (currentPath) {
|
||||
setCreateFileDialog({
|
||||
parentPath: currentPath,
|
||||
template
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Asset context menu
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
icon: <ExternalLink size={16} />,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await TauriAPI.showInFolder(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in folder:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => navigator.clipboard.writeText(asset.path)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]);
|
||||
|
||||
// Render folder tree node
|
||||
const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => {
|
||||
const isSelected = currentPath === node.path;
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`folder-tree-item ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => handleFolderSelect(node.path)}
|
||||
>
|
||||
<span
|
||||
className="folder-tree-expand"
|
||||
onClick={(e) => toggleFolderExpand(node.path, e)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
) : (
|
||||
<span style={{ width: 14 }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="folder-tree-icon">
|
||||
{isExpanded ? <FolderOpen size={14} /> : <FolderClosed size={14} />}
|
||||
</span>
|
||||
<span className="folder-tree-name">{node.name}</span>
|
||||
</div>
|
||||
{isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}, [currentPath, expandedFolders, handleFolderSelect, toggleFolderExpand]);
|
||||
|
||||
// Filter assets by search
|
||||
const filteredAssets = searchQuery.trim()
|
||||
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: assets;
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="content-browser">
|
||||
<div className="content-browser-empty">
|
||||
<p>{t.noProject}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}>
|
||||
{/* Left Panel - Folder Tree */}
|
||||
<div className="content-browser-left">
|
||||
{/* Favorites Section */}
|
||||
<div className="cb-section">
|
||||
<div
|
||||
className="cb-section-header"
|
||||
onClick={() => setFavoritesExpanded(!favoritesExpanded)}
|
||||
>
|
||||
{favoritesExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{t.favorites}</span>
|
||||
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
|
||||
<Search size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{favoritesExpanded && (
|
||||
<div className="cb-section-content">
|
||||
{favorites.length === 0 ? (
|
||||
<div className="cb-section-empty">
|
||||
{/* Empty favorites */}
|
||||
</div>
|
||||
) : (
|
||||
favorites.map(fav => (
|
||||
<div key={fav} className="folder-tree-item">
|
||||
<FolderClosed size={14} />
|
||||
<span>{fav.split('/').pop()}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder Tree */}
|
||||
<div className="cb-folder-tree">
|
||||
{folderTree && renderFolderNode(folderTree)}
|
||||
</div>
|
||||
|
||||
{/* Collections Section */}
|
||||
<div className="cb-section">
|
||||
<div
|
||||
className="cb-section-header"
|
||||
onClick={() => setCollectionsExpanded(!collectionsExpanded)}
|
||||
>
|
||||
{collectionsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{t.collections}</span>
|
||||
<div className="cb-section-actions">
|
||||
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
|
||||
<Search size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{collectionsExpanded && (
|
||||
<div className="cb-section-content">
|
||||
{/* Collections list */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Content Area */}
|
||||
<div className="content-browser-right">
|
||||
{/* Top Toolbar */}
|
||||
<div className="cb-toolbar">
|
||||
<div className="cb-toolbar-left">
|
||||
<button className="cb-toolbar-btn primary">
|
||||
<Plus size={14} />
|
||||
<span>{t.add}</span>
|
||||
</button>
|
||||
<button className="cb-toolbar-btn">
|
||||
<Download size={14} />
|
||||
<span>{t.import}</span>
|
||||
</button>
|
||||
<button className="cb-toolbar-btn">
|
||||
<Save size={14} />
|
||||
<span>{t.saveAll}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="cb-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className="cb-breadcrumb-item">
|
||||
{index > 0 && <ChevronRight size={12} className="cb-breadcrumb-sep" />}
|
||||
<span
|
||||
className="cb-breadcrumb-link"
|
||||
onClick={() => handleFolderSelect(crumb.path)}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cb-toolbar-right">
|
||||
{isDrawer && onDockInLayout && (
|
||||
<button
|
||||
className="cb-toolbar-btn dock-btn"
|
||||
onClick={onDockInLayout}
|
||||
title={t.dockInLayout}
|
||||
>
|
||||
<PanelRightClose size={14} />
|
||||
<span>{t.dockInLayout}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="cb-search-bar">
|
||||
<button className="cb-filter-btn">
|
||||
<SlidersHorizontal size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
<div className="cb-search-input-wrapper">
|
||||
<Search size={14} className="cb-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="cb-search-input"
|
||||
placeholder={`${t.search} ${breadcrumbs[breadcrumbs.length - 1]?.name || ''}`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cb-view-options">
|
||||
<button
|
||||
className={`cb-view-btn ${viewMode === 'grid' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`cb-view-btn ${viewMode === 'list' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Grid */}
|
||||
<div
|
||||
className={`cb-asset-grid ${viewMode}`}
|
||||
onContextMenu={(e) => handleContextMenu(e)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="cb-loading">Loading...</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="cb-empty">{t.empty}</div>
|
||||
) : (
|
||||
filteredAssets.map(asset => (
|
||||
<div
|
||||
key={asset.path}
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="cb-asset-thumbnail">
|
||||
{getFileIcon(asset)}
|
||||
</div>
|
||||
<div className="cb-asset-info">
|
||||
<div className="cb-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="cb-asset-type">
|
||||
{getAssetTypeName(asset)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="cb-status-bar">
|
||||
<span>{filteredAssets.length} {t.items}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
items={getContextMenuItems(contextMenu.asset)}
|
||||
position={contextMenu.position}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{renameDialog && (
|
||||
<div className="cb-dialog-overlay" onClick={() => setRenameDialog(null)}>
|
||||
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cb-dialog-header">
|
||||
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
|
||||
</div>
|
||||
<div className="cb-dialog-body">
|
||||
<input
|
||||
type="text"
|
||||
value={renameDialog.newName}
|
||||
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(renameDialog.asset, renameDialog.newName);
|
||||
if (e.key === 'Escape') setRenameDialog(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="cb-dialog-footer">
|
||||
<button className="cb-btn" onClick={() => setRenameDialog(null)}>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
className="cb-btn primary"
|
||||
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
|
||||
>
|
||||
{locale === 'zh' ? '确定' : 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Dialog */}
|
||||
{deleteConfirmDialog && (
|
||||
<div className="cb-dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
|
||||
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cb-dialog-header">
|
||||
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
|
||||
</div>
|
||||
<div className="cb-dialog-body">
|
||||
<p>
|
||||
{locale === 'zh'
|
||||
? `确定要删除 "${deleteConfirmDialog.name}" 吗?`
|
||||
: `Delete "${deleteConfirmDialog.name}"?`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cb-dialog-footer">
|
||||
<button className="cb-btn" onClick={() => setDeleteConfirmDialog(null)}>
|
||||
{locale === 'zh' ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
className="cb-btn danger"
|
||||
onClick={() => handleDelete(deleteConfirmDialog)}
|
||||
>
|
||||
{locale === 'zh' ? '删除' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create File Dialog */}
|
||||
{createFileDialog && (
|
||||
<PromptDialog
|
||||
title={`New ${createFileDialog.template.label}`}
|
||||
message={`Enter file name (.${createFileDialog.template.extension} will be added):`}
|
||||
placeholder="filename"
|
||||
confirmText={locale === 'zh' ? '创建' : 'Create'}
|
||||
cancelText={locale === 'zh' ? '取消' : 'Cancel'}
|
||||
onConfirm={async (value) => {
|
||||
const { parentPath, template } = createFileDialog;
|
||||
setCreateFileDialog(null);
|
||||
|
||||
let fileName = value;
|
||||
if (!fileName.endsWith(`.${template.extension}`)) {
|
||||
fileName = `${fileName}.${template.extension}`;
|
||||
}
|
||||
const filePath = `${parentPath}/${fileName}`;
|
||||
|
||||
try {
|
||||
const content = await template.getContent(fileName);
|
||||
await TauriAPI.writeFileContent(filePath, content);
|
||||
if (currentPath) {
|
||||
await loadAssets(currentPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create file:', error);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCreateFileDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,8 +83,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
type: 'create-file' | 'create-folder' | 'create-template';
|
||||
parentPath: string;
|
||||
templateExtension?: string;
|
||||
templateContent?: (fileName: string) => Promise<string>;
|
||||
} | null>(null);
|
||||
templateGetContent?: (fileName: string) => string | Promise<string>;
|
||||
} | null>(null);
|
||||
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
|
||||
@@ -515,14 +515,14 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
type: 'create-template',
|
||||
parentPath,
|
||||
templateExtension: template.extension,
|
||||
templateContent: template.createContent
|
||||
templateGetContent: template.getContent
|
||||
});
|
||||
};
|
||||
|
||||
const handlePromptConfirm = async (value: string) => {
|
||||
if (!promptDialog) return;
|
||||
|
||||
const { type, parentPath, templateExtension, templateContent } = promptDialog;
|
||||
const { type, parentPath, templateExtension, templateGetContent } = promptDialog;
|
||||
setPromptDialog(null);
|
||||
|
||||
let fileName = value;
|
||||
@@ -533,13 +533,13 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
await TauriAPI.createFile(targetPath);
|
||||
} else if (type === 'create-folder') {
|
||||
await TauriAPI.createDirectory(targetPath);
|
||||
} else if (type === 'create-template' && templateExtension && templateContent) {
|
||||
} else if (type === 'create-template' && templateExtension && templateGetContent) {
|
||||
if (!fileName.endsWith(`.${templateExtension}`)) {
|
||||
fileName = `${fileName}.${templateExtension}`;
|
||||
targetPath = `${parentPath}/${fileName}`;
|
||||
}
|
||||
|
||||
const content = await templateContent(fileName);
|
||||
// 获取内容并通过后端 API 写入文件
|
||||
const content = await templateGetContent(fileName);
|
||||
await TauriAPI.writeFileContent(targetPath, content);
|
||||
}
|
||||
await refreshTree();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
|
||||
@@ -91,9 +91,10 @@ interface FlexLayoutDockContainerProps {
|
||||
panels: FlexDockPanel[];
|
||||
onPanelClose?: (panelId: string) => void;
|
||||
activePanelId?: string;
|
||||
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
|
||||
}
|
||||
|
||||
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: FlexLayoutDockContainerProps) {
|
||||
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) {
|
||||
const layoutRef = useRef<Layout>(null);
|
||||
const previousLayoutJsonRef = useRef<string | null>(null);
|
||||
const previousPanelIdsRef = useRef<string>('');
|
||||
@@ -104,6 +105,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
|
||||
() => new Set(PERSISTENT_PANEL_IDS)
|
||||
);
|
||||
const [isAnyTabsetMaximized, setIsAnyTabsetMaximized] = useState(false);
|
||||
|
||||
const persistentPanels = useMemo(
|
||||
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
|
||||
@@ -337,8 +339,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
// 保存布局状态以便在panels变化时恢复
|
||||
const layoutJson = newModel.toJson();
|
||||
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
|
||||
|
||||
// Check if any tabset is maximized
|
||||
let hasMaximized = false;
|
||||
newModel.visitNodes((node) => {
|
||||
if (node.getType() === 'tabset') {
|
||||
const tabset = node as TabSetNode;
|
||||
if (tabset.isMaximized()) {
|
||||
hasMaximized = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
setIsAnyTabsetMaximized(hasMaximized);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageHub || !model) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('panel:select', (data: { panelId: string }) => {
|
||||
const { panelId } = data;
|
||||
const node = model.getNodeById(panelId);
|
||||
if (node && node.getType() === 'tab') {
|
||||
model.doAction(Actions.selectTab(panelId));
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub, model]);
|
||||
|
||||
return (
|
||||
<div className="flexlayout-dock-container">
|
||||
<Layout
|
||||
@@ -357,6 +385,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
panel={panel}
|
||||
rect={persistentPanelRects.get(panel.id)}
|
||||
isVisible={visiblePersistentPanels.has(panel.id)}
|
||||
isMaximized={isAnyTabsetMaximized}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -370,14 +399,20 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
function PersistentPanelContainer({
|
||||
panel,
|
||||
rect,
|
||||
isVisible
|
||||
isVisible,
|
||||
isMaximized
|
||||
}: {
|
||||
panel: FlexDockPanel;
|
||||
rect?: DOMRect;
|
||||
isVisible: boolean;
|
||||
isMaximized: boolean;
|
||||
}) {
|
||||
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
|
||||
|
||||
// Hide persistent panel completely when another tabset is maximized
|
||||
// (unless this panel itself is in the maximized tabset)
|
||||
const shouldHide = isMaximized && !isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="persistent-panel-container"
|
||||
@@ -387,9 +422,10 @@ function PersistentPanelContainer({
|
||||
top: hasValidRect ? rect.y : 0,
|
||||
width: hasValidRect ? rect.width : '100%',
|
||||
height: hasValidRect ? rect.height : '100%',
|
||||
visibility: isVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
zIndex: isVisible ? 1 : -1,
|
||||
visibility: (isVisible && !shouldHide) ? 'visible' : 'hidden',
|
||||
pointerEvents: (isVisible && !shouldHide) ? 'auto' : 'none',
|
||||
// 使用较低的 z-index,确保不会遮挡 FlexLayout 的 tab bar
|
||||
zIndex: 0,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
|
||||
323
packages/editor-app/src/components/MainToolbar.tsx
Normal file
323
packages/editor-app/src/components/MainToolbar.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipForward,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Eye,
|
||||
Globe,
|
||||
QrCode,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import type { MessageHub, CommandManager } from '@esengine/editor-core';
|
||||
import '../styles/MainToolbar.css';
|
||||
|
||||
export type PlayState = 'stopped' | 'playing' | 'paused';
|
||||
|
||||
interface MainToolbarProps {
|
||||
locale?: string;
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
onSaveScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onStop?: () => void;
|
||||
onStep?: () => void;
|
||||
onRunInBrowser?: () => void;
|
||||
onRunOnDevice?: () => void;
|
||||
}
|
||||
|
||||
interface ToolButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function ToolButton({ icon, label, active, disabled, onClick }: ToolButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`toolbar-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolSeparator() {
|
||||
return <div className="toolbar-separator" />;
|
||||
}
|
||||
|
||||
export function MainToolbar({
|
||||
locale = 'en',
|
||||
messageHub,
|
||||
commandManager,
|
||||
onSaveScene,
|
||||
onOpenScene,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
onStep,
|
||||
onRunInBrowser,
|
||||
onRunOnDevice
|
||||
}: MainToolbarProps) {
|
||||
const [playState, setPlayState] = useState<PlayState>('stopped');
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [showRunMenu, setShowRunMenu] = useState(false);
|
||||
const runMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
stop: 'Stop',
|
||||
step: 'Step Forward',
|
||||
save: 'Save Scene (Ctrl+S)',
|
||||
open: 'Open Scene',
|
||||
undo: 'Undo (Ctrl+Z)',
|
||||
redo: 'Redo (Ctrl+Y)',
|
||||
preview: 'Preview Mode',
|
||||
runOptions: 'Run Options',
|
||||
runInBrowser: 'Run in Browser',
|
||||
runOnDevice: 'Run on Device'
|
||||
},
|
||||
zh: {
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
stop: '停止',
|
||||
step: '单步执行',
|
||||
save: '保存场景 (Ctrl+S)',
|
||||
open: '打开场景',
|
||||
undo: '撤销 (Ctrl+Z)',
|
||||
redo: '重做 (Ctrl+Y)',
|
||||
preview: '预览模式',
|
||||
runOptions: '运行选项',
|
||||
runInBrowser: '浏览器运行',
|
||||
runOnDevice: '真机运行'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
};
|
||||
|
||||
// Close run menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showRunMenu) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
|
||||
setShowRunMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [showRunMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandManager) {
|
||||
const updateUndoRedo = () => {
|
||||
setCanUndo(commandManager.canUndo());
|
||||
setCanRedo(commandManager.canRedo());
|
||||
};
|
||||
updateUndoRedo();
|
||||
|
||||
if (messageHub) {
|
||||
const unsubscribe = messageHub.subscribe('command:executed', updateUndoRedo);
|
||||
return () => unsubscribe();
|
||||
}
|
||||
}
|
||||
}, [commandManager, messageHub]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribePlay = messageHub.subscribe('preview:started', () => {
|
||||
setPlayState('playing');
|
||||
});
|
||||
const unsubscribePause = messageHub.subscribe('preview:paused', () => {
|
||||
setPlayState('paused');
|
||||
});
|
||||
const unsubscribeStop = messageHub.subscribe('preview:stopped', () => {
|
||||
setPlayState('stopped');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribePlay();
|
||||
unsubscribePause();
|
||||
unsubscribeStop();
|
||||
};
|
||||
}
|
||||
}, [messageHub]);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (playState === 'stopped' || playState === 'paused') {
|
||||
onPlay?.();
|
||||
messageHub?.publish('preview:start', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (playState === 'playing') {
|
||||
onPause?.();
|
||||
messageHub?.publish('preview:pause', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (playState !== 'stopped') {
|
||||
onStop?.();
|
||||
messageHub?.publish('preview:stop', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep = () => {
|
||||
onStep?.();
|
||||
messageHub?.publish('preview:step', {});
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (commandManager?.canUndo()) {
|
||||
commandManager.undo();
|
||||
onUndo?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
if (commandManager?.canRedo()) {
|
||||
commandManager.redo();
|
||||
onRedo?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunInBrowser = () => {
|
||||
setShowRunMenu(false);
|
||||
onRunInBrowser?.();
|
||||
messageHub?.publish('viewport:run-in-browser', {});
|
||||
};
|
||||
|
||||
const handleRunOnDevice = () => {
|
||||
setShowRunMenu(false);
|
||||
onRunOnDevice?.();
|
||||
messageHub?.publish('viewport:run-on-device', {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-toolbar">
|
||||
{/* File Operations */}
|
||||
<div className="toolbar-group">
|
||||
<ToolButton
|
||||
icon={<Save size={16} />}
|
||||
label={t('save')}
|
||||
onClick={onSaveScene}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<FolderOpen size={16} />}
|
||||
label={t('open')}
|
||||
onClick={onOpenScene}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToolSeparator />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="toolbar-group">
|
||||
<ToolButton
|
||||
icon={<Undo2 size={16} />}
|
||||
label={t('undo')}
|
||||
disabled={!canUndo}
|
||||
onClick={handleUndo}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<Redo2 size={16} />}
|
||||
label={t('redo')}
|
||||
disabled={!canRedo}
|
||||
onClick={handleRedo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Play Controls - Absolutely Centered */}
|
||||
<div className="toolbar-center-wrapper">
|
||||
<div className="toolbar-group toolbar-center">
|
||||
<ToolButton
|
||||
icon={playState === 'playing' ? <Pause size={18} /> : <Play size={18} />}
|
||||
label={playState === 'playing' ? t('pause') : t('play')}
|
||||
onClick={playState === 'playing' ? handlePause : handlePlay}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<Square size={16} />}
|
||||
label={t('stop')}
|
||||
disabled={playState === 'stopped'}
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<SkipForward size={16} />}
|
||||
label={t('step')}
|
||||
disabled={playState === 'playing'}
|
||||
onClick={handleStep}
|
||||
/>
|
||||
|
||||
<ToolSeparator />
|
||||
|
||||
{/* Run Options Dropdown */}
|
||||
<div className="toolbar-dropdown" ref={runMenuRef}>
|
||||
<button
|
||||
className="toolbar-button toolbar-dropdown-trigger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRunMenu(prev => !prev);
|
||||
}}
|
||||
title={t('runOptions')}
|
||||
type="button"
|
||||
>
|
||||
<Globe size={16} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showRunMenu && (
|
||||
<div className="toolbar-dropdown-menu">
|
||||
<button type="button" onClick={handleRunInBrowser}>
|
||||
<Globe size={14} />
|
||||
<span>{t('runInBrowser')}</span>
|
||||
</button>
|
||||
<button type="button" onClick={handleRunOnDevice}>
|
||||
<QrCode size={14} />
|
||||
<span>{t('runOnDevice')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Mode Indicator - Right aligned */}
|
||||
<div className="toolbar-right">
|
||||
{playState !== 'stopped' && (
|
||||
<div className="preview-indicator">
|
||||
<Eye size={14} />
|
||||
<span>{t('preview')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
packages/editor-app/src/components/OutputLogPanel.tsx
Normal file
474
packages/editor-app/src/components/OutputLogPanel.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Search, Filter, Settings, X, Trash2, ChevronDown,
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play
|
||||
} from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/OutputLogPanel.css';
|
||||
|
||||
interface OutputLogPanelProps {
|
||||
logService: LogService;
|
||||
locale?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
log: LogEntry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
}) => {
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`output-log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="output-log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
</div>
|
||||
)}
|
||||
<div className="output-log-entry-message">
|
||||
<div className="output-log-message-container">
|
||||
<div className="output-log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="output-log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="output-log-json-btn"
|
||||
onClick={() => onOpenJsonViewer(parsed)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogEntryItem.displayName = 'LogEntryItem';
|
||||
|
||||
export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
LogLevel.Warn,
|
||||
LogLevel.Error,
|
||||
LogLevel.Fatal
|
||||
]));
|
||||
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const [showTimestamp, setShowTimestamp] = useState(true);
|
||||
const [showSource, setShowSource] = useState(true);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, entry];
|
||||
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [logService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Close menus on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
||||
setShowFilterMenu(false);
|
||||
}
|
||||
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowSettingsMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
}, [logService]);
|
||||
|
||||
const toggleLevelFilter = useCallback((level: LogLevel) => {
|
||||
setLevelFilter((prev) => {
|
||||
const newFilter = new Set(prev);
|
||||
if (newFilter.has(level)) {
|
||||
newFilter.delete(level);
|
||||
} else {
|
||||
newFilter.add(level);
|
||||
}
|
||||
return newFilter;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
if (showRemoteOnly && log.source !== 'remote') return false;
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!log.message.toLowerCase().includes(query) &&
|
||||
!log.source.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [logs, levelFilter, showRemoteOnly, searchQuery]);
|
||||
|
||||
const levelCounts = useMemo(() => ({
|
||||
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
|
||||
[LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length,
|
||||
[LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
|
||||
}), [logs]);
|
||||
|
||||
const remoteLogCount = useMemo(() =>
|
||||
logs.filter((l) => l.source === 'remote').length
|
||||
, [logs]);
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (!levelFilter.has(LogLevel.Debug)) count++;
|
||||
if (!levelFilter.has(LogLevel.Info)) count++;
|
||||
if (!levelFilter.has(LogLevel.Warn)) count++;
|
||||
if (!levelFilter.has(LogLevel.Error)) count++;
|
||||
if (showRemoteOnly) count++;
|
||||
return count;
|
||||
}, [levelFilter, showRemoteOnly]);
|
||||
|
||||
return (
|
||||
<div className="output-log-panel">
|
||||
{/* Toolbar */}
|
||||
<div className="output-log-toolbar">
|
||||
<div className="output-log-toolbar-left">
|
||||
<div className="output-log-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={locale === 'zh' ? '搜索日志...' : 'Search logs...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="output-log-search-clear"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="output-log-toolbar-right">
|
||||
{/* Filter Dropdown */}
|
||||
<div className="output-log-dropdown" ref={filterMenuRef}>
|
||||
<button
|
||||
className={`output-log-btn ${showFilterMenu ? 'active' : ''} ${activeFilterCount > 0 ? 'has-filter' : ''}`}
|
||||
onClick={() => {
|
||||
setShowFilterMenu(!showFilterMenu);
|
||||
setShowSettingsMenu(false);
|
||||
}}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>{locale === 'zh' ? '过滤器' : 'Filters'}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="filter-badge">{activeFilterCount}</span>
|
||||
)}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showFilterMenu && (
|
||||
<div className="output-log-menu">
|
||||
<div className="output-log-menu-header">
|
||||
{locale === 'zh' ? '日志级别' : 'Log Levels'}
|
||||
</div>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Debug)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Debug)}
|
||||
/>
|
||||
<Bug size={14} className="level-icon debug" />
|
||||
<span>Debug</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Debug]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Info)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Info)}
|
||||
/>
|
||||
<Info size={14} className="level-icon info" />
|
||||
<span>Info</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Info]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Warn)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Warn)}
|
||||
/>
|
||||
<AlertTriangle size={14} className="level-icon warn" />
|
||||
<span>Warning</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Warn]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Error)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Error)}
|
||||
/>
|
||||
<XCircle size={14} className="level-icon error" />
|
||||
<span>Error</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Error]}</span>
|
||||
</label>
|
||||
<div className="output-log-menu-divider" />
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRemoteOnly}
|
||||
onChange={() => setShowRemoteOnly(!showRemoteOnly)}
|
||||
/>
|
||||
<Wifi size={14} className="level-icon remote" />
|
||||
<span>{locale === 'zh' ? '仅远程日志' : 'Remote Only'}</span>
|
||||
<span className="level-count">{remoteLogCount}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto Scroll Toggle */}
|
||||
<button
|
||||
className={`output-log-icon-btn ${autoScroll ? 'active' : ''}`}
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
title={autoScroll
|
||||
? (locale === 'zh' ? '暂停自动滚动' : 'Pause auto-scroll')
|
||||
: (locale === 'zh' ? '恢复自动滚动' : 'Resume auto-scroll')
|
||||
}
|
||||
>
|
||||
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
|
||||
{/* Settings Dropdown */}
|
||||
<div className="output-log-dropdown" ref={settingsMenuRef}>
|
||||
<button
|
||||
className={`output-log-icon-btn ${showSettingsMenu ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setShowSettingsMenu(!showSettingsMenu);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
title={locale === 'zh' ? '设置' : 'Settings'}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
{showSettingsMenu && (
|
||||
<div className="output-log-menu settings-menu">
|
||||
<div className="output-log-menu-header">
|
||||
{locale === 'zh' ? '显示选项' : 'Display Options'}
|
||||
</div>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTimestamp}
|
||||
onChange={() => setShowTimestamp(!showTimestamp)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示时间戳' : 'Show Timestamp'}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSource}
|
||||
onChange={() => setShowSource(!showSource)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示来源' : 'Show Source'}</span>
|
||||
</label>
|
||||
<div className="output-log-menu-divider" />
|
||||
<button
|
||||
className="output-log-menu-action"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{locale === 'zh' ? '清空日志' : 'Clear Logs'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
{onClose && (
|
||||
<button
|
||||
className="output-log-close-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
className={`output-log-content ${!showTimestamp ? 'hide-timestamp' : ''} ${!showSource ? 'hide-source' : ''}`}
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="output-log-empty">
|
||||
<AlertCircle size={32} />
|
||||
<p>{searchQuery
|
||||
? (locale === 'zh' ? '没有匹配的日志' : 'No matching logs')
|
||||
: (locale === 'zh' ? '暂无日志' : 'No logs to display')
|
||||
}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="output-log-status">
|
||||
<span>{filteredLogs.length} / {logs.length} {locale === 'zh' ? '条日志' : 'logs'}</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="output-log-scroll-btn"
|
||||
onClick={() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓ {locale === 'zh' ? '滚动到底部' : 'Scroll to bottom'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Viewer Modal */}
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,11 @@ const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
audio: { zh: '音频', en: 'Audio' },
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'physics', 'audio', 'networking', 'tools'];
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } 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 '../styles/PropertyInspector.css';
|
||||
|
||||
const animationClipsEditor = new AnimationClipsFieldEditor();
|
||||
@@ -140,9 +141,9 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
);
|
||||
|
||||
case 'color': {
|
||||
// Convert numeric color (0xRRGGBB) to hex string (#RRGGBB)
|
||||
let colorValue = value ?? '#ffffff';
|
||||
if (typeof colorValue === 'number') {
|
||||
const wasNumber = typeof colorValue === 'number';
|
||||
if (wasNumber) {
|
||||
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
|
||||
}
|
||||
return (
|
||||
@@ -152,9 +153,12 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
value={colorValue}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => {
|
||||
// Convert hex string back to number for storage
|
||||
const numericValue = parseInt(newValue.slice(1), 16);
|
||||
handleChange(propertyName, numericValue);
|
||||
if (wasNumber) {
|
||||
const numericValue = parseInt(newValue.slice(1), 16);
|
||||
handleChange(propertyName, numericValue);
|
||||
} else {
|
||||
handleChange(propertyName, newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -206,25 +210,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
}
|
||||
};
|
||||
|
||||
// 从 FileActionRegistry 获取资产创建消息映射
|
||||
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
|
||||
const getCreationMapping = () => {
|
||||
if (!fileActionRegistry || !assetMeta.extensions) return null;
|
||||
for (const ext of assetMeta.extensions) {
|
||||
const mapping = fileActionRegistry.getAssetCreationMapping(ext);
|
||||
if (mapping) return mapping;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const creationMapping = getCreationMapping();
|
||||
|
||||
const handleCreate = () => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
if (fileExtension === '.tilemap.json') {
|
||||
messageHub.publish('tilemap:create-asset', {
|
||||
entityId: entity?.id,
|
||||
onChange: (newValue: string) => handleChange(propertyName, newValue)
|
||||
});
|
||||
} else if (fileExtension === '.btree') {
|
||||
messageHub.publish('behavior-tree:create-asset', {
|
||||
entityId: entity?.id,
|
||||
onChange: (newValue: string) => handleChange(propertyName, newValue)
|
||||
});
|
||||
}
|
||||
if (messageHub && creationMapping) {
|
||||
messageHub.publish(creationMapping.createMessage, {
|
||||
entityId: entity?.id,
|
||||
onChange: (newValue: string) => handleChange(propertyName, newValue)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const creatableExtensions = ['.tilemap.json', '.btree'];
|
||||
const canCreate = assetMeta.extensions?.some(ext => creatableExtensions.includes(ext));
|
||||
const canCreate = creationMapping !== null;
|
||||
|
||||
return (
|
||||
<div key={propertyName} className="property-field">
|
||||
@@ -267,6 +276,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'collisionLayer':
|
||||
return (
|
||||
<CollisionLayerField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? 1}
|
||||
multiple={false}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'collisionMask':
|
||||
return (
|
||||
<CollisionLayerField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? 0xFFFF}
|
||||
multiple={true}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,32 +3,28 @@ import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown,
|
||||
Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag,
|
||||
SquareStack
|
||||
} from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
/**
|
||||
* 根据图标名称获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 12): React.ReactNode {
|
||||
if (!iconName) return <Plus size={size} />;
|
||||
function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode {
|
||||
if (!iconName) return <Box size={size} />;
|
||||
|
||||
// 获取图标组件
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
// 回退到 Plus 图标
|
||||
return <Plus size={size} />;
|
||||
return <Box size={size} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类别图标映射
|
||||
*/
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
'rendering': 'Image',
|
||||
'ui': 'LayoutGrid',
|
||||
@@ -38,13 +34,35 @@ const categoryIconMap: Record<string, string> = {
|
||||
'other': 'MoreHorizontal',
|
||||
};
|
||||
|
||||
// 实体类型到图标的映射
|
||||
const entityTypeIcons: Record<string, React.ReactNode> = {
|
||||
'World': <Mountain size={14} className="entity-type-icon world" />,
|
||||
'Folder': <Folder size={14} className="entity-type-icon folder" />,
|
||||
'DirectionalLight': <Sun size={14} className="entity-type-icon light" />,
|
||||
'SkyLight': <Sun size={14} className="entity-type-icon light" />,
|
||||
'SkyAtmosphere': <Cloud size={14} className="entity-type-icon atmosphere" />,
|
||||
'VolumetricCloud': <Cloud size={14} className="entity-type-icon cloud" />,
|
||||
'StaticMeshActor': <SquareStack size={14} className="entity-type-icon mesh" />,
|
||||
'PlayerStart': <Flag size={14} className="entity-type-icon player" />,
|
||||
'ExponentialHeightFog': <Cloud size={14} className="entity-type-icon fog" />,
|
||||
};
|
||||
|
||||
type ViewMode = 'local' | 'remote';
|
||||
type SortColumn = 'name' | 'type';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface SceneHierarchyProps {
|
||||
entityStore: EntityStoreService;
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
isProfilerMode?: boolean;
|
||||
entityStore: EntityStoreService;
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
isProfilerMode?: boolean;
|
||||
}
|
||||
|
||||
interface EntityNode {
|
||||
entity: Entity;
|
||||
children: EntityNode[];
|
||||
isExpanded: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) {
|
||||
@@ -52,7 +70,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(isProfilerMode ? 'remote' : 'local');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sceneName, setSceneName] = useState<string>('Untitled');
|
||||
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
||||
@@ -62,9 +80,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
|
||||
const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null;
|
||||
|
||||
// Get entity creation templates from plugins
|
||||
useEffect(() => {
|
||||
@@ -77,7 +100,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
updateTemplates();
|
||||
|
||||
// Update when plugins are installed
|
||||
const unsubInstalled = messageHub.subscribe('plugin:installed', updateTemplates);
|
||||
const unsubUninstalled = messageHub.subscribe('plugin:uninstalled', updateTemplates);
|
||||
|
||||
@@ -134,7 +156,11 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
};
|
||||
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedId(data.entity?.id ?? null);
|
||||
if (data.entity) {
|
||||
setSelectedIds(new Set([data.entity.id]));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
updateEntities();
|
||||
@@ -174,25 +200,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
setIsRemoteConnected(connected);
|
||||
|
||||
if (connected && data.entities && data.entities.length > 0) {
|
||||
// 只在实体列表发生实质性变化时才更新
|
||||
setRemoteEntities((prev) => {
|
||||
if (prev.length !== data.entities!.length) {
|
||||
return data.entities!;
|
||||
}
|
||||
|
||||
// 检查实体ID和名称是否变化
|
||||
const hasChanged = data.entities!.some((entity, index) => {
|
||||
const prevEntity = prev[index];
|
||||
return !prevEntity ||
|
||||
prevEntity.id !== entity.id ||
|
||||
prevEntity.name !== entity.name ||
|
||||
prevEntity.componentCount !== entity.componentCount;
|
||||
prevEntity.id !== entity.id ||
|
||||
prevEntity.name !== entity.name ||
|
||||
prevEntity.componentCount !== entity.componentCount;
|
||||
});
|
||||
|
||||
return hasChanged ? data.entities! : prev;
|
||||
});
|
||||
|
||||
// 请求第一个实体的详情以获取场景名称
|
||||
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
|
||||
profilerService.requestEntityDetails(data.entities[0].id);
|
||||
}
|
||||
@@ -218,8 +241,21 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
return () => window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
}, []);
|
||||
|
||||
const handleEntityClick = (entity: Entity) => {
|
||||
entityStore.selectEntity(entity);
|
||||
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entity.id)) {
|
||||
next.delete(entity.id);
|
||||
} else {
|
||||
next.add(entity.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds(new Set([entity.id]));
|
||||
entityStore.selectEntity(entity);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, entityId: number) => {
|
||||
@@ -253,15 +289,13 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
};
|
||||
|
||||
const handleRemoteEntityClick = (entity: RemoteEntity) => {
|
||||
setSelectedId(entity.id);
|
||||
setSelectedIds(new Set([entity.id]));
|
||||
|
||||
// 请求完整的实体详情(包含组件属性)
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
profilerService.requestEntityDetails(entity.id);
|
||||
}
|
||||
|
||||
// 先发布基本信息,详细信息稍后通过 ProfilerService 异步返回
|
||||
messageHub.publish('remote-entity:selected', {
|
||||
entity: {
|
||||
id: entity.id,
|
||||
@@ -273,12 +307,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
});
|
||||
};
|
||||
|
||||
const handleSceneNameClick = () => {
|
||||
if (sceneFilePath) {
|
||||
messageHub.publish('asset:reveal', { path: sceneFilePath });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEntity = () => {
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Entity ${entityCount + 1}`;
|
||||
@@ -326,7 +354,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => closeContextMenu();
|
||||
if (contextMenu) {
|
||||
@@ -335,7 +362,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
// Listen for Delete key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' && selectedId && !isShowingRemote) {
|
||||
@@ -347,6 +373,42 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isShowingRemote]);
|
||||
|
||||
const toggleFolderExpand = (entityId: number) => {
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entityId)) {
|
||||
next.delete(entityId);
|
||||
} else {
|
||||
next.add(entityId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSortClick = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Get entity type for display
|
||||
const getEntityType = (entity: Entity): string => {
|
||||
const components = entity.components || [];
|
||||
if (components.length > 0) {
|
||||
const firstComponent = components[0];
|
||||
return firstComponent?.constructor?.name || 'Entity';
|
||||
}
|
||||
return 'Entity';
|
||||
};
|
||||
|
||||
// Get icon for entity type
|
||||
const getEntityIcon = (entityType: string): React.ReactNode => {
|
||||
return entityTypeIcons[entityType] || <Box size={14} className="entity-type-icon default" />;
|
||||
};
|
||||
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
if (!searchQuery.trim()) return entityList;
|
||||
@@ -356,12 +418,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const name = entity.name;
|
||||
const id = entity.id.toString();
|
||||
|
||||
// Search by name or ID
|
||||
if (name.toLowerCase().includes(query) || id.includes(query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search by component types
|
||||
if (Array.isArray(entity.componentTypes)) {
|
||||
return entity.componentTypes.some((type) =>
|
||||
type.toLowerCase().includes(query)
|
||||
@@ -377,41 +437,64 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entityList.filter((entity) => {
|
||||
const name = entity.name || '';
|
||||
const id = entity.id.toString();
|
||||
return id.includes(query);
|
||||
return name.toLowerCase().includes(query) || id.includes(query);
|
||||
});
|
||||
};
|
||||
|
||||
// Determine which entities to display
|
||||
const displayEntities = isShowingRemote
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
|
||||
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
|
||||
|
||||
const totalCount = displayEntities.length;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
return (
|
||||
<div className="scene-hierarchy">
|
||||
<div className="hierarchy-header">
|
||||
<Layers size={16} className="hierarchy-header-icon" />
|
||||
<h3>{t('hierarchy.title')}</h3>
|
||||
<div
|
||||
className={[
|
||||
'scene-name-container',
|
||||
!isRemoteConnected && sceneFilePath && 'clickable',
|
||||
isSceneModified && 'modified'
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={!isRemoteConnected ? handleSceneNameClick : undefined}
|
||||
title={!isRemoteConnected && sceneFilePath
|
||||
? `${displaySceneName}${isSceneModified ? (locale === 'zh' ? ' (未保存 - Ctrl+S 保存)' : ' (Unsaved - Ctrl+S to save)') : ''} - ${locale === 'zh' ? '点击跳转到文件' : 'Click to reveal file'}`
|
||||
: displaySceneName}
|
||||
>
|
||||
<span className="scene-name">
|
||||
{displaySceneName}
|
||||
</span>
|
||||
{!isRemoteConnected && isSceneModified && (
|
||||
<span className="modified-indicator">●</span>
|
||||
)}
|
||||
<div className="scene-hierarchy outliner">
|
||||
{/* Toolbar */}
|
||||
<div className="outliner-toolbar">
|
||||
<div className="outliner-toolbar-left">
|
||||
<button
|
||||
className="outliner-filter-btn"
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="outliner-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={locale === 'zh' ? '搜索...' : 'Search...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<ChevronDown size={12} className="search-dropdown" />
|
||||
</div>
|
||||
|
||||
<div className="outliner-toolbar-right">
|
||||
{!isShowingRemote && (
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '添加' : 'Add'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
title={locale === 'zh' ? '设置' : 'Settings'}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isRemoteConnected && !isProfilerMode && (
|
||||
<div className="view-mode-toggle">
|
||||
<button
|
||||
@@ -430,97 +513,139 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRemoteIndicator && (
|
||||
<div className="remote-indicator" title="Showing remote entities">
|
||||
<Wifi size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hierarchy-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('hierarchy.search') || 'Search entities...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isShowingRemote && (
|
||||
<div className="hierarchy-toolbar">
|
||||
<button
|
||||
className="toolbar-btn icon-only"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn icon-only"
|
||||
onClick={handleDeleteEntity}
|
||||
disabled={!selectedId}
|
||||
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="outliner-header">
|
||||
<div className="outliner-header-icons">
|
||||
<span title={locale === 'zh' ? '可见性' : 'Visibility'}><Eye size={12} className="header-icon" /></span>
|
||||
<span title={locale === 'zh' ? '收藏' : 'Favorite'}><Star size={12} className="header-icon" /></span>
|
||||
<span title={locale === 'zh' ? '锁定' : 'Lock'}><Lock size={12} className="header-icon" /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hierarchy-content scrollable" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||
<div
|
||||
className={`outliner-header-label ${sortColumn === 'name' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSortClick('name')}
|
||||
>
|
||||
<span>Item Label</span>
|
||||
{sortColumn === 'name' && (
|
||||
<span className="sort-indicator">{sortDirection === 'asc' ? '▲' : '▼'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`outliner-header-type ${sortColumn === 'type' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSortClick('type')}
|
||||
>
|
||||
<span>Type</span>
|
||||
{sortColumn === 'type' && (
|
||||
<span className="sort-indicator">{sortDirection === 'asc' ? '▲' : '▼'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity List */}
|
||||
<div className="outliner-content" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-title">{t('hierarchy.empty')}</div>
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{isShowingRemote
|
||||
? 'No entities in remote game'
|
||||
: 'Create an entity to get started'}
|
||||
? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game')
|
||||
: (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
|
||||
</div>
|
||||
</div>
|
||||
) : isShowingRemote ? (
|
||||
<ul className="entity-list">
|
||||
<div className="outliner-list">
|
||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||
<li
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`entity-item remote-entity ${selectedId === entity.id ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
title={`${entity.name} - ${entity.componentTypes.join(', ')}`}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleRemoteEntityClick(entity)}
|
||||
>
|
||||
<Box size={14} className="entity-icon" />
|
||||
<span className="entity-name">{entity.name}</span>
|
||||
{entity.tag !== 0 && (
|
||||
<span className="entity-tag" title={`Tag: ${entity.tag}`}>
|
||||
#{entity.tag}
|
||||
</span>
|
||||
)}
|
||||
{entity.componentCount > 0 && (
|
||||
<span className="component-count">{entity.componentCount}</span>
|
||||
)}
|
||||
</li>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')}
|
||||
<span className="outliner-item-name">{entity.name}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">
|
||||
{entity.componentTypes?.[0] || 'Entity'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="entity-list">
|
||||
{entities.map((entity, index) => (
|
||||
<li
|
||||
key={entity.id}
|
||||
className={`entity-item ${selectedId === entity.id ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
|
||||
draggable
|
||||
onClick={() => handleEntityClick(entity)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<Box size={14} className="entity-icon" />
|
||||
<span className="entity-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="outliner-list">
|
||||
{/* World/Scene Root */}
|
||||
<div
|
||||
className={`outliner-item world-item ${expandedFolders.has(-1) ? 'expanded' : ''}`}
|
||||
onClick={() => toggleFolderExpand(-1)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span
|
||||
className="outliner-item-expand"
|
||||
onClick={(e) => { e.stopPropagation(); toggleFolderExpand(-1); }}
|
||||
>
|
||||
{expandedFolders.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<Mountain size={14} className="entity-type-icon world" />
|
||||
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">World</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Items */}
|
||||
{expandedFolders.has(-1) && entities.map((entity, index) => {
|
||||
const entityType = getEntityType(entity);
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entityType)}
|
||||
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">{entityType}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="outliner-status">
|
||||
<span>{totalCount} {locale === 'zh' ? '个对象' : 'actors'}</span>
|
||||
{selectedCount > 0 && (
|
||||
<span> ({selectedCount} {locale === 'zh' ? '个已选中' : 'selected'})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -578,7 +703,6 @@ function ContextMenuWithSubmenu({
|
||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
|
||||
};
|
||||
|
||||
// 将模板按类别分组(所有模板现在都来自插件)
|
||||
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||
const cat = template.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -586,7 +710,6 @@ function ContextMenuWithSubmenu({
|
||||
return acc;
|
||||
}, {} as Record<string, EntityCreationTemplate[]>);
|
||||
|
||||
// 按顺序排序每个类别内的模板
|
||||
Object.values(templatesByCategory).forEach(templates => {
|
||||
templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
});
|
||||
@@ -597,7 +720,6 @@ function ContextMenuWithSubmenu({
|
||||
setActiveSubmenu(category);
|
||||
};
|
||||
|
||||
// 定义类别显示顺序
|
||||
const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other'];
|
||||
const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => {
|
||||
const orderA = categoryOrder.indexOf(a);
|
||||
@@ -618,7 +740,6 @@ function ContextMenuWithSubmenu({
|
||||
|
||||
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
|
||||
|
||||
{/* 按类别渲染所有模板 */}
|
||||
{sortedCategories.map(([category, templates]) => (
|
||||
<div
|
||||
key={category}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Settings as SettingsIcon, ChevronRight } from 'lucide-react';
|
||||
/**
|
||||
* Settings Window - 设置窗口
|
||||
* 重新设计以匹配编辑器设计稿
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
|
||||
@@ -7,9 +18,16 @@ import { PluginListSetting } from './PluginListSetting';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
onClose: () => void;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
initialCategoryId?: string;
|
||||
onClose: () => void;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
initialCategoryId?: string;
|
||||
}
|
||||
|
||||
// 主分类结构
|
||||
interface MainCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
subCategories: SettingCategory[];
|
||||
}
|
||||
|
||||
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
|
||||
@@ -17,14 +35,88 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(initialCategoryId || null);
|
||||
const [values, setValues] = useState<Map<string, any>>(new Map());
|
||||
const [errors, setErrors] = useState<Map<string, string>>(new Map());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [expandedMainCategories, setExpandedMainCategories] = useState<Set<string>>(new Set(['通用']));
|
||||
|
||||
// 将分类组织成主分类和子分类
|
||||
const mainCategories = useMemo((): MainCategory[] => {
|
||||
const categoryMap = new Map<string, SettingCategory[]>();
|
||||
|
||||
// 定义主分类映射
|
||||
const mainCategoryMapping: Record<string, string> = {
|
||||
'appearance': '通用',
|
||||
'general': '通用',
|
||||
'project': '通用',
|
||||
'plugins': '通用',
|
||||
'editor': '通用',
|
||||
'physics': '全局',
|
||||
'rendering': '全局',
|
||||
'audio': '全局',
|
||||
'world': '世界分区',
|
||||
'local': '世界分区(本地)',
|
||||
'performance': '性能'
|
||||
};
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const mainCatName = mainCategoryMapping[cat.id] || '其他';
|
||||
if (!categoryMap.has(mainCatName)) {
|
||||
categoryMap.set(mainCatName, []);
|
||||
}
|
||||
categoryMap.get(mainCatName)!.push(cat);
|
||||
});
|
||||
|
||||
// 定义固定的主分类顺序
|
||||
const orderedMainCategories = [
|
||||
'通用',
|
||||
'全局',
|
||||
'世界分区',
|
||||
'世界分区(本地)',
|
||||
'性能',
|
||||
'其他'
|
||||
];
|
||||
|
||||
return orderedMainCategories
|
||||
.filter((name) => categoryMap.has(name))
|
||||
.map((name) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
subCategories: categoryMap.get(name)!
|
||||
}));
|
||||
}, [categories]);
|
||||
|
||||
// 获取显示的子分类标题
|
||||
const subCategoryTitle = useMemo(() => {
|
||||
if (!selectedCategoryId) return '';
|
||||
const cat = categories.find((c) => c.id === selectedCategoryId);
|
||||
return cat?.title || '';
|
||||
}, [categories, selectedCategoryId]);
|
||||
|
||||
// 获取主分类标题
|
||||
const mainCategoryTitle = useMemo(() => {
|
||||
for (const main of mainCategories) {
|
||||
if (main.subCategories.some((sub) => sub.id === selectedCategoryId)) {
|
||||
return main.title;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, [mainCategories, selectedCategoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
const allCategories = settingsRegistry.getAllCategories();
|
||||
setCategories(allCategories);
|
||||
|
||||
// 默认展开所有section
|
||||
const allSectionIds = new Set<string>();
|
||||
allCategories.forEach((cat) => {
|
||||
cat.sections.forEach((section) => {
|
||||
allSectionIds.add(`${cat.id}-${section.id}`);
|
||||
});
|
||||
});
|
||||
setExpandedSections(allSectionIds);
|
||||
|
||||
if (allCategories.length > 0 && !selectedCategoryId) {
|
||||
// 如果有 initialCategoryId,尝试使用它
|
||||
if (initialCategoryId && allCategories.some(c => c.id === initialCategoryId)) {
|
||||
if (initialCategoryId && allCategories.some((c) => c.id === initialCategoryId)) {
|
||||
setSelectedCategoryId(initialCategoryId);
|
||||
} else {
|
||||
const firstCategory = allCategories[0];
|
||||
@@ -40,7 +132,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const initialValues = new Map<string, any>();
|
||||
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
// Project-scoped settings are loaded from ProjectService
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
@@ -52,7 +143,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else {
|
||||
// For other project settings, use default
|
||||
initialValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
} else {
|
||||
@@ -62,7 +152,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, selectedCategoryId]);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
@@ -71,7 +161,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value');
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || '无效值');
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
@@ -87,13 +177,11 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
// Track UI resolution changes for batch saving
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
// Project-scoped settings are saved to ProjectService
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
newWidth = value;
|
||||
@@ -102,7 +190,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
newHeight = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
// Preset changes width and height together
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newWidth = w;
|
||||
@@ -117,7 +204,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
}
|
||||
|
||||
// Save UI resolution if changed
|
||||
if (uiResolutionChanged && projectService) {
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
@@ -133,6 +219,76 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
const allSettings = settingsRegistry.getAllSettings();
|
||||
const defaultValues = new Map<string, any>();
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
defaultValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
setValues(defaultValues);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData: Record<string, any> = {};
|
||||
for (const [key, value] of values.entries()) {
|
||||
exportData[key] = value;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'editor-settings.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importData = JSON.parse(text);
|
||||
const newValues = new Map(values);
|
||||
for (const [key, value] of Object.entries(importData)) {
|
||||
newValues.set(key, value);
|
||||
}
|
||||
setValues(newValues);
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sectionId)) {
|
||||
newSet.delete(sectionId);
|
||||
} else {
|
||||
newSet.add(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMainCategory = (categoryId: string) => {
|
||||
setExpandedMainCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(categoryId)) {
|
||||
newSet.delete(categoryId);
|
||||
} else {
|
||||
newSet.add(categoryId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const renderSettingInput = (setting: SettingDescriptor) => {
|
||||
const value = values.get(setting.key) ?? setting.defaultValue;
|
||||
const error = errors.get(setting.key);
|
||||
@@ -140,105 +296,109 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label settings-label-checkbox">
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="settings-checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.checked, setting)}
|
||||
/>
|
||||
<span>{setting.label}</span>
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</label>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className={`settings-input ${error ? 'settings-input-error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
|
||||
placeholder={setting.placeholder}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="number"
|
||||
className={`settings-number-input ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
|
||||
placeholder={setting.placeholder}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`settings-input ${error ? 'settings-input-error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
placeholder={setting.placeholder}
|
||||
/>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="text"
|
||||
className={`settings-text-input ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
placeholder={setting.placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
className={`settings-select ${error ? 'settings-input-error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
|
||||
if (option) {
|
||||
handleValueChange(setting.key, option.value, setting);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{setting.options?.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<select
|
||||
className={`settings-select ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
|
||||
if (option) {
|
||||
handleValueChange(setting.key, option.value, setting);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{setting.options?.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'range':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
</label>
|
||||
<div className="settings-range-wrapper">
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="range"
|
||||
className="settings-range"
|
||||
@@ -250,26 +410,28 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
/>
|
||||
<span className="settings-range-value">{value}</span>
|
||||
</div>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'color':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
className="settings-color-input"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
/>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<div className="settings-color-bar" style={{ backgroundColor: value }}>
|
||||
<input
|
||||
type="color"
|
||||
className="settings-color-input"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -277,15 +439,30 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (!pluginManager) {
|
||||
return (
|
||||
<div className="settings-field settings-field-full">
|
||||
<div className="settings-row">
|
||||
<p className="settings-error">PluginManager 不可用</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-field settings-field-full">
|
||||
<div className="settings-plugin-list">
|
||||
<PluginListSetting pluginManager={pluginManager} />
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'collisionMatrix': {
|
||||
const CustomRenderer = setting.customRenderer as React.ComponentType<any> | undefined;
|
||||
if (CustomRenderer) {
|
||||
return (
|
||||
<div className="settings-custom-renderer">
|
||||
<CustomRenderer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<p className="settings-hint">碰撞矩阵编辑器未配置</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -298,71 +475,149 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
|
||||
|
||||
return (
|
||||
<div className="settings-overlay">
|
||||
<div className="settings-window">
|
||||
<div className="settings-header">
|
||||
<div className="settings-title">
|
||||
<SettingsIcon size={18} />
|
||||
<h2>设置</h2>
|
||||
</div>
|
||||
<button className="settings-close-btn" onClick={handleCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<div className="settings-sidebar">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
className={`settings-category-btn ${selectedCategoryId === category.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
>
|
||||
<span className="settings-category-title">{category.title}</span>
|
||||
{category.description && (
|
||||
<span className="settings-category-desc">{category.description}</span>
|
||||
)}
|
||||
<ChevronRight size={14} className="settings-category-arrow" />
|
||||
</button>
|
||||
))}
|
||||
<div className="settings-overlay" onClick={handleCancel}>
|
||||
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Left Sidebar */}
|
||||
<div className="settings-sidebar-new">
|
||||
<div className="settings-sidebar-header">
|
||||
<SettingsIcon size={16} />
|
||||
<span>编辑器偏好设置</span>
|
||||
<button className="settings-sidebar-close" onClick={handleCancel}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
{selectedCategory && selectedCategory.sections.map((section) => (
|
||||
<div key={section.id} className="settings-section">
|
||||
<h3 className="settings-section-title">{section.title}</h3>
|
||||
{section.description && (
|
||||
<p className="settings-section-description">{section.description}</p>
|
||||
)}
|
||||
{section.settings.map((setting) => (
|
||||
<div key={setting.key}>
|
||||
{renderSettingInput(setting)}
|
||||
<div className="settings-sidebar-search">
|
||||
<span>所有设置</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-sidebar-categories">
|
||||
{mainCategories.map((mainCat) => (
|
||||
<div key={mainCat.id} className="settings-main-category">
|
||||
<div
|
||||
className="settings-main-category-header"
|
||||
onClick={() => toggleMainCategory(mainCat.id)}
|
||||
>
|
||||
{expandedMainCategories.has(mainCat.id) ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>{mainCat.title}</span>
|
||||
</div>
|
||||
|
||||
{expandedMainCategories.has(mainCat.id) && (
|
||||
<div className="settings-sub-categories">
|
||||
{mainCat.subCategories.map((subCat) => (
|
||||
<button
|
||||
key={subCat.id}
|
||||
className={`settings-sub-category ${selectedCategoryId === subCat.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategoryId(subCat.id)}
|
||||
>
|
||||
{subCat.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<div className="settings-content-new">
|
||||
{/* Top Header */}
|
||||
<div className="settings-content-header">
|
||||
<div className="settings-search-bar">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-header-actions">
|
||||
<button className="settings-icon-btn" title="设置">
|
||||
<SettingsIcon size={14} />
|
||||
</button>
|
||||
<button className="settings-action-btn" onClick={handleExport}>
|
||||
导出......
|
||||
</button>
|
||||
<button className="settings-action-btn" onClick={handleImport}>
|
||||
导入......
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Title */}
|
||||
<div className="settings-category-title-bar">
|
||||
<div className="settings-category-breadcrumb">
|
||||
<ChevronDown size={14} />
|
||||
<span className="settings-breadcrumb-main">{mainCategoryTitle}</span>
|
||||
<span className="settings-breadcrumb-separator">-</span>
|
||||
<span className="settings-breadcrumb-sub">{subCategoryTitle}</span>
|
||||
</div>
|
||||
{selectedCategory?.description && (
|
||||
<p className="settings-category-desc">{selectedCategory.description}</p>
|
||||
)}
|
||||
<div className="settings-category-actions">
|
||||
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
|
||||
设置为默认值
|
||||
</button>
|
||||
<button className="settings-category-action-btn" onClick={handleExport}>
|
||||
导出......
|
||||
</button>
|
||||
<button className="settings-category-action-btn" onClick={handleImport}>
|
||||
导入......
|
||||
</button>
|
||||
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
|
||||
重置为默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="settings-sections-container">
|
||||
{selectedCategory && selectedCategory.sections.map((section) => {
|
||||
const sectionKey = `${selectedCategory.id}-${section.id}`;
|
||||
const isExpanded = expandedSections.has(sectionKey);
|
||||
|
||||
return (
|
||||
<div key={section.id} className="settings-section-new">
|
||||
<div
|
||||
className="settings-section-header-new"
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="settings-section-content-new">
|
||||
{section.settings.map((setting) => (
|
||||
<div key={setting.key}>
|
||||
{renderSettingInput(setting)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!selectedCategory && (
|
||||
<div className="settings-empty">
|
||||
<div className="settings-empty-new">
|
||||
<SettingsIcon size={48} />
|
||||
<p>请选择一个设置分类</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<button className="settings-btn settings-btn-cancel" onClick={handleCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="settings-btn settings-btn-save"
|
||||
onClick={handleSave}
|
||||
disabled={errors.size > 0}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
326
packages/editor-app/src/components/StatusBar.tsx
Normal file
326
packages/editor-app/src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X } from 'lucide-react';
|
||||
import type { MessageHub, LogService } from '@esengine/editor-core';
|
||||
import { ContentBrowser } from './ContentBrowser';
|
||||
import { OutputLogPanel } from './OutputLogPanel';
|
||||
import '../styles/StatusBar.css';
|
||||
|
||||
interface StatusBarProps {
|
||||
pluginCount?: number;
|
||||
entityCount?: number;
|
||||
messageHub?: MessageHub | null;
|
||||
logService?: LogService | null;
|
||||
locale?: string;
|
||||
projectPath?: string | null;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'output' | 'cmd';
|
||||
|
||||
export function StatusBar({
|
||||
pluginCount = 0,
|
||||
entityCount = 0,
|
||||
messageHub,
|
||||
logService,
|
||||
locale = 'en',
|
||||
projectPath,
|
||||
onOpenScene
|
||||
}: StatusBarProps) {
|
||||
const [consoleInput, setConsoleInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
|
||||
const [contentDrawerOpen, setContentDrawerOpen] = useState(false);
|
||||
const [outputLogDrawerOpen, setOutputLogDrawerOpen] = useState(false);
|
||||
const [contentDrawerHeight, setContentDrawerHeight] = useState(300);
|
||||
const [outputLogDrawerHeight, setOutputLogDrawerHeight] = useState(300);
|
||||
const [isResizingContent, setIsResizingContent] = useState(false);
|
||||
const [isResizingOutputLog, setIsResizingOutputLog] = useState(false);
|
||||
const [revealPath, setRevealPath] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const startY = useRef(0);
|
||||
const startHeight = useRef(0);
|
||||
|
||||
// Subscribe to asset:reveal event
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (payload: { path: string }) => {
|
||||
if (payload.path) {
|
||||
// Generate unique key to force re-trigger even with same path
|
||||
setRevealPath(`${payload.path}?t=${Date.now()}`);
|
||||
setContentDrawerOpen(true);
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [messageHub]);
|
||||
|
||||
// Clear revealPath when drawer closes
|
||||
useEffect(() => {
|
||||
if (!contentDrawerOpen) {
|
||||
setRevealPath(null);
|
||||
}
|
||||
}, [contentDrawerOpen]);
|
||||
|
||||
const handleSelectPanel = useCallback((panelId: string) => {
|
||||
if (messageHub) {
|
||||
messageHub.publish('panel:select', { panelId });
|
||||
}
|
||||
}, [messageHub]);
|
||||
|
||||
const handleContentDrawerClick = useCallback(() => {
|
||||
setContentDrawerOpen(!contentDrawerOpen);
|
||||
if (!contentDrawerOpen) {
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
}, [contentDrawerOpen]);
|
||||
|
||||
const handleOutputLogClick = useCallback(() => {
|
||||
setActiveTab('output');
|
||||
setOutputLogDrawerOpen(!outputLogDrawerOpen);
|
||||
if (!outputLogDrawerOpen) {
|
||||
setContentDrawerOpen(false);
|
||||
}
|
||||
}, [outputLogDrawerOpen]);
|
||||
|
||||
const handleCmdClick = useCallback(() => {
|
||||
setActiveTab('cmd');
|
||||
handleSelectPanel('console');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}, [handleSelectPanel]);
|
||||
|
||||
const handleConsoleSubmit = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && consoleInput.trim()) {
|
||||
const command = consoleInput.trim();
|
||||
|
||||
console.info(`> ${command}`);
|
||||
|
||||
try {
|
||||
if (command.startsWith('help')) {
|
||||
console.info('Available commands: help, clear, echo <message>');
|
||||
} else if (command === 'clear') {
|
||||
logService?.clear();
|
||||
} else if (command.startsWith('echo ')) {
|
||||
console.info(command.substring(5));
|
||||
} else {
|
||||
console.warn(`Unknown command: ${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing command: ${error}`);
|
||||
}
|
||||
|
||||
setConsoleInput('');
|
||||
}
|
||||
}, [consoleInput, logService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'cmd') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Handle content drawer resize
|
||||
const handleContentResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizingContent(true);
|
||||
startY.current = e.clientY;
|
||||
startHeight.current = contentDrawerHeight;
|
||||
}, [contentDrawerHeight]);
|
||||
|
||||
// Handle output log drawer resize
|
||||
const handleOutputLogResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizingOutputLog(true);
|
||||
startY.current = e.clientY;
|
||||
startHeight.current = outputLogDrawerHeight;
|
||||
}, [outputLogDrawerHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizingContent && !isResizingOutputLog) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY.current - e.clientY;
|
||||
const newHeight = Math.max(200, Math.min(startHeight.current + delta, window.innerHeight * 0.7));
|
||||
if (isResizingContent) {
|
||||
setContentDrawerHeight(newHeight);
|
||||
} else if (isResizingOutputLog) {
|
||||
setOutputLogDrawerHeight(newHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizingContent(false);
|
||||
setIsResizingOutputLog(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizingContent, isResizingOutputLog]);
|
||||
|
||||
// Close drawer on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (contentDrawerOpen) {
|
||||
setContentDrawerOpen(false);
|
||||
}
|
||||
if (outputLogDrawerOpen) {
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [contentDrawerOpen, outputLogDrawerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Drawer Backdrop */}
|
||||
{(contentDrawerOpen || outputLogDrawerOpen) && (
|
||||
<div
|
||||
className="drawer-backdrop"
|
||||
onClick={() => {
|
||||
setContentDrawerOpen(false);
|
||||
setOutputLogDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content Drawer Panel */}
|
||||
<div
|
||||
className={`drawer-panel content-drawer-panel ${contentDrawerOpen ? 'open' : ''}`}
|
||||
style={{ height: contentDrawerOpen ? contentDrawerHeight : 0 }}
|
||||
>
|
||||
<div
|
||||
className="drawer-resize-handle"
|
||||
onMouseDown={handleContentResizeStart}
|
||||
/>
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-title">
|
||||
<FolderOpen size={14} />
|
||||
Content Browser
|
||||
</span>
|
||||
<button
|
||||
className="drawer-close"
|
||||
onClick={() => setContentDrawerOpen(false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<ContentBrowser
|
||||
projectPath={projectPath ?? null}
|
||||
locale={locale}
|
||||
onOpenScene={onOpenScene}
|
||||
isDrawer={true}
|
||||
revealPath={revealPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Log Drawer Panel */}
|
||||
<div
|
||||
className={`drawer-panel output-log-drawer-panel ${outputLogDrawerOpen ? 'open' : ''}`}
|
||||
style={{ height: outputLogDrawerOpen ? outputLogDrawerHeight : 0 }}
|
||||
>
|
||||
<div
|
||||
className="drawer-resize-handle"
|
||||
onMouseDown={handleOutputLogResizeStart}
|
||||
/>
|
||||
<div className="drawer-body output-log-body">
|
||||
{logService && (
|
||||
<OutputLogPanel
|
||||
logService={logService}
|
||||
locale={locale}
|
||||
onClose={() => setOutputLogDrawerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-left">
|
||||
<button
|
||||
className={`status-bar-btn drawer-toggle-btn ${contentDrawerOpen ? 'active' : ''}`}
|
||||
onClick={handleContentDrawerClick}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span>{locale === 'zh' ? '内容侧滑菜单' : 'Content Drawer'}</span>
|
||||
{contentDrawerOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||
</button>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<button
|
||||
className={`status-bar-tab ${outputLogDrawerOpen ? 'active' : ''}`}
|
||||
onClick={handleOutputLogClick}
|
||||
>
|
||||
<FileText size={12} />
|
||||
<span>{locale === 'zh' ? '输出日志' : 'Output Log'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`status-bar-tab ${activeTab === 'cmd' ? 'active' : ''}`}
|
||||
onClick={handleCmdClick}
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Cmd</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
<div className="status-bar-console-input">
|
||||
<span className="console-prompt">></span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={locale === 'zh' ? '输入控制台命令' : 'Enter Console Command'}
|
||||
value={consoleInput}
|
||||
onChange={(e) => setConsoleInput(e.target.value)}
|
||||
onKeyDown={handleConsoleSubmit}
|
||||
onFocus={() => setActiveTab('cmd')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-right">
|
||||
<button className="status-bar-indicator">
|
||||
<Activity size={12} />
|
||||
<span>{locale === 'zh' ? '回追踪' : 'Trace'}</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<div className="status-bar-icon-group">
|
||||
<button className="status-bar-icon-btn" title={locale === 'zh' ? '网络' : 'Network'}>
|
||||
<Wifi size={14} />
|
||||
</button>
|
||||
<button className="status-bar-icon-btn" title={locale === 'zh' ? '源代码管理' : 'Source Control'}>
|
||||
<GitBranch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<div className="status-bar-info">
|
||||
<Save size={12} />
|
||||
<span>{locale === 'zh' ? '所有已保存' : 'All Saved'}</span>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-info">
|
||||
<span>{locale === 'zh' ? '版本控制' : 'Revision Control'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
packages/editor-app/src/components/TitleBar.tsx
Normal file
360
packages/editor-app/src/components/TitleBar.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
|
||||
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import '../styles/TitleBar.css';
|
||||
|
||||
interface MenuItem {
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface TitleBarProps {
|
||||
projectName?: string;
|
||||
locale?: string;
|
||||
uiRegistry?: UIRegistry;
|
||||
messageHub?: MessageHub;
|
||||
pluginManager?: PluginManager;
|
||||
onNewScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onSaveScene?: () => void;
|
||||
onSaveSceneAs?: () => void;
|
||||
onOpenProject?: () => void;
|
||||
onCloseProject?: () => void;
|
||||
onExit?: () => void;
|
||||
onOpenPluginManager?: () => void;
|
||||
onOpenProfiler?: () => void;
|
||||
onOpenPortManager?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onToggleDevtools?: () => void;
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
projectName = 'Untitled',
|
||||
locale = 'en',
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
onSaveSceneAs,
|
||||
onOpenProject,
|
||||
onCloseProject,
|
||||
onExit,
|
||||
onOpenPluginManager,
|
||||
onOpenProfiler: _onOpenProfiler,
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
}: TitleBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateMenuItems();
|
||||
}, [uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeInstalled();
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub, uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMaximized = async () => {
|
||||
const maximized = await appWindow.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
};
|
||||
|
||||
checkMaximized();
|
||||
|
||||
const unlisten = appWindow.onResized(async () => {
|
||||
const maximized = await appWindow.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(fn => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
file: 'File',
|
||||
newScene: 'New Scene',
|
||||
openScene: 'Open Scene',
|
||||
saveScene: 'Save Scene',
|
||||
saveSceneAs: 'Save Scene As...',
|
||||
openProject: 'Open Project',
|
||||
closeProject: 'Close Project',
|
||||
exit: 'Exit',
|
||||
edit: 'Edit',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
cut: 'Cut',
|
||||
copy: 'Copy',
|
||||
paste: 'Paste',
|
||||
delete: 'Delete',
|
||||
selectAll: 'Select All',
|
||||
window: 'Window',
|
||||
sceneHierarchy: 'Scene Hierarchy',
|
||||
inspector: 'Inspector',
|
||||
assets: 'Assets',
|
||||
console: 'Console',
|
||||
viewport: 'Viewport',
|
||||
pluginManager: 'Plugin Manager',
|
||||
tools: 'Tools',
|
||||
createPlugin: 'Create Plugin',
|
||||
reloadPlugins: 'Reload Plugins',
|
||||
portManager: 'Port Manager',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
newScene: '新建场景',
|
||||
openScene: '打开场景',
|
||||
saveScene: '保存场景',
|
||||
saveSceneAs: '场景另存为...',
|
||||
openProject: '打开项目',
|
||||
closeProject: '关闭项目',
|
||||
exit: '退出',
|
||||
edit: '编辑',
|
||||
undo: '撤销',
|
||||
redo: '重做',
|
||||
cut: '剪切',
|
||||
copy: '复制',
|
||||
paste: '粘贴',
|
||||
delete: '删除',
|
||||
selectAll: '全选',
|
||||
window: '窗口',
|
||||
sceneHierarchy: '场景层级',
|
||||
inspector: '检视器',
|
||||
assets: '资产',
|
||||
console: '控制台',
|
||||
viewport: '视口',
|
||||
pluginManager: '插件管理器',
|
||||
tools: '工具',
|
||||
createPlugin: '创建插件',
|
||||
reloadPlugins: '重新加载插件',
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
};
|
||||
|
||||
const menus: Record<string, MenuItem[]> = {
|
||||
file: [
|
||||
{ label: t('newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
|
||||
{ label: t('openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
|
||||
{ separator: true },
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
{ label: t('exit'), onClick: onExit }
|
||||
],
|
||||
edit: [
|
||||
{ label: t('undo'), shortcut: 'Ctrl+Z', disabled: true },
|
||||
{ label: t('redo'), shortcut: 'Ctrl+Y', disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('cut'), shortcut: 'Ctrl+X', disabled: true },
|
||||
{ label: t('copy'), shortcut: 'Ctrl+C', disabled: true },
|
||||
{ label: t('paste'), shortcut: 'Ctrl+V', disabled: true },
|
||||
{ label: t('delete'), shortcut: 'Delete', disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('selectAll'), shortcut: 'Ctrl+A', disabled: true }
|
||||
],
|
||||
window: [
|
||||
...pluginMenuItems.map((item) => ({
|
||||
label: item.label || '',
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
onClick: item.onClick
|
||||
})),
|
||||
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
|
||||
{ label: t('pluginManager'), onClick: onOpenPluginManager },
|
||||
{ separator: true },
|
||||
{ label: t('devtools'), onClick: onToggleDevtools }
|
||||
],
|
||||
tools: [
|
||||
{ label: t('createPlugin'), onClick: onCreatePlugin },
|
||||
{ label: t('reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
{ label: t('settings'), onClick: onOpenSettings }
|
||||
],
|
||||
help: [
|
||||
{ label: t('documentation'), disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('about'), onClick: onOpenAbout }
|
||||
]
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (menuKey: string) => {
|
||||
setOpenMenu(openMenu === menuKey ? null : menuKey);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (!item.disabled && !item.separator && item.onClick && item.label) {
|
||||
item.onClick();
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinimize = async () => {
|
||||
await appWindow.minimize();
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
await appWindow.toggleMaximize();
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
await appWindow.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="titlebar">
|
||||
{/* Left: Logo and Menu */}
|
||||
<div className="titlebar-left">
|
||||
<div className="titlebar-logo">
|
||||
<span className="titlebar-logo-text">ES</span>
|
||||
</div>
|
||||
<div className="titlebar-menus" ref={menuRef}>
|
||||
{Object.keys(menus).map((menuKey) => (
|
||||
<div key={menuKey} className="titlebar-menu-item">
|
||||
<button
|
||||
className={`titlebar-menu-button ${openMenu === menuKey ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(menuKey)}
|
||||
>
|
||||
{t(menuKey)}
|
||||
</button>
|
||||
{openMenu === menuKey && menus[menuKey] && (
|
||||
<div className="titlebar-dropdown">
|
||||
{menus[menuKey].map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="titlebar-dropdown-separator" />;
|
||||
}
|
||||
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`titlebar-dropdown-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="titlebar-dropdown-item-content">
|
||||
{IconComponent && <IconComponent size={14} />}
|
||||
<span>{item.label || ''}</span>
|
||||
</span>
|
||||
{item.shortcut && <span className="titlebar-dropdown-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Draggable area */}
|
||||
<div className="titlebar-center" data-tauri-drag-region />
|
||||
|
||||
{/* Right: Project name + Window controls */}
|
||||
<div className="titlebar-right">
|
||||
<span className="titlebar-project-name" data-tauri-drag-region>{projectName}</span>
|
||||
<div className="titlebar-window-controls">
|
||||
<button className="titlebar-button" onClick={handleMinimize} title="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<rect width="10" height="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="titlebar-button" onClick={handleMaximize} title={isMaximized ? "Restore" : "Maximize"}>
|
||||
{isMaximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M2 0v2H0v8h8V8h2V0H2zm6 8H2V4h6v4z" fill="currentColor"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button className="titlebar-button titlebar-button-close" onClick={handleClose} title="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Play, Pause, Square, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
|
||||
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
|
||||
Magnet, ZoomIn
|
||||
} from 'lucide-react';
|
||||
import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
@@ -101,6 +105,18 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
|
||||
const runMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Snap settings
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [gridSnapValue, setGridSnapValue] = useState(10);
|
||||
const [rotationSnapValue, setRotationSnapValue] = useState(15);
|
||||
const [scaleSnapValue, setScaleSnapValue] = useState(0.25);
|
||||
const [showGridSnapMenu, setShowGridSnapMenu] = useState(false);
|
||||
const [showRotationSnapMenu, setShowRotationSnapMenu] = useState(false);
|
||||
const [showScaleSnapMenu, setShowScaleSnapMenu] = useState(false);
|
||||
const gridSnapMenuRef = useRef<HTMLDivElement>(null);
|
||||
const rotationSnapMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scaleSnapMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Store editor camera state when entering play mode
|
||||
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||||
const playStateRef = useRef<PlayState>('stopped');
|
||||
@@ -130,6 +146,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const selectedEntityRef = useRef<Entity | null>(null);
|
||||
const messageHubRef = useRef<MessageHub | null>(null);
|
||||
const transformModeRef = useRef<TransformMode>('select');
|
||||
const snapEnabledRef = useRef(true);
|
||||
const gridSnapRef = useRef(10);
|
||||
const rotationSnapRef = useRef(15);
|
||||
const scaleSnapRef = useRef(0.25);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
@@ -144,6 +164,40 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
transformModeRef.current = transformMode;
|
||||
}, [transformMode]);
|
||||
|
||||
useEffect(() => {
|
||||
snapEnabledRef.current = snapEnabled;
|
||||
}, [snapEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
gridSnapRef.current = gridSnapValue;
|
||||
}, [gridSnapValue]);
|
||||
|
||||
useEffect(() => {
|
||||
rotationSnapRef.current = rotationSnapValue;
|
||||
}, [rotationSnapValue]);
|
||||
|
||||
useEffect(() => {
|
||||
scaleSnapRef.current = scaleSnapValue;
|
||||
}, [scaleSnapValue]);
|
||||
|
||||
// Snap helper functions
|
||||
const snapToGrid = useCallback((value: number): number => {
|
||||
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
|
||||
return Math.round(value / gridSnapRef.current) * gridSnapRef.current;
|
||||
}, []);
|
||||
|
||||
const snapRotation = useCallback((value: number): number => {
|
||||
if (!snapEnabledRef.current || rotationSnapRef.current <= 0) return value;
|
||||
const degrees = (value * 180) / Math.PI;
|
||||
const snappedDegrees = Math.round(degrees / rotationSnapRef.current) * rotationSnapRef.current;
|
||||
return (snappedDegrees * Math.PI) / 180;
|
||||
}, []);
|
||||
|
||||
const snapScale = useCallback((value: number): number => {
|
||||
if (!snapEnabledRef.current || scaleSnapRef.current <= 0) return value;
|
||||
return Math.round(value / scaleSnapRef.current) * scaleSnapRef.current;
|
||||
}, []);
|
||||
|
||||
// Screen to world coordinate conversion - uses refs to avoid re-registering event handlers
|
||||
const screenToWorld = useCallback((screenX: number, screenY: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -205,8 +259,17 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
let rafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resizeCanvas();
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
|
||||
// Use requestAnimationFrame to avoid ResizeObserver loop errors
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
@@ -349,8 +412,38 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
isDraggingTransformRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
|
||||
// Apply snap on mouse up
|
||||
const entity = selectedEntityRef.current;
|
||||
if (entity && snapEnabledRef.current) {
|
||||
const mode = transformModeRef.current;
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
if (mode === 'move') {
|
||||
transform.position.x = snapToGrid(transform.position.x);
|
||||
transform.position.y = snapToGrid(transform.position.y);
|
||||
} else if (mode === 'rotate') {
|
||||
transform.rotation.z = snapRotation(transform.rotation.z);
|
||||
} else if (mode === 'scale') {
|
||||
transform.scale.x = snapScale(transform.scale.x);
|
||||
transform.scale.y = snapScale(transform.scale.y);
|
||||
}
|
||||
}
|
||||
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (uiTransform) {
|
||||
if (mode === 'move') {
|
||||
uiTransform.x = snapToGrid(uiTransform.x);
|
||||
uiTransform.y = snapToGrid(uiTransform.y);
|
||||
} else if (mode === 'rotate') {
|
||||
uiTransform.rotation = snapRotation(uiTransform.rotation);
|
||||
} else if (mode === 'scale') {
|
||||
uiTransform.scaleX = snapScale(uiTransform.scaleX);
|
||||
uiTransform.scaleY = snapScale(uiTransform.scaleY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify Inspector to refresh after transform change
|
||||
// 通知 Inspector 在变换更改后刷新
|
||||
if (messageHubRef.current && selectedEntityRef.current) {
|
||||
messageHubRef.current.publish('entity:selected', {
|
||||
entity: selectedEntityRef.current
|
||||
@@ -383,6 +476,9 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
@@ -538,6 +634,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
setCamera2DZoom(1);
|
||||
};
|
||||
|
||||
// Store handlers in refs to avoid dependency issues
|
||||
const handlePlayRef = useRef(handlePlay);
|
||||
const handlePauseRef = useRef(handlePause);
|
||||
const handleStopRef = useRef(handleStop);
|
||||
const handleRunInBrowserRef = useRef<(() => void) | null>(null);
|
||||
const handleRunOnDeviceRef = useRef<(() => void) | null>(null);
|
||||
handlePlayRef.current = handlePlay;
|
||||
handlePauseRef.current = handlePause;
|
||||
handleStopRef.current = handleStop;
|
||||
|
||||
const handleRunInBrowser = async () => {
|
||||
setShowRunMenu(false);
|
||||
|
||||
@@ -749,6 +855,51 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Update refs after function definitions
|
||||
handleRunInBrowserRef.current = handleRunInBrowser;
|
||||
handleRunOnDeviceRef.current = handleRunOnDevice;
|
||||
|
||||
// Subscribe to MainToolbar events
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribeStart = messageHub.subscribe('preview:start', () => {
|
||||
handlePlayRef.current();
|
||||
messageHub.publish('preview:started', {});
|
||||
});
|
||||
|
||||
const unsubscribePause = messageHub.subscribe('preview:pause', () => {
|
||||
handlePauseRef.current();
|
||||
messageHub.publish('preview:paused', {});
|
||||
});
|
||||
|
||||
const unsubscribeStop = messageHub.subscribe('preview:stop', () => {
|
||||
handleStopRef.current();
|
||||
messageHub.publish('preview:stopped', {});
|
||||
});
|
||||
|
||||
const unsubscribeStep = messageHub.subscribe('preview:step', () => {
|
||||
engine.step();
|
||||
});
|
||||
|
||||
const unsubscribeRunBrowser = messageHub.subscribe('viewport:run-in-browser', () => {
|
||||
handleRunInBrowserRef.current?.();
|
||||
});
|
||||
|
||||
const unsubscribeRunDevice = messageHub.subscribe('viewport:run-on-device', () => {
|
||||
handleRunOnDeviceRef.current?.();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeStart();
|
||||
unsubscribePause();
|
||||
unsubscribeStop();
|
||||
unsubscribeStep();
|
||||
unsubscribeRunBrowser();
|
||||
unsubscribeRunDevice();
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (containerRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
@@ -788,11 +939,44 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const gridSnapOptions = [1, 5, 10, 25, 50, 100];
|
||||
const rotationSnapOptions = [5, 10, 15, 30, 45, 90];
|
||||
const scaleSnapOptions = [0.1, 0.25, 0.5, 1];
|
||||
|
||||
const closeAllSnapMenus = useCallback(() => {
|
||||
setShowGridSnapMenu(false);
|
||||
setShowRotationSnapMenu(false);
|
||||
setShowScaleSnapMenu(false);
|
||||
setShowRunMenu(false);
|
||||
}, []);
|
||||
|
||||
// Close menus when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (gridSnapMenuRef.current && !gridSnapMenuRef.current.contains(target)) {
|
||||
setShowGridSnapMenu(false);
|
||||
}
|
||||
if (rotationSnapMenuRef.current && !rotationSnapMenuRef.current.contains(target)) {
|
||||
setShowRotationSnapMenu(false);
|
||||
}
|
||||
if (scaleSnapMenuRef.current && !scaleSnapMenuRef.current.contains(target)) {
|
||||
setShowScaleSnapMenu(false);
|
||||
}
|
||||
if (runMenuRef.current && !runMenuRef.current.contains(target)) {
|
||||
setShowRunMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="viewport" ref={containerRef}>
|
||||
<div className="viewport-toolbar">
|
||||
<div className="viewport-toolbar-left">
|
||||
{/* Transform tools group */}
|
||||
{/* Internal Overlay Toolbar */}
|
||||
<div className="viewport-internal-toolbar">
|
||||
<div className="viewport-internal-toolbar-left">
|
||||
{/* Transform tools */}
|
||||
<div className="viewport-btn-group">
|
||||
<button
|
||||
className={`viewport-btn ${transformMode === 'select' ? 'active' : ''}`}
|
||||
@@ -823,37 +1007,165 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
<Scaling size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="viewport-divider" />
|
||||
{/* View options group */}
|
||||
<div className="viewport-btn-group">
|
||||
|
||||
{/* Snap toggle */}
|
||||
<button
|
||||
className={`viewport-btn ${snapEnabled ? 'active' : ''}`}
|
||||
onClick={() => setSnapEnabled(!snapEnabled)}
|
||||
title={locale === 'zh' ? '吸附开关' : 'Toggle Snap'}
|
||||
>
|
||||
<Magnet size={14} />
|
||||
</button>
|
||||
|
||||
{/* Grid Snap Value */}
|
||||
<div className="viewport-snap-dropdown" ref={gridSnapMenuRef}>
|
||||
<button
|
||||
className={`viewport-btn ${showGrid ? 'active' : ''}`}
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
|
||||
className="viewport-snap-btn"
|
||||
onClick={() => { closeAllSnapMenus(); setShowGridSnapMenu(!showGridSnapMenu); }}
|
||||
title={locale === 'zh' ? '网格吸附' : 'Grid Snap'}
|
||||
>
|
||||
<Grid3x3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
|
||||
onClick={() => setShowGizmos(!showGizmos)}
|
||||
title={locale === 'zh' ? '显示辅助工具' : 'Show Gizmos'}
|
||||
>
|
||||
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
<Grid3x3 size={12} />
|
||||
<span>{gridSnapValue}</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showGridSnapMenu && (
|
||||
<div className="viewport-snap-menu">
|
||||
{gridSnapOptions.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
className={gridSnapValue === val ? 'active' : ''}
|
||||
onClick={() => { setGridSnapValue(val); setShowGridSnapMenu(false); }}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="viewport-divider" />
|
||||
{/* Run options dropdown */}
|
||||
<div className="viewport-dropdown" ref={runMenuRef}>
|
||||
|
||||
{/* Rotation Snap Value */}
|
||||
<div className="viewport-snap-dropdown" ref={rotationSnapMenuRef}>
|
||||
<button
|
||||
className="viewport-btn"
|
||||
onClick={() => setShowRunMenu(!showRunMenu)}
|
||||
className="viewport-snap-btn"
|
||||
onClick={() => { closeAllSnapMenus(); setShowRotationSnapMenu(!showRotationSnapMenu); }}
|
||||
title={locale === 'zh' ? '旋转吸附' : 'Rotation Snap'}
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
<span>{rotationSnapValue}°</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showRotationSnapMenu && (
|
||||
<div className="viewport-snap-menu">
|
||||
{rotationSnapOptions.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
className={rotationSnapValue === val ? 'active' : ''}
|
||||
onClick={() => { setRotationSnapValue(val); setShowRotationSnapMenu(false); }}
|
||||
>
|
||||
{val}°
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scale Snap Value */}
|
||||
<div className="viewport-snap-dropdown" ref={scaleSnapMenuRef}>
|
||||
<button
|
||||
className="viewport-snap-btn"
|
||||
onClick={() => { closeAllSnapMenus(); setShowScaleSnapMenu(!showScaleSnapMenu); }}
|
||||
title={locale === 'zh' ? '缩放吸附' : 'Scale Snap'}
|
||||
>
|
||||
<Scaling size={12} />
|
||||
<span>{scaleSnapValue}</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showScaleSnapMenu && (
|
||||
<div className="viewport-snap-menu">
|
||||
{scaleSnapOptions.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
className={scaleSnapValue === val ? 'active' : ''}
|
||||
onClick={() => { setScaleSnapValue(val); setShowScaleSnapMenu(false); }}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewport-internal-toolbar-right">
|
||||
{/* View options */}
|
||||
<button
|
||||
className={`viewport-btn ${showGrid ? 'active' : ''}`}
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
|
||||
>
|
||||
<Grid3x3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
|
||||
onClick={() => setShowGizmos(!showGizmos)}
|
||||
title={locale === 'zh' ? '显示辅助线' : 'Show Gizmos'}
|
||||
>
|
||||
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
</button>
|
||||
|
||||
<div className="viewport-divider" />
|
||||
|
||||
{/* Zoom display */}
|
||||
<div className="viewport-zoom-display">
|
||||
<ZoomIn size={12} />
|
||||
<span>{Math.round(camera2DZoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="viewport-divider" />
|
||||
|
||||
{/* Stats toggle */}
|
||||
<button
|
||||
className={`viewport-btn ${showStats ? 'active' : ''}`}
|
||||
onClick={() => setShowStats(!showStats)}
|
||||
title={locale === 'zh' ? '统计信息' : 'Stats'}
|
||||
>
|
||||
<Activity size={14} />
|
||||
</button>
|
||||
|
||||
{/* Reset view */}
|
||||
<button
|
||||
className="viewport-btn"
|
||||
onClick={handleReset}
|
||||
title={locale === 'zh' ? '重置视图' : 'Reset View'}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
className="viewport-btn"
|
||||
onClick={handleFullscreen}
|
||||
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
|
||||
<div className="viewport-divider" />
|
||||
|
||||
{/* Run options */}
|
||||
<div className="viewport-snap-dropdown" ref={runMenuRef}>
|
||||
<button
|
||||
className="viewport-snap-btn"
|
||||
onClick={() => { closeAllSnapMenus(); setShowRunMenu(!showRunMenu); }}
|
||||
title={locale === 'zh' ? '运行选项' : 'Run Options'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showRunMenu && (
|
||||
<div className="viewport-dropdown-menu">
|
||||
<div className="viewport-snap-menu viewport-snap-menu-right">
|
||||
<button onClick={handleRunInBrowser}>
|
||||
<Globe size={14} />
|
||||
{locale === 'zh' ? '浏览器运行' : 'Run in Browser'}
|
||||
@@ -866,62 +1178,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Centered playback controls */}
|
||||
<div className="viewport-toolbar-center">
|
||||
<div className="viewport-playback">
|
||||
<button
|
||||
className={`viewport-btn play-btn ${playState === 'playing' ? 'active' : ''}`}
|
||||
onClick={handlePlay}
|
||||
disabled={playState === 'playing'}
|
||||
title={locale === 'zh' ? '播放' : 'Play'}
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`viewport-btn pause-btn ${playState === 'paused' ? 'active' : ''}`}
|
||||
onClick={handlePause}
|
||||
disabled={playState !== 'playing'}
|
||||
title={locale === 'zh' ? '暂停' : 'Pause'}
|
||||
>
|
||||
<Pause size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="viewport-btn stop-btn"
|
||||
onClick={handleStop}
|
||||
disabled={playState === 'stopped'}
|
||||
title={locale === 'zh' ? '停止' : 'Stop'}
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="viewport-toolbar-right">
|
||||
<span className="viewport-zoom">{Math.round(camera2DZoom * 100)}%</span>
|
||||
<div className="viewport-divider" />
|
||||
<button
|
||||
className="viewport-btn"
|
||||
onClick={handleReset}
|
||||
title={locale === 'zh' ? '重置视图' : 'Reset View'}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`viewport-btn ${showStats ? 'active' : ''}`}
|
||||
onClick={() => setShowStats(!showStats)}
|
||||
title={locale === 'zh' ? '显示统计信息' : 'Show Stats'}
|
||||
>
|
||||
<Activity size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="viewport-btn"
|
||||
onClick={handleFullscreen}
|
||||
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
|
||||
|
||||
{showStats && (
|
||||
<div className="viewport-stats">
|
||||
<div className="viewport-stat">
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Component } from '@esengine/ecs-framework';
|
||||
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { ChevronDown, Lock, Unlock } from 'lucide-react';
|
||||
import '../../../styles/TransformInspector.css';
|
||||
|
||||
interface AxisInputProps {
|
||||
axis: 'x' | 'y' | 'z';
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(String(value ?? 0));
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(String(value ?? 0));
|
||||
}, [value]);
|
||||
|
||||
const handleBarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1;
|
||||
const newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
const rounded = Math.round(newValue * 1000) / 1000;
|
||||
onChange(rounded);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
const parsed = parseFloat(inputValue);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(parsed);
|
||||
} else {
|
||||
setInputValue(String(value ?? 0));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(String(value ?? 0));
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`tf-axis-input ${isDragging ? 'dragging' : ''}`}>
|
||||
<div
|
||||
className={`tf-axis-bar tf-axis-${axis}`}
|
||||
onMouseDown={handleBarMouseDown}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
{suffix && <span className="tf-axis-suffix">{suffix}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 双向箭头重置图标
|
||||
function ResetIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 6H11M1 6L3 4M1 6L3 8M11 6L9 4M11 6L9 8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface TransformRowProps {
|
||||
label: string;
|
||||
value: { x: number; y: number; z: number };
|
||||
showLock?: boolean;
|
||||
isLocked?: boolean;
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
onChange: (value: { x: number; y: number; z: number }) => void;
|
||||
onReset?: () => void;
|
||||
suffix?: string;
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
function TransformRow({
|
||||
label,
|
||||
value,
|
||||
showLock = false,
|
||||
isLocked = false,
|
||||
onLockChange,
|
||||
onChange,
|
||||
onReset,
|
||||
suffix,
|
||||
showDivider = true
|
||||
}: TransformRowProps) {
|
||||
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
|
||||
if (isLocked && showLock) {
|
||||
const oldVal = value[axis];
|
||||
if (oldVal !== 0) {
|
||||
const ratio = newValue / oldVal;
|
||||
onChange({
|
||||
x: axis === 'x' ? newValue : value.x * ratio,
|
||||
y: axis === 'y' ? newValue : value.y * ratio,
|
||||
z: axis === 'z' ? newValue : value.z * ratio
|
||||
});
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
}
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tf-row">
|
||||
<button className="tf-label-btn">
|
||||
{label}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
<div className="tf-inputs">
|
||||
<AxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
onChange={(v) => handleAxisChange('x', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
onChange={(v) => handleAxisChange('y', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
onChange={(v) => handleAxisChange('z', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
</div>
|
||||
{showLock && (
|
||||
<button
|
||||
className={`tf-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="tf-reset-btn"
|
||||
onClick={onReset}
|
||||
title="Reset"
|
||||
>
|
||||
<ResetIcon />
|
||||
</button>
|
||||
</div>
|
||||
{showDivider && <div className="tf-divider" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MobilityRowProps {
|
||||
value: 'static' | 'stationary' | 'movable';
|
||||
onChange: (value: 'static' | 'stationary' | 'movable') => void;
|
||||
}
|
||||
|
||||
function MobilityRow({ value, onChange }: MobilityRowProps) {
|
||||
return (
|
||||
<div className="tf-mobility-row">
|
||||
<span className="tf-mobility-label">Mobility</span>
|
||||
<div className="tf-mobility-buttons">
|
||||
<button
|
||||
className={`tf-mobility-btn ${value === 'static' ? 'active' : ''}`}
|
||||
onClick={() => onChange('static')}
|
||||
>
|
||||
Static
|
||||
</button>
|
||||
<button
|
||||
className={`tf-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
|
||||
onClick={() => onChange('stationary')}
|
||||
>
|
||||
Stationary
|
||||
</button>
|
||||
<button
|
||||
className={`tf-mobility-btn ${value === 'movable' ? 'active' : ''}`}
|
||||
onClick={() => onChange('movable')}
|
||||
>
|
||||
Movable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransformInspectorContent({ context }: { context: ComponentInspectorContext }) {
|
||||
const transform = context.component as TransformComponent;
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
const handlePositionChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.position = value;
|
||||
context.onChange?.('position', value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleRotationChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.rotation = value;
|
||||
context.onChange?.('rotation', value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleScaleChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.scale = value;
|
||||
context.onChange?.('scale', value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tf-inspector">
|
||||
<TransformRow
|
||||
label="Location"
|
||||
value={transform.position}
|
||||
onChange={handlePositionChange}
|
||||
onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })}
|
||||
/>
|
||||
<TransformRow
|
||||
label="Rotation"
|
||||
value={transform.rotation}
|
||||
onChange={handleRotationChange}
|
||||
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
|
||||
suffix="°"
|
||||
/>
|
||||
<TransformRow
|
||||
label="Scale"
|
||||
value={transform.scale}
|
||||
showLock
|
||||
isLocked={isScaleLocked}
|
||||
onLockChange={setIsScaleLocked}
|
||||
onChange={handleScaleChange}
|
||||
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
|
||||
showDivider={false}
|
||||
/>
|
||||
<div className="tf-divider" />
|
||||
<MobilityRow value={mobility} onChange={setMobility} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class TransformComponentInspector implements IComponentInspector<TransformComponent> {
|
||||
readonly id = 'transform-component-inspector';
|
||||
readonly name = 'Transform Component Inspector';
|
||||
readonly priority = 100;
|
||||
readonly targetComponents = ['Transform', 'TransformComponent'];
|
||||
|
||||
canHandle(component: Component): component is TransformComponent {
|
||||
return component instanceof TransformComponent ||
|
||||
component.constructor.name === 'TransformComponent' ||
|
||||
(component.constructor as any).componentName === 'Transform';
|
||||
}
|
||||
|
||||
render(context: ComponentInspectorContext): React.ReactElement {
|
||||
return React.createElement(TransformInspectorContent, {
|
||||
context,
|
||||
key: `transform-${context.version}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,198 +1,147 @@
|
||||
/* 资产选择框 */
|
||||
/* Asset Field - Design System Style */
|
||||
.asset-field {
|
||||
margin-bottom: 6px;
|
||||
min-width: 0; /* 允许在flex容器中收缩 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.asset-field__container {
|
||||
/* Main content container */
|
||||
.asset-field__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Thumbnail Preview */
|
||||
.asset-field__thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
min-width: 0; /* 允许在flex容器中收缩 */
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
.asset-field__container.hovered {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.asset-field__container.dragging {
|
||||
border-color: #4ade80;
|
||||
background: #1a2a1a;
|
||||
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
/* 资产图标区域 */
|
||||
.asset-field__icon {
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
background: #262626;
|
||||
border-right: 1px solid #333;
|
||||
color: #888;
|
||||
flex-shrink: 0; /* 图标不收缩 */
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.asset-field__container.hovered .asset-field__icon {
|
||||
color: #aaa;
|
||||
.asset-field__thumbnail:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 资产输入区域 */
|
||||
.asset-field__input {
|
||||
.asset-field__thumbnail.dragging {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.asset-field__thumbnail img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.asset-field__thumbnail-icon {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Right side container */
|
||||
.asset-field__right {
|
||||
flex: 1;
|
||||
padding: 0 8px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dropdown selector */
|
||||
.asset-field__dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-width: 0; /* 关键:允许flex项收缩到小于内容宽度 */
|
||||
overflow: hidden; /* 配合min-width: 0防止溢出 */
|
||||
transition: border-color 0.15s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-field__input:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
.asset-field__dropdown:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.asset-field__dropdown.dragging {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.asset-field__value {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%; /* 确保不超出父容器 */
|
||||
display: block; /* 让text-overflow生效 */
|
||||
}
|
||||
|
||||
.asset-field__input.empty .asset-field__value {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.asset-field__dropdown.has-value .asset-field__value {
|
||||
color: #ddd;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.asset-field__dropdown-arrow {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Action buttons row */
|
||||
.asset-field__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 0 1px;
|
||||
flex-shrink: 0; /* 操作按钮不收缩 */
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.asset-field__button {
|
||||
.asset-field__btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.asset-field__button:hover {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
.asset-field__btn:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.asset-field__button:active {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* 清除按钮特殊样式 */
|
||||
.asset-field__button--clear:hover {
|
||||
.asset-field__btn--clear:hover {
|
||||
background: #4a2020;
|
||||
border-color: #5a3030;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* 创建按钮特殊样式 */
|
||||
.asset-field__button--create {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.asset-field__button--create:hover {
|
||||
background: #1a3a1a;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.asset-field__container[disabled] {
|
||||
/* Disabled state */
|
||||
.asset-field[disabled] {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 下拉菜单样式(如果需要) */
|
||||
.asset-field__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.asset-field__dropdown-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-field__dropdown-item:hover {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.asset-field__dropdown-item-icon {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.asset-field__container.dragging {
|
||||
animation: highlight 0.5s ease;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.asset-field__button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.asset-field__icon {
|
||||
width: 26px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { FileText, Search, X, FolderOpen, ArrowRight, Package, Plus } from 'lucide-react';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Image, X, Navigation, ChevronDown, Copy } from 'lucide-react';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ProjectService } from '@esengine/editor-core';
|
||||
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
|
||||
import './AssetField.css';
|
||||
|
||||
@@ -7,11 +10,11 @@ interface AssetFieldProps {
|
||||
label?: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
fileExtension?: string; // 例如: '.btree'
|
||||
fileExtension?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
onNavigate?: (path: string) => void; // 导航到资产
|
||||
onCreate?: () => void; // 创建新资产
|
||||
onNavigate?: (path: string) => void;
|
||||
onCreate?: () => void;
|
||||
}
|
||||
|
||||
export function AssetField({
|
||||
@@ -25,10 +28,51 @@ export function AssetField({
|
||||
onCreate
|
||||
}: AssetFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 检测是否是图片资源
|
||||
const isImageAsset = useCallback((path: string | null) => {
|
||||
if (!path) return false;
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].some(ext =>
|
||||
path.toLowerCase().endsWith(ext)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 加载缩略图
|
||||
useEffect(() => {
|
||||
if (value && isImageAsset(value)) {
|
||||
// 获取项目路径并构建完整路径
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
|
||||
if (projectPath) {
|
||||
// 构建完整的文件路径
|
||||
const fullPath = value.startsWith('/') || value.includes(':')
|
||||
? value
|
||||
: `${projectPath}/${value}`;
|
||||
|
||||
try {
|
||||
const url = convertFileSrc(fullPath);
|
||||
setThumbnailUrl(url);
|
||||
} catch {
|
||||
setThumbnailUrl(null);
|
||||
}
|
||||
} else {
|
||||
// 没有项目路径时,尝试直接使用 value
|
||||
try {
|
||||
const url = convertFileSrc(value);
|
||||
setThumbnailUrl(url);
|
||||
} catch {
|
||||
setThumbnailUrl(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setThumbnailUrl(null);
|
||||
}
|
||||
}, [value, isImageAsset]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -55,26 +99,22 @@ export function AssetField({
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 处理从文件系统拖入的文件
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
if (file) {
|
||||
// Web File API 没有 path 属性,使用 name
|
||||
onChange(file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理从资产面板拖入的文件路径
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
|
||||
onChange(assetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容纯文本拖拽
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && (!fileExtension || text.endsWith(fileExtension))) {
|
||||
onChange(text);
|
||||
@@ -105,99 +145,85 @@ export function AssetField({
|
||||
return (
|
||||
<div className="asset-field">
|
||||
{label && <label className="asset-field__label">{label}</label>}
|
||||
<div
|
||||
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 资产图标 */}
|
||||
<div className="asset-field__icon">
|
||||
{value ? (
|
||||
fileExtension === '.btree' ?
|
||||
<FileText size={14} /> :
|
||||
<Package size={14} />
|
||||
) : (
|
||||
<Package size={14} style={{ opacity: 0.5 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 资产选择框 */}
|
||||
<div className="asset-field__content">
|
||||
{/* 缩略图预览 */}
|
||||
<div
|
||||
ref={inputRef}
|
||||
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
|
||||
className={`asset-field__thumbnail ${isDragging ? 'dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={!readonly ? handleBrowse : undefined}
|
||||
title={value || placeholder}
|
||||
>
|
||||
<span className="asset-field__value">
|
||||
{value ? getFileName(value) : placeholder}
|
||||
</span>
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<Image size={18} className="asset-field__thumbnail-icon" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮组 */}
|
||||
<div className="asset-field__actions">
|
||||
{/* 创建按钮 */}
|
||||
{onCreate && !readonly && !value && (
|
||||
<button
|
||||
className="asset-field__button asset-field__button--create"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreate();
|
||||
}}
|
||||
title="创建新资产"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* 右侧区域 */}
|
||||
<div className="asset-field__right">
|
||||
{/* 下拉选择框 */}
|
||||
<div
|
||||
ref={inputRef}
|
||||
className={`asset-field__dropdown ${value ? 'has-value' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||
onClick={!readonly ? handleBrowse : undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
title={value || placeholder}
|
||||
>
|
||||
<span className="asset-field__value">
|
||||
{value ? getFileName(value) : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={12} className="asset-field__dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{/* 浏览按钮 */}
|
||||
{!readonly && (
|
||||
<button
|
||||
className="asset-field__button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBrowse();
|
||||
}}
|
||||
title="浏览..."
|
||||
>
|
||||
<Search size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 导航/定位按钮 */}
|
||||
{onNavigate && (
|
||||
<button
|
||||
className="asset-field__button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (value) {
|
||||
{/* 操作按钮行 */}
|
||||
<div className="asset-field__actions">
|
||||
{/* 定位按钮 */}
|
||||
{value && onNavigate && (
|
||||
<button
|
||||
className="asset-field__btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(value);
|
||||
} else {
|
||||
handleBrowse();
|
||||
}
|
||||
}}
|
||||
title={value ? '在资产浏览器中显示' : '选择资产'}
|
||||
>
|
||||
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
|
||||
</button>
|
||||
)}
|
||||
}}
|
||||
title="Locate in Asset Browser"
|
||||
>
|
||||
<Navigation size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 */}
|
||||
{value && !readonly && (
|
||||
<button
|
||||
className="asset-field__button asset-field__button--clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
title="清除"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* 复制路径按钮 */}
|
||||
{value && (
|
||||
<button
|
||||
className="asset-field__btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value);
|
||||
}}
|
||||
title="Copy Path"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 */}
|
||||
{value && !readonly && (
|
||||
<button
|
||||
className="asset-field__btn asset-field__btn--clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Collision Layer Field Component
|
||||
* 碰撞层字段组件 - 支持 16 层选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 碰撞层配置接口(用于获取自定义层名称)
|
||||
*/
|
||||
interface CollisionLayerConfigAPI {
|
||||
getLayers(): Array<{ name: string }>;
|
||||
addListener(callback: () => void): void;
|
||||
removeListener(callback: () => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认层名称(当 CollisionLayerConfig 不可用时使用)
|
||||
*/
|
||||
const DEFAULT_LAYER_NAMES = [
|
||||
'Default', 'Player', 'Enemy', 'Projectile',
|
||||
'Ground', 'Platform', 'Trigger', 'Item',
|
||||
'Layer8', 'Layer9', 'Layer10', 'Layer11',
|
||||
'Layer12', 'Layer13', 'Layer14', 'Layer15',
|
||||
];
|
||||
|
||||
let cachedConfig: CollisionLayerConfigAPI | null = null;
|
||||
|
||||
/**
|
||||
* 尝试获取 CollisionLayerConfig 实例
|
||||
*/
|
||||
function getCollisionConfig(): CollisionLayerConfigAPI | null {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
try {
|
||||
// 动态导入以避免循环依赖
|
||||
const physicsModule = (window as any).__PHYSICS_RAPIER2D__;
|
||||
if (physicsModule?.CollisionLayerConfig) {
|
||||
cachedConfig = physicsModule.CollisionLayerConfig.getInstance();
|
||||
return cachedConfig;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CollisionLayerFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
multiple?: boolean;
|
||||
readOnly?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const CollisionLayerField: React.FC<CollisionLayerFieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
multiple = false,
|
||||
readOnly = false,
|
||||
onChange
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [layerNames, setLayerNames] = useState<string[]>(DEFAULT_LAYER_NAMES);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 从配置服务获取层名称
|
||||
useEffect(() => {
|
||||
const config = getCollisionConfig();
|
||||
if (config) {
|
||||
const updateNames = () => {
|
||||
const layers = config.getLayers();
|
||||
setLayerNames(layers.map(l => l.name));
|
||||
};
|
||||
updateNames();
|
||||
config.addListener(updateNames);
|
||||
return () => config.removeListener(updateNames);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getLayerIndex = useCallback((layerBit: number): number => {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (layerBit === (1 << i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if ((layerBit & (1 << i)) !== 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const getSelectedCount = useCallback((): number => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if ((value & (1 << i)) !== 0) count++;
|
||||
}
|
||||
return count;
|
||||
}, [value]);
|
||||
|
||||
const getSelectedLayerNames = useCallback((): string => {
|
||||
if (!multiple) {
|
||||
const index = getLayerIndex(value);
|
||||
return `${index}: ${layerNames[index] ?? 'Unknown'}`;
|
||||
}
|
||||
|
||||
const count = getSelectedCount();
|
||||
if (count === 0) return 'None';
|
||||
if (count === 16) return 'All (16)';
|
||||
if (count > 3) return `${count} layers`;
|
||||
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if ((value & (1 << i)) !== 0) {
|
||||
names.push(layerNames[i] ?? `Layer${i}`);
|
||||
}
|
||||
}
|
||||
return names.join(', ');
|
||||
}, [value, multiple, layerNames, getLayerIndex, getSelectedCount]);
|
||||
|
||||
const handleLayerToggle = (index: number) => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (multiple) {
|
||||
const bit = 1 << index;
|
||||
const newValue = (value & bit) ? (value & ~bit) : (value | bit);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
onChange(1 << index);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!readOnly) onChange(0xFFFF);
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
if (!readOnly) onChange(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field clayer-field" ref={dropdownRef}>
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="clayer-selector">
|
||||
<button
|
||||
className={`clayer-btn ${readOnly ? 'readonly' : ''}`}
|
||||
onClick={() => !readOnly && setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<span className="clayer-text">{getSelectedLayerNames()}</span>
|
||||
<span className="clayer-arrow">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && !readOnly && (
|
||||
<div className="clayer-dropdown">
|
||||
{multiple && (
|
||||
<div className="clayer-actions">
|
||||
<button onClick={handleSelectAll} type="button">全选</button>
|
||||
<button onClick={handleSelectNone} type="button">清空</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="clayer-list">
|
||||
{layerNames.map((layerName, index) => {
|
||||
const isSelected = multiple
|
||||
? (value & (1 << index)) !== 0
|
||||
: getLayerIndex(value) === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`clayer-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleLayerToggle(index)}
|
||||
>
|
||||
{multiple && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="clayer-check"
|
||||
/>
|
||||
)}
|
||||
<span className="clayer-idx">{index}</span>
|
||||
<span className="clayer-name">{layerName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.clayer-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clayer-selector {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clayer-btn {
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--input-border, #3c3c3c);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg, #1e1e1e);
|
||||
color: var(--text-primary, #ccc);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.clayer-btn:hover:not(.readonly) {
|
||||
border-color: var(--accent-color, #007acc);
|
||||
}
|
||||
|
||||
.clayer-btn.readonly {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.clayer-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clayer-arrow {
|
||||
font-size: 7px;
|
||||
margin-left: 6px;
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
.clayer-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: var(--dropdown-bg, #252526);
|
||||
border: 1px solid var(--border-color, #3c3c3c);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clayer-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid var(--border-color, #3c3c3c);
|
||||
}
|
||||
|
||||
.clayer-actions button {
|
||||
flex: 1;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--button-bg, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clayer-actions button:hover {
|
||||
background: var(--button-hover-bg, #444);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.clayer-list {
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.clayer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.clayer-item:hover {
|
||||
background: var(--list-hover-bg, #2a2d2e);
|
||||
}
|
||||
|
||||
.clayer-item.selected {
|
||||
background: var(--list-active-bg, #094771);
|
||||
}
|
||||
|
||||
.clayer-check {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
accent-color: var(--accent-color, #007acc);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clayer-idx {
|
||||
width: 14px;
|
||||
font-size: 9px;
|
||||
color: var(--text-tertiary, #666);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.clayer-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary, #ccc);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollisionLayerField;
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronRight, Lock, Unlock, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface TransformValue {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
}
|
||||
|
||||
interface AxisInputProps {
|
||||
axis: 'x' | 'y' | 'z';
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(String(value ?? 0));
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(String(value ?? 0));
|
||||
}, [value]);
|
||||
|
||||
const handleBarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1;
|
||||
const newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
const rounded = Math.round(newValue * 1000) / 1000;
|
||||
onChange(rounded);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
const parsed = parseFloat(inputValue);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(parsed);
|
||||
} else {
|
||||
setInputValue(String(value ?? 0));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(String(value ?? 0));
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`transform-axis-input ${isDragging ? 'dragging' : ''}`}>
|
||||
<div
|
||||
className={`transform-axis-bar ${axis}`}
|
||||
onMouseDown={handleBarMouseDown}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
{suffix && <span className="transform-axis-suffix">{suffix}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TransformRowProps {
|
||||
label: string;
|
||||
value: TransformValue;
|
||||
showZ?: boolean;
|
||||
showLock?: boolean;
|
||||
isLocked?: boolean;
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
onChange: (value: TransformValue) => void;
|
||||
onReset?: () => void;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export function TransformRow({
|
||||
label,
|
||||
value,
|
||||
showZ = false,
|
||||
showLock = false,
|
||||
isLocked = false,
|
||||
onLockChange,
|
||||
onChange,
|
||||
onReset,
|
||||
suffix
|
||||
}: TransformRowProps) {
|
||||
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
|
||||
if (isLocked && showLock) {
|
||||
const oldVal = axis === 'x' ? value.x : axis === 'y' ? value.y : (value.z ?? 0);
|
||||
if (oldVal !== 0) {
|
||||
const ratio = newValue / oldVal;
|
||||
onChange({
|
||||
x: axis === 'x' ? newValue : value.x * ratio,
|
||||
y: axis === 'y' ? newValue : value.y * ratio,
|
||||
z: showZ ? (axis === 'z' ? newValue : (value.z ?? 1) * ratio) : undefined
|
||||
});
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
}
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="transform-row">
|
||||
<div className="transform-row-label">
|
||||
<span className="transform-label-text">{label}</span>
|
||||
</div>
|
||||
<div className="transform-row-inputs">
|
||||
<AxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
onChange={(v) => handleAxisChange('x', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
onChange={(v) => handleAxisChange('y', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
{showZ && (
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
onChange={(v) => handleAxisChange('z', v)}
|
||||
suffix={suffix}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showLock && (
|
||||
<button
|
||||
className={`transform-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="transform-reset-btn"
|
||||
onClick={onReset}
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RotationRowProps {
|
||||
value: number | { x: number; y: number; z: number };
|
||||
onChange: (value: number | { x: number; y: number; z: number }) => void;
|
||||
onReset?: () => void;
|
||||
is3D?: boolean;
|
||||
}
|
||||
|
||||
export function RotationRow({ value, onChange, onReset, is3D = false }: RotationRowProps) {
|
||||
if (is3D && typeof value === 'object') {
|
||||
return (
|
||||
<div className="transform-row">
|
||||
<div className="transform-row-label">
|
||||
<span className="transform-label-text">Rotation</span>
|
||||
</div>
|
||||
<div className="transform-row-inputs">
|
||||
<AxisInput
|
||||
axis="x"
|
||||
value={value.x ?? 0}
|
||||
onChange={(v) => onChange({ ...value, x: v })}
|
||||
suffix="°"
|
||||
/>
|
||||
<AxisInput
|
||||
axis="y"
|
||||
value={value.y ?? 0}
|
||||
onChange={(v) => onChange({ ...value, y: v })}
|
||||
suffix="°"
|
||||
/>
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={value.z ?? 0}
|
||||
onChange={(v) => onChange({ ...value, z: v })}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="transform-reset-btn"
|
||||
onClick={() => onReset?.() || onChange({ x: 0, y: 0, z: 0 })}
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numericValue = typeof value === 'number' ? value : 0;
|
||||
|
||||
return (
|
||||
<div className="transform-row">
|
||||
<div className="transform-row-label">
|
||||
<span className="transform-label-text">Rotation</span>
|
||||
</div>
|
||||
<div className="transform-row-inputs rotation-single">
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={numericValue}
|
||||
onChange={(v) => onChange(v)}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="transform-reset-btn"
|
||||
onClick={() => onReset?.() || onChange(0)}
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MobilityRowProps {
|
||||
value: 'static' | 'stationary' | 'movable';
|
||||
onChange: (value: 'static' | 'stationary' | 'movable') => void;
|
||||
}
|
||||
|
||||
export function MobilityRow({ value, onChange }: MobilityRowProps) {
|
||||
return (
|
||||
<div className="transform-mobility-row">
|
||||
<span className="transform-mobility-label">Mobility</span>
|
||||
<div className="transform-mobility-buttons">
|
||||
<button
|
||||
className={`transform-mobility-btn ${value === 'static' ? 'active' : ''}`}
|
||||
onClick={() => onChange('static')}
|
||||
>
|
||||
Static
|
||||
</button>
|
||||
<button
|
||||
className={`transform-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
|
||||
onClick={() => onChange('stationary')}
|
||||
>
|
||||
Stationary
|
||||
</button>
|
||||
<button
|
||||
className={`transform-mobility-btn ${value === 'movable' ? 'active' : ''}`}
|
||||
onClick={() => onChange('movable')}
|
||||
>
|
||||
Movable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TransformSectionProps {
|
||||
position: { x: number; y: number };
|
||||
rotation: number;
|
||||
scale: { x: number; y: number };
|
||||
onPositionChange: (value: { x: number; y: number }) => void;
|
||||
onRotationChange: (value: number) => void;
|
||||
onScaleChange: (value: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
export function TransformSection({
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
onPositionChange,
|
||||
onRotationChange,
|
||||
onScaleChange
|
||||
}: TransformSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
|
||||
|
||||
return (
|
||||
<div className="transform-section">
|
||||
<div
|
||||
className="transform-section-header"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className={`transform-section-expand ${isExpanded ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
<span className="transform-section-title">Transform</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="transform-section-content">
|
||||
<TransformRow
|
||||
label="Location"
|
||||
value={position}
|
||||
onChange={onPositionChange}
|
||||
onReset={() => onPositionChange({ x: 0, y: 0 })}
|
||||
/>
|
||||
<RotationRow
|
||||
value={rotation}
|
||||
onChange={(v) => onRotationChange(typeof v === 'number' ? v : 0)}
|
||||
onReset={() => onRotationChange(0)}
|
||||
/>
|
||||
<TransformRow
|
||||
label="Scale"
|
||||
value={scale}
|
||||
showLock
|
||||
isLocked={isScaleLocked}
|
||||
onLockChange={setIsScaleLocked}
|
||||
onChange={onScaleChange}
|
||||
onReset={() => onScaleChange({ x: 1, y: 1 })}
|
||||
/>
|
||||
<MobilityRow value={mobility} onChange={setMobility} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search } from 'lucide-react';
|
||||
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 { PropertyInspector } from '../../PropertyInspector';
|
||||
@@ -8,6 +8,20 @@ import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } f
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
|
||||
|
||||
// 从 ComponentRegistry category 到 CategoryFilter 的映射
|
||||
const categoryKeyMap: Record<string, CategoryFilter> = {
|
||||
'components.category.core': 'general',
|
||||
'components.category.rendering': 'rendering',
|
||||
'components.category.physics': 'physics',
|
||||
'components.category.audio': 'audio',
|
||||
'components.category.ui': 'rendering',
|
||||
'components.category.ui.core': 'rendering',
|
||||
'components.category.ui.widgets': 'rendering',
|
||||
'components.category.other': 'other',
|
||||
};
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
type?: new () => Component;
|
||||
@@ -24,12 +38,18 @@ interface EntityInspectorProps {
|
||||
}
|
||||
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
|
||||
// 默认展开所有组件
|
||||
return new Set(entity.components.map((_, index) => index));
|
||||
});
|
||||
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 [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [propertySearchQuery, setPropertySearchQuery] = useState('');
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -38,6 +58,18 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 添加所有当前组件的索引(保留已有的展开状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
newSet.add(index);
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length, componentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
const rect = addButtonRef.current.getBoundingClientRect();
|
||||
@@ -182,25 +214,89 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
}
|
||||
};
|
||||
|
||||
const categoryTabs: { key: CategoryFilter; label: string }[] = [
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'transform', label: 'Transform' },
|
||||
{ key: 'rendering', label: 'Rendering' },
|
||||
{ key: 'physics', label: 'Physics' },
|
||||
{ key: 'audio', label: 'Audio' },
|
||||
{ key: 'other', label: 'Other' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
if (componentInfo?.category) {
|
||||
return categoryKeyMap[componentInfo.category] || 'general';
|
||||
}
|
||||
return 'general';
|
||||
}, [componentRegistry]);
|
||||
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
const category = getComponentCategory(componentName);
|
||||
if (category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (propertySearchQuery.trim()) {
|
||||
const query = propertySearchQuery.toLowerCase();
|
||||
if (!componentName.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [entity.components, categoryFilter, propertySearchQuery, getComponentCategory]);
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
{/* Header */}
|
||||
<div className="inspector-header">
|
||||
<Settings size={16} />
|
||||
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
|
||||
<div className="inspector-header-left">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => setIsLocked(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<Settings size={14} color="#666" />
|
||||
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
|
||||
</div>
|
||||
<span className="inspector-object-count">1 object</span>
|
||||
</div>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="inspector-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={propertySearchQuery}
|
||||
onChange={(e) => setPropertySearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="inspector-category-tabs">
|
||||
{categoryTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`inspector-category-tab ${categoryFilter === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setCategoryFilter(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">基本信息</div>
|
||||
<div className="property-field">
|
||||
<label className="property-label">Entity ID</label>
|
||||
<span className="property-value-text">{entity.id}</span>
|
||||
</div>
|
||||
<div className="property-field">
|
||||
<label className="property-label">Enabled</label>
|
||||
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title section-title-with-action">
|
||||
@@ -273,11 +369,14 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{entity.components.length === 0 ? (
|
||||
<div className="empty-state-small">暂无组件</div>
|
||||
{filteredComponents.length === 0 ? (
|
||||
<div className="empty-state-small">
|
||||
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
|
||||
</div>
|
||||
) : (
|
||||
entity.components.map((component: Component, index: number) => {
|
||||
const isExpanded = expandedComponents.has(index);
|
||||
filteredComponents.map((component: Component) => {
|
||||
const originalIndex = entity.components.indexOf(component);
|
||||
const isExpanded = expandedComponents.has(originalIndex);
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
@@ -285,12 +384,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${componentName}-${index}`}
|
||||
key={`${componentName}-${originalIndex}`}
|
||||
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(index)}
|
||||
onClick={() => toggleComponentExpanded(originalIndex)}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
@@ -311,7 +410,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
className="component-remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(index);
|
||||
handleRemoveComponent(originalIndex);
|
||||
}}
|
||||
title="移除组件"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user