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:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -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}
/>
);
}

View 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>
);
}

View File

@@ -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();

View File

@@ -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'
}}
>

View 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>
);
}

View 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>
);
}

View File

@@ -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[]>([]);

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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>
);

View 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">&gt;</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>
</>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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}`
});
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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="移除组件"
>