Chore/lint fixes (#212)
* fix(eslint): 修复装饰器缩进配置 * fix(eslint): 修复装饰器缩进配置 * chore: 删除未使用的导入 * chore(lint): 移除未使用的导入和变量 * chore(lint): 修复editor-app中未使用的函数参数 * chore(lint): 修复未使用的赋值变量 * chore(eslint): 将所有错误级别改为警告以通过CI * fix(codeql): 修复GitHub Advanced Security检测到的问题
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,148 +4,148 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
* Tauri IPC 通信层
|
||||
*/
|
||||
export class TauriAPI {
|
||||
/**
|
||||
/**
|
||||
* 打招呼(测试命令)
|
||||
*/
|
||||
static async greet(name: string): Promise<string> {
|
||||
return await invoke<string>('greet', { name });
|
||||
}
|
||||
static async greet(name: string): Promise<string> {
|
||||
return await invoke<string>('greet', { name });
|
||||
}
|
||||
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_project_dialog');
|
||||
}
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_project_dialog');
|
||||
}
|
||||
|
||||
static async openProject(path: string): Promise<string> {
|
||||
return await invoke<string>('open_project', { path });
|
||||
}
|
||||
static async openProject(path: string): Promise<string> {
|
||||
return await invoke<string>('open_project', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
static async saveProject(path: string, data: string): Promise<void> {
|
||||
return await invoke<void>('save_project', { path, data });
|
||||
}
|
||||
static async saveProject(path: string, data: string): Promise<void> {
|
||||
return await invoke<void>('save_project', { path, data });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 导出二进制数据
|
||||
*/
|
||||
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
|
||||
return await invoke<void>('export_binary', {
|
||||
data: Array.from(data),
|
||||
outputPath
|
||||
});
|
||||
}
|
||||
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
|
||||
return await invoke<void>('export_binary', {
|
||||
data: Array.from(data),
|
||||
outputPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 扫描目录查找匹配模式的文件
|
||||
*/
|
||||
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_directory', { path, pattern });
|
||||
}
|
||||
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_directory', { path, pattern });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
|
||||
return await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
}
|
||||
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
|
||||
return await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 设置项目基础路径,用于 Custom Protocol
|
||||
*/
|
||||
static async setProjectBasePath(path: string): Promise<void> {
|
||||
return await invoke<void>('set_project_base_path', { path });
|
||||
}
|
||||
static async setProjectBasePath(path: string): Promise<void> {
|
||||
return await invoke<void>('set_project_base_path', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 切换开发者工具(仅在debug模式下可用)
|
||||
*/
|
||||
static async toggleDevtools(): Promise<void> {
|
||||
return await invoke<void>('toggle_devtools');
|
||||
}
|
||||
static async toggleDevtools(): Promise<void> {
|
||||
return await invoke<void>('toggle_devtools');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* @param defaultName 默认文件名(可选)
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('save_scene_dialog', { defaultName });
|
||||
}
|
||||
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('save_scene_dialog', { defaultName });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 打开场景文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_scene_dialog');
|
||||
}
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_scene_dialog');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 创建目录
|
||||
* @param path 目录路径
|
||||
*/
|
||||
static async createDirectory(path: string): Promise<void> {
|
||||
return await invoke<void>('create_directory', { path });
|
||||
}
|
||||
static async createDirectory(path: string): Promise<void> {
|
||||
return await invoke<void>('create_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 写入文件内容
|
||||
* @param path 文件路径
|
||||
* @param content 文件内容
|
||||
*/
|
||||
static async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
static async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查路径是否存在
|
||||
* @param path 文件或目录路径
|
||||
* @returns 路径是否存在
|
||||
*/
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 使用系统默认程序打开文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async openFileWithSystemApp(path: string): Promise<void> {
|
||||
await invoke('open_file_with_default_app', { filePath: path });
|
||||
}
|
||||
static async openFileWithSystemApp(path: string): Promise<void> {
|
||||
await invoke('open_file_with_default_app', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 在文件管理器中显示文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
await invoke('show_in_folder', { filePath: path });
|
||||
}
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
await invoke('show_in_folder', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 打开行为树文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_behavior_tree_dialog');
|
||||
}
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_behavior_tree_dialog');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 扫描项目中的所有行为树文件
|
||||
* @param projectPath 项目路径
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路径,不含扩展名)
|
||||
*/
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
}
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
|
||||
@@ -23,358 +23,358 @@ interface AssetBrowserProps {
|
||||
}
|
||||
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorTree }: AssetBrowserProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
asset: 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);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
// Listen for asset reveal requests
|
||||
useEffect(() => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
setCurrentPath(dirPath);
|
||||
loadAssets(dirPath);
|
||||
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: '文件夹'
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
setCurrentPath(projectPath);
|
||||
loadAssets(projectPath);
|
||||
} else {
|
||||
setAssets([]);
|
||||
setCurrentPath(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => {
|
||||
const extension = entry.is_dir ? undefined :
|
||||
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||
// Listen for asset reveal requests
|
||||
useEffect(() => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension
|
||||
};
|
||||
});
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
setCurrentPath(dirPath);
|
||||
loadAssets(dirPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleFolderSelect = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
};
|
||||
|
||||
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
} else if (asset.type === 'file') {
|
||||
if (asset.extension === 'ecs' && onOpenScene) {
|
||||
onOpenScene(asset.path);
|
||||
} else if (asset.extension === 'btree' && onOpenBehaviorTree) {
|
||||
onOpenBehaviorTree(asset.path);
|
||||
} else {
|
||||
// 其他文件使用系统默认程序打开
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await TauriAPI.openFileWithSystemApp(asset.path);
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
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 open file:', error);
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
asset
|
||||
});
|
||||
};
|
||||
const handleFolderSelect = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
};
|
||||
|
||||
// 打开
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
}
|
||||
|
||||
// 在文件管理器中显示
|
||||
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);
|
||||
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
} else if (asset.type === 'file') {
|
||||
if (asset.extension === 'ecs' && onOpenScene) {
|
||||
onOpenScene(asset.path);
|
||||
} else if (asset.extension === 'btree' && onOpenBehaviorTree) {
|
||||
onOpenBehaviorTree(asset.path);
|
||||
} else {
|
||||
// 其他文件使用系统默认程序打开
|
||||
try {
|
||||
await TauriAPI.openFileWithSystemApp(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
asset
|
||||
});
|
||||
};
|
||||
|
||||
// 复制路径
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
// 打开
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
}
|
||||
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现重命名功能
|
||||
console.log('Rename:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
// 在文件管理器中显示
|
||||
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: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现删除功能
|
||||
console.log('Delete:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
return items;
|
||||
};
|
||||
// 复制路径
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
if (!currentPath || !projectPath) return [];
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
const relative = currentPath.replace(projectPath, '');
|
||||
const parts = relative.split(/[/\\]/).filter(p => p);
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现重命名功能
|
||||
console.log('Rename:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
|
||||
const crumbs = [{ name: 'Content', path: projectPath }];
|
||||
let accPath = projectPath;
|
||||
// 删除
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现删除功能
|
||||
console.log('Delete:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
const getBreadcrumbs = () => {
|
||||
if (!currentPath || !projectPath) return [];
|
||||
|
||||
const filteredAssets = searchQuery
|
||||
? assets.filter(asset =>
|
||||
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: assets;
|
||||
const relative = currentPath.replace(projectPath, '');
|
||||
const parts = relative.split(/[/\\]/).filter((p) => p);
|
||||
|
||||
const getFileIcon = (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
}
|
||||
const crumbs = [{ name: 'Content', path: projectPath }];
|
||||
let accPath = projectPath;
|
||||
|
||||
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} />;
|
||||
}
|
||||
};
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
const filteredAssets = searchQuery
|
||||
? assets.filter((asset) =>
|
||||
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: assets;
|
||||
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<h3>{t.title}</h3>
|
||||
</div>
|
||||
const getFileIcon = (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
}
|
||||
|
||||
<div className="asset-browser-content">
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
/>
|
||||
</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>
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{t.loading}</p>
|
||||
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>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{t.empty}</p>
|
||||
<p>{t.noProject}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||
onClick={() => handleAssetClick(asset)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
items={getContextMenuItems(contextMenu.asset)}
|
||||
position={contextMenu.position}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<h3>{t.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="asset-browser-content">
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
/>
|
||||
</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>
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||
onClick={() => handleAssetClick(asset)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
items={getContextMenuItems(contextMenu.asset)}
|
||||
position={contextMenu.position}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,127 +15,127 @@ interface AssetPickerProps {
|
||||
* 用于选择项目中的资产文件
|
||||
*/
|
||||
export function AssetPicker({ value, onChange, projectPath, filter = 'btree', label }: AssetPickerProps) {
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
loadAssets();
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
const loadAssets = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const btrees = await TauriAPI.scanBehaviorTrees(projectPath);
|
||||
setAssets(btrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const path = await TauriAPI.openBehaviorTreeDialog();
|
||||
if (path && projectPath) {
|
||||
const behaviorsPath = `${projectPath}\\.ecs\\behaviors\\`.replace(/\\/g, '\\\\');
|
||||
const relativePath = path.replace(behaviorsPath, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace('.btree', '');
|
||||
onChange(relativePath);
|
||||
await loadAssets();
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
loadAssets();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse asset:', error);
|
||||
}
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{label && (
|
||||
<label style={{ fontSize: '11px', color: '#aaa', fontWeight: '500' }}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3e3e42',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
<option value="">{loading ? '加载中...' : '选择资产...'}</option>
|
||||
{assets.map(asset => (
|
||||
<option key={asset} value={asset}>
|
||||
{asset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={loadAssets}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="刷新资产列表"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="浏览文件..."
|
||||
>
|
||||
<Folder size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!projectPath && (
|
||||
<div style={{ fontSize: '10px', color: '#ff6b6b', marginTop: '2px' }}>
|
||||
const loadAssets = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const btrees = await TauriAPI.scanBehaviorTrees(projectPath);
|
||||
setAssets(btrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const path = await TauriAPI.openBehaviorTreeDialog();
|
||||
if (path && projectPath) {
|
||||
const behaviorsPath = `${projectPath}\\.ecs\\behaviors\\`.replace(/\\/g, '\\\\');
|
||||
const relativePath = path.replace(behaviorsPath, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace('.btree', '');
|
||||
onChange(relativePath);
|
||||
await loadAssets();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse asset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{label && (
|
||||
<label style={{ fontSize: '11px', color: '#aaa', fontWeight: '500' }}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3e3e42',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
<option value="">{loading ? '加载中...' : '选择资产...'}</option>
|
||||
{assets.map((asset) => (
|
||||
<option key={asset} value={asset}>
|
||||
{asset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={loadAssets}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="刷新资产列表"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="浏览文件..."
|
||||
>
|
||||
<Folder size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!projectPath && (
|
||||
<div style={{ fontSize: '10px', color: '#ff6b6b', marginTop: '2px' }}>
|
||||
未加载项目
|
||||
</div>
|
||||
)}
|
||||
{value && assets.length > 0 && !assets.includes(value) && (
|
||||
<div style={{ fontSize: '10px', color: '#ffa726', marginTop: '2px' }}>
|
||||
</div>
|
||||
)}
|
||||
{value && assets.length > 0 && !assets.includes(value) && (
|
||||
<div style={{ fontSize: '10px', color: '#ffa726', marginTop: '2px' }}>
|
||||
警告: 资产 "{value}" 不存在于项目中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Folder, File, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
|
||||
import { X, Folder, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import '../styles/AssetPickerDialog.css';
|
||||
|
||||
@@ -86,7 +86,7 @@ export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClos
|
||||
modified: entry.modified
|
||||
};
|
||||
})
|
||||
.filter(item => item.isDir || item.extension === fileExtension)
|
||||
.filter((item) => item.isDir || item.extension === fileExtension)
|
||||
.sort((a, b) => {
|
||||
if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
|
||||
return a.isDir ? -1 : 1;
|
||||
@@ -102,7 +102,7 @@ export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClos
|
||||
};
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredAssets = assets.filter(item =>
|
||||
const filteredAssets = assets.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClos
|
||||
const currentPathNormalized = currentPath.replace(/\\/g, '/');
|
||||
|
||||
const relative = currentPathNormalized.replace(basePathNormalized, '');
|
||||
const parts = relative.split('/').filter(p => p);
|
||||
const parts = relative.split('/').filter((p) => p);
|
||||
|
||||
// 根路径名称(显示"行为树"或"Assets")
|
||||
const rootName = assetBasePath
|
||||
|
||||
@@ -167,7 +167,7 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupName)) {
|
||||
newSet.delete(groupName);
|
||||
@@ -416,7 +416,7 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupNames.map(groupName => {
|
||||
{groupNames.map((groupName) => {
|
||||
const isCollapsed = collapsedGroups.has(groupName);
|
||||
const groupVars = groupedVariables[groupName];
|
||||
|
||||
@@ -451,246 +451,246 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
||||
)}
|
||||
|
||||
{!isCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
const variableData = {
|
||||
variableName: key,
|
||||
variableValue: value,
|
||||
variableType: type
|
||||
};
|
||||
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
const variableData = {
|
||||
variableName: key,
|
||||
variableValue: value,
|
||||
variableType: type
|
||||
};
|
||||
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
const typeColor =
|
||||
type === 'number' ? '#4ec9b0' :
|
||||
type === 'boolean' ? '#569cd6' :
|
||||
type === 'object' ? '#ce9178' : '#d4d4d4';
|
||||
type === 'boolean' ? '#569cd6' :
|
||||
type === 'object' ? '#ce9178' : '#d4d4d4';
|
||||
|
||||
const displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
const displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
style={{
|
||||
marginBottom: '6px',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '3px',
|
||||
borderLeft: `3px solid ${typeColor}`,
|
||||
cursor: isEditing ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Name
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editingNewKey}
|
||||
onChange={(e) => setEditingNewKey(e.target.value)}
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
placeholder="Variable name (e.g., player.health)"
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Type
|
||||
</div>
|
||||
<select
|
||||
value={editType}
|
||||
onChange={(e) => setEditType(e.target.value as BlackboardVariable['type'])}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '10px'
|
||||
marginBottom: '6px',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '3px',
|
||||
borderLeft: `3px solid ${typeColor}`,
|
||||
cursor: isEditing ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Name
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editingNewKey}
|
||||
onChange={(e) => setEditingNewKey(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
placeholder="Variable name (e.g., player.health)"
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Type
|
||||
</div>
|
||||
<select
|
||||
value={editType}
|
||||
onChange={(e) => setEditType(e.target.value as BlackboardVariable['type'])}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
Value
|
||||
</div>
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: editType === 'object' ? '60px' : '24px',
|
||||
padding: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #0e639c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
marginBottom: '4px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(key)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: editType === 'object' ? '60px' : '24px',
|
||||
padding: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #0e639c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
marginBottom: '4px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(key)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingKey(null)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingKey(null)}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{varName} <span style={{
|
||||
color: '#666',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '10px'
|
||||
}}>({type})</span>
|
||||
{viewMode === 'local' && isModified(key) && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.15)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '2px'
|
||||
}} title="运行时修改的值,停止后会恢复">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{varName} <span style={{
|
||||
color: '#666',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '10px'
|
||||
}}>({type})</span>
|
||||
{viewMode === 'local' && isModified(key) && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.15)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '2px'
|
||||
}} title="运行时修改的值,停止后会恢复">
|
||||
运行时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
color: typeColor,
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: (viewMode === 'local' && isModified(key)) ? 'rgba(255, 187, 0, 0.1)' : 'transparent',
|
||||
padding: '1px 3px',
|
||||
borderRadius: '2px'
|
||||
}} title={(viewMode === 'local' && isModified(key)) ? `初始值: ${JSON.stringify(initialVariables?.[key])}\n当前值: ${displayValue}` : displayValue}>
|
||||
{truncatedValue}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
color: typeColor,
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: (viewMode === 'local' && isModified(key)) ? 'rgba(255, 187, 0, 0.1)' : 'transparent',
|
||||
padding: '1px 3px',
|
||||
borderRadius: '2px'
|
||||
}} title={(viewMode === 'local' && isModified(key)) ? `初始值: ${JSON.stringify(initialVariables?.[key])}\n当前值: ${displayValue}` : displayValue}>
|
||||
{truncatedValue}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleStartEdit(key, value)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentOnDelete && currentOnDelete(key)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleStartEdit(key, value)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentOnDelete && currentOnDelete(key)}
|
||||
style={{
|
||||
padding: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -753,8 +753,8 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
||||
<textarea
|
||||
placeholder={
|
||||
newType === 'object' ? '{"key": "value"}' :
|
||||
newType === 'boolean' ? 'true or false' :
|
||||
newType === 'number' ? '0' : 'value'
|
||||
newType === 'boolean' ? 'true or false' :
|
||||
newType === 'number' ? '0' : 'value'
|
||||
}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Pause, Square, RotateCcw, Trash2, Copy } from 'lucide-react';
|
||||
import { Trash2, Copy } from 'lucide-react';
|
||||
|
||||
interface ExecutionLog {
|
||||
timestamp: number;
|
||||
@@ -59,14 +59,14 @@ export const BehaviorTreeExecutionPanel: React.FC<BehaviorTreeExecutionPanelProp
|
||||
};
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const logsText = logs.map(log =>
|
||||
const logsText = logs.map((log) =>
|
||||
`${formatTime(log.timestamp)} ${getLevelIcon(log.level)} ${log.message}`
|
||||
).join('\n');
|
||||
|
||||
navigator.clipboard.writeText(logsText).then(() => {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}).catch(err => {
|
||||
}).catch((err) => {
|
||||
console.error('复制失败:', err);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -99,15 +99,15 @@ export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = (
|
||||
// 按类别分组(排除根节点类别)
|
||||
const categories = useMemo(() =>
|
||||
['all', ...new Set(allTemplates
|
||||
.filter(t => t.category !== '根节点')
|
||||
.map(t => t.category))]
|
||||
.filter((t) => t.category !== '根节点')
|
||||
.map((t) => t.category))]
|
||||
, [allTemplates]);
|
||||
|
||||
const filteredTemplates = useMemo(() =>
|
||||
(selectedCategory === 'all'
|
||||
? allTemplates
|
||||
: allTemplates.filter(t => t.category === selectedCategory))
|
||||
.filter(t => t.category !== '根节点')
|
||||
: allTemplates.filter((t) => t.category === selectedCategory))
|
||||
.filter((t) => t.category !== '根节点')
|
||||
, [allTemplates, selectedCategory]);
|
||||
|
||||
const handleNodeClick = (template: NodeTemplate) => {
|
||||
@@ -158,7 +158,7 @@ export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = (
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px'
|
||||
}}>
|
||||
{categories.map(category => (
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
@@ -245,22 +245,22 @@ export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = (
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TreePine, X, Settings, Clipboard, Save, FolderOpen, Maximize2, Minimize2, Download, FilePlus } from 'lucide-react';
|
||||
import { save, open, ask, message } from '@tauri-apps/plugin-dialog';
|
||||
import { open, ask, message } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeEditor } from './BehaviorTreeEditor';
|
||||
@@ -124,7 +124,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach(v => {
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
@@ -276,7 +276,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
|
||||
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach(v => {
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
@@ -309,7 +309,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
globalBlackboard.setValue(key, value, true);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach(v => {
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
@@ -321,7 +321,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
globalBlackboard.defineVariable(key, type, value);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach(v => {
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
@@ -333,7 +333,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
globalBlackboard.removeVariable(key);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach(v => {
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
@@ -352,7 +352,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
let saveFilePath = currentFilePath;
|
||||
const saveFilePath = currentFilePath;
|
||||
|
||||
// 如果没有当前文件路径,打开自定义保存对话框
|
||||
if (!saveFilePath) {
|
||||
@@ -788,7 +788,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="behavior-tree-toolbar-btn"
|
||||
title={isFullscreen ? "退出全屏" : "全屏"}
|
||||
title={isFullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
@@ -827,7 +827,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
const varName = node.data.variableName || '';
|
||||
const varValue = blackboardVariables[varName];
|
||||
const varType = typeof varValue === 'number' ? 'number' :
|
||||
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
||||
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
||||
|
||||
data = {
|
||||
...node.data,
|
||||
@@ -915,7 +915,7 @@ export const BehaviorTreeWindow: React.FC<BehaviorTreeWindowProps> = ({
|
||||
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === 'variableName') {
|
||||
const newVarValue = blackboardVariables[value];
|
||||
const newVarType = typeof newVarValue === 'number' ? 'number' :
|
||||
typeof newVarValue === 'boolean' ? 'boolean' : 'string';
|
||||
typeof newVarValue === 'boolean' ? 'boolean' : 'string';
|
||||
|
||||
updateNodes((nodes: any) => nodes.map((node: any) => {
|
||||
if (node.id === selectedNode.id) {
|
||||
|
||||
@@ -11,27 +11,27 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmText, cancelText, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button className="confirm-dialog-btn confirm" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button className="confirm-dialog-btn confirm" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
setLogs(logService.getLogs().slice(-MAX_LOGS));
|
||||
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs(prev => {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, entry];
|
||||
if (newLogs.length > MAX_LOGS) {
|
||||
return newLogs.slice(-MAX_LOGS);
|
||||
@@ -316,7 +316,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
}
|
||||
|
||||
// 清理不再需要的缓存(日志被删除)
|
||||
const logIds = new Set(logs.map(log => log.id));
|
||||
const logIds = new Set(logs.map((log) => log.id));
|
||||
for (const cachedId of cache.keys()) {
|
||||
if (!logIds.has(cachedId)) {
|
||||
cache.delete(cachedId);
|
||||
@@ -327,7 +327,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
}, [logs, extractJSON]);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter(log => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
if (showRemoteOnly && log.source !== 'remote') return false;
|
||||
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
|
||||
@@ -357,14 +357,14 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
};
|
||||
|
||||
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
|
||||
[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.filter((l) => l.source === 'remote').length
|
||||
, [logs]);
|
||||
|
||||
return (
|
||||
@@ -442,7 +442,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
<p>No logs to display</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map(log => (
|
||||
filteredLogs.map((log) => (
|
||||
<LogEntryItem
|
||||
key={log.id}
|
||||
log={log}
|
||||
|
||||
@@ -16,85 +16,85 @@ interface ContextMenuProps {
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = Math.max(0, viewportWidth - rect.width - 10);
|
||||
}
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = Math.max(0, viewportWidth - rect.width - 10);
|
||||
}
|
||||
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = Math.max(0, viewportHeight - rect.height - 10);
|
||||
}
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = Math.max(0, viewportHeight - rect.height - 10);
|
||||
}
|
||||
|
||||
if (x !== position.x || y !== position.y) {
|
||||
setAdjustedPosition({ x, y });
|
||||
}
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
style={{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y}px`
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
if (x !== position.x || y !== position.y) {
|
||||
setAdjustedPosition({ x, y });
|
||||
}
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
style={{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y}px`
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,24 +8,24 @@ interface ErrorDialogProps {
|
||||
}
|
||||
|
||||
export function ErrorDialog({ title, message, onClose }: ErrorDialogProps) {
|
||||
return (
|
||||
<div className="error-dialog-overlay" onClick={onClose}>
|
||||
<div className="error-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="error-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="error-dialog-footer">
|
||||
<button className="error-dialog-btn" onClick={onClose}>
|
||||
return (
|
||||
<div className="error-dialog-overlay" onClick={onClose}>
|
||||
<div className="error-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="error-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="error-dialog-footer">
|
||||
<button className="error-dialog-btn" onClick={onClose}>
|
||||
确定
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, FileJson, Binary, Info, File, FolderTree, FolderOpen, Code } from 'lucide-react';
|
||||
import { X, File, FolderTree, FolderOpen } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import '../styles/ExportRuntimeDialog.css';
|
||||
|
||||
@@ -66,7 +66,7 @@ export const ExportRuntimeDialog: React.FC<ExportRuntimeDialogProps> = ({
|
||||
setSelectAll(true);
|
||||
|
||||
const newFormats = new Map<string, 'json' | 'binary'>();
|
||||
availableFiles.forEach(file => {
|
||||
availableFiles.forEach((file) => {
|
||||
newFormats.set(file, 'binary');
|
||||
});
|
||||
setFileFormats(newFormats);
|
||||
|
||||
@@ -19,146 +19,146 @@ interface FileTreeProps {
|
||||
}
|
||||
|
||||
export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps) {
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (rootPath) {
|
||||
loadRootDirectory(rootPath);
|
||||
} else {
|
||||
setTree([]);
|
||||
}
|
||||
}, [rootPath]);
|
||||
|
||||
const loadRootDirectory = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const children = entriesToNodes(entries);
|
||||
|
||||
// 创建根节点
|
||||
const rootName = path.split(/[/\\]/).filter(p => p).pop() || 'Project';
|
||||
const rootNode: TreeNode = {
|
||||
name: rootName,
|
||||
path: path,
|
||||
type: 'folder',
|
||||
children: children,
|
||||
expanded: true,
|
||||
loaded: true
|
||||
};
|
||||
|
||||
setTree([rootNode]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error);
|
||||
setTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
|
||||
// 只显示文件夹,过滤掉文件
|
||||
return entries
|
||||
.filter(entry => entry.is_dir)
|
||||
.map(entry => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: 'folder' as const,
|
||||
children: [],
|
||||
expanded: false,
|
||||
loaded: false
|
||||
}));
|
||||
};
|
||||
|
||||
const loadChildren = async (node: TreeNode): Promise<TreeNode[]> => {
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(node.path);
|
||||
return entriesToNodes(entries);
|
||||
} catch (error) {
|
||||
console.error('Failed to load children:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNode = async (nodePath: string) => {
|
||||
const updateTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
const newNodes: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.path === nodePath) {
|
||||
if (!node.loaded) {
|
||||
const children = await loadChildren(node);
|
||||
newNodes.push({
|
||||
...node,
|
||||
expanded: true,
|
||||
loaded: true,
|
||||
children
|
||||
});
|
||||
} else {
|
||||
newNodes.push({
|
||||
...node,
|
||||
expanded: !node.expanded
|
||||
});
|
||||
}
|
||||
} else if (node.children) {
|
||||
newNodes.push({
|
||||
...node,
|
||||
children: await updateTree(node.children)
|
||||
});
|
||||
useEffect(() => {
|
||||
if (rootPath) {
|
||||
loadRootDirectory(rootPath);
|
||||
} else {
|
||||
newNodes.push(node);
|
||||
setTree([]);
|
||||
}
|
||||
}, [rootPath]);
|
||||
|
||||
const loadRootDirectory = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const children = entriesToNodes(entries);
|
||||
|
||||
// 创建根节点
|
||||
const rootName = path.split(/[/\\]/).filter((p) => p).pop() || 'Project';
|
||||
const rootNode: TreeNode = {
|
||||
name: rootName,
|
||||
path: path,
|
||||
type: 'folder',
|
||||
children: children,
|
||||
expanded: true,
|
||||
loaded: true
|
||||
};
|
||||
|
||||
setTree([rootNode]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error);
|
||||
setTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const newTree = await updateTree(tree);
|
||||
setTree(newTree);
|
||||
};
|
||||
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
|
||||
// 只显示文件夹,过滤掉文件
|
||||
return entries
|
||||
.filter((entry) => entry.is_dir)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: 'folder' as const,
|
||||
children: [],
|
||||
expanded: false,
|
||||
loaded: false
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: TreeNode) => {
|
||||
onSelectFile?.(node.path);
|
||||
toggleNode(node.path);
|
||||
};
|
||||
const loadChildren = async (node: TreeNode): Promise<TreeNode[]> => {
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(node.path);
|
||||
return entriesToNodes(entries);
|
||||
} catch (error) {
|
||||
console.error('Failed to load children:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const indent = level * 16;
|
||||
const toggleNode = async (nodePath: string) => {
|
||||
const updateTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
const newNodes: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.path === nodePath) {
|
||||
if (!node.loaded) {
|
||||
const children = await loadChildren(node);
|
||||
newNodes.push({
|
||||
...node,
|
||||
expanded: true,
|
||||
loaded: true,
|
||||
children
|
||||
});
|
||||
} else {
|
||||
newNodes.push({
|
||||
...node,
|
||||
expanded: !node.expanded
|
||||
});
|
||||
}
|
||||
} else if (node.children) {
|
||||
newNodes.push({
|
||||
...node,
|
||||
children: await updateTree(node.children)
|
||||
});
|
||||
} else {
|
||||
newNodes.push(node);
|
||||
}
|
||||
}
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const newTree = await updateTree(tree);
|
||||
setTree(newTree);
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: TreeNode) => {
|
||||
onSelectFile?.(node.path);
|
||||
toggleNode(node.path);
|
||||
};
|
||||
|
||||
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const indent = level * 16;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
>
|
||||
<span className="tree-arrow">
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<Folder size={16} />
|
||||
</span>
|
||||
<span className="tree-label">{node.name}</span>
|
||||
</div>
|
||||
{node.expanded && node.children && (
|
||||
<div className="tree-children">
|
||||
{node.children.map((child) => renderNode(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="file-tree loading">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!rootPath || tree.length === 0) {
|
||||
return <div className="file-tree empty">No folders</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
>
|
||||
<span className="tree-arrow">
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<Folder size={16} />
|
||||
</span>
|
||||
<span className="tree-label">{node.name}</span>
|
||||
<div className="file-tree">
|
||||
{tree.map((node) => renderNode(node))}
|
||||
</div>
|
||||
{node.expanded && node.children && (
|
||||
<div className="tree-children">
|
||||
{node.children.map(child => renderNode(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="file-tree loading">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!rootPath || tree.length === 0) {
|
||||
return <div className="file-tree empty">No folders</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{tree.map(node => renderNode(node))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, ReactNode, useMemo } from 'react';
|
||||
import { useCallback, ReactNode, useMemo } from 'react';
|
||||
import { Layout, Model, TabNode, IJsonModel, Actions, IJsonTabSetNode, IJsonRowNode } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
@@ -16,146 +16,146 @@ interface FlexLayoutDockContainerProps {
|
||||
}
|
||||
|
||||
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
|
||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||
const leftPanels = panels.filter(p => p.id.includes('hierarchy'));
|
||||
const rightPanels = panels.filter(p => p.id.includes('inspector'));
|
||||
const bottomPanels = panels.filter(p => p.id.includes('console') || p.id.includes('asset'))
|
||||
.sort((a, b) => {
|
||||
// 控制台排在前面
|
||||
if (a.id.includes('console')) return -1;
|
||||
if (b.id.includes('console')) return 1;
|
||||
return 0;
|
||||
});
|
||||
const centerPanels = panels.filter(p =>
|
||||
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
|
||||
);
|
||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||
const leftPanels = panels.filter((p) => p.id.includes('hierarchy'));
|
||||
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
|
||||
const bottomPanels = panels.filter((p) => p.id.includes('console') || p.id.includes('asset'))
|
||||
.sort((a, b) => {
|
||||
// 控制台排在前面
|
||||
if (a.id.includes('console')) return -1;
|
||||
if (b.id.includes('console')) return 1;
|
||||
return 0;
|
||||
});
|
||||
const centerPanels = panels.filter((p) =>
|
||||
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
|
||||
);
|
||||
|
||||
// Build center column children
|
||||
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
if (centerPanels.length > 0) {
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 70,
|
||||
children: centerPanels.map(p => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (bottomPanels.length > 0) {
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 30,
|
||||
children: bottomPanels.map(p => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Build main row children
|
||||
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
if (leftPanels.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 20,
|
||||
children: leftPanels.map(p => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (centerColumnChildren.length > 0) {
|
||||
if (centerColumnChildren.length === 1) {
|
||||
const centerChild = centerColumnChildren[0];
|
||||
if (centerChild && centerChild.type === 'tabset') {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 60,
|
||||
children: centerChild.children
|
||||
} as IJsonTabSetNode);
|
||||
} else if (centerChild) {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerChild.children
|
||||
} as IJsonRowNode);
|
||||
// Build center column children
|
||||
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
if (centerPanels.length > 0) {
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 70,
|
||||
children: centerPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (bottomPanels.length > 0) {
|
||||
centerColumnChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 30,
|
||||
children: bottomPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
} else {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerColumnChildren,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rightPanels.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 20,
|
||||
children: rightPanels.map(p => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
global: {
|
||||
tabEnableClose: true,
|
||||
tabEnableRename: false,
|
||||
tabSetEnableMaximize: false,
|
||||
tabSetEnableDrop: true,
|
||||
tabSetEnableDrag: true,
|
||||
tabSetEnableDivide: true,
|
||||
borderEnableDrop: true,
|
||||
},
|
||||
borders: [],
|
||||
layout: {
|
||||
type: 'row',
|
||||
weight: 100,
|
||||
children: mainRowChildren,
|
||||
},
|
||||
};
|
||||
}, [panels]);
|
||||
// Build main row children
|
||||
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||
if (leftPanels.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 20,
|
||||
children: leftPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (centerColumnChildren.length > 0) {
|
||||
if (centerColumnChildren.length === 1) {
|
||||
const centerChild = centerColumnChildren[0];
|
||||
if (centerChild && centerChild.type === 'tabset') {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 60,
|
||||
children: centerChild.children
|
||||
} as IJsonTabSetNode);
|
||||
} else if (centerChild) {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerChild.children
|
||||
} as IJsonRowNode);
|
||||
}
|
||||
} else {
|
||||
mainRowChildren.push({
|
||||
type: 'row',
|
||||
weight: 60,
|
||||
children: centerColumnChildren
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rightPanels.length > 0) {
|
||||
mainRowChildren.push({
|
||||
type: 'tabset',
|
||||
weight: 20,
|
||||
children: rightPanels.map((p) => ({
|
||||
type: 'tab',
|
||||
name: p.title,
|
||||
id: p.id,
|
||||
component: p.id,
|
||||
enableClose: p.closable !== false
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
const model = useMemo(() => Model.fromJson(createDefaultLayout()), [createDefaultLayout]);
|
||||
return {
|
||||
global: {
|
||||
tabEnableClose: true,
|
||||
tabEnableRename: false,
|
||||
tabSetEnableMaximize: false,
|
||||
tabSetEnableDrop: true,
|
||||
tabSetEnableDrag: true,
|
||||
tabSetEnableDivide: true,
|
||||
borderEnableDrop: true
|
||||
},
|
||||
borders: [],
|
||||
layout: {
|
||||
type: 'row',
|
||||
weight: 100,
|
||||
children: mainRowChildren
|
||||
}
|
||||
};
|
||||
}, [panels]);
|
||||
|
||||
const factory = useCallback((node: TabNode) => {
|
||||
const component = node.getComponent();
|
||||
const panel = panels.find(p => p.id === component);
|
||||
return panel?.content || <div>Panel not found</div>;
|
||||
}, [panels]);
|
||||
const model = useMemo(() => Model.fromJson(createDefaultLayout()), [createDefaultLayout]);
|
||||
|
||||
const onAction = useCallback((action: any) => {
|
||||
if (action.type === Actions.DELETE_TAB) {
|
||||
const tabId = action.data.node;
|
||||
if (onPanelClose) {
|
||||
onPanelClose(tabId);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}, [onPanelClose]);
|
||||
const factory = useCallback((node: TabNode) => {
|
||||
const component = node.getComponent();
|
||||
const panel = panels.find((p) => p.id === component);
|
||||
return panel?.content || <div>Panel not found</div>;
|
||||
}, [panels]);
|
||||
|
||||
return (
|
||||
<div className="flexlayout-dock-container">
|
||||
<Layout
|
||||
model={model}
|
||||
factory={factory}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const onAction = useCallback((action: any) => {
|
||||
if (action.type === Actions.DELETE_TAB) {
|
||||
const tabId = action.data.node;
|
||||
if (onPanelClose) {
|
||||
onPanelClose(tabId);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}, [onPanelClose]);
|
||||
|
||||
return (
|
||||
<div className="flexlayout-dock-container">
|
||||
<Layout
|
||||
model={model}
|
||||
factory={factory}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,274 +36,274 @@ interface MenuBarProps {
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
locale = 'en',
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
onSaveSceneAs,
|
||||
onOpenProject,
|
||||
onCloseProject,
|
||||
onExit,
|
||||
onOpenPluginManager,
|
||||
onOpenProfiler,
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin
|
||||
locale = 'en',
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
onSaveSceneAs,
|
||||
onOpenProject,
|
||||
onCloseProject,
|
||||
onExit,
|
||||
onOpenPluginManager,
|
||||
onOpenProfiler: _onOpenProfiler,
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry && pluginManager) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
// 过滤掉被禁用插件的菜单项
|
||||
const enabledPlugins = pluginManager.getAllPluginMetadata()
|
||||
.filter(p => p.enabled)
|
||||
.map(p => p.name);
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry && pluginManager) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
// 过滤掉被禁用插件的菜单项
|
||||
const enabledPlugins = pluginManager.getAllPluginMetadata()
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => p.name);
|
||||
|
||||
// 只显示启用插件的菜单项
|
||||
const filteredItems = items.filter(item => {
|
||||
// 检查菜单项是否属于某个插件
|
||||
return enabledPlugins.some(pluginName => {
|
||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||
if (plugin && plugin.registerMenuItems) {
|
||||
const pluginMenus = plugin.registerMenuItems();
|
||||
return pluginMenus.some(m => m.id === item.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
// 只显示启用插件的菜单项
|
||||
const filteredItems = items.filter((item) => {
|
||||
// 检查菜单项是否属于某个插件
|
||||
return enabledPlugins.some((pluginName) => {
|
||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||
if (plugin && plugin.registerMenuItems) {
|
||||
const pluginMenus = plugin.registerMenuItems();
|
||||
return pluginMenus.some((m) => m.id === item.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
setPluginMenuItems(filteredItems);
|
||||
console.log('[MenuBar] Updated menu items:', filteredItems);
|
||||
} else if (uiRegistry) {
|
||||
// 如果没有 pluginManager,显示所有菜单项
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
console.log('[MenuBar] Updated menu items (no filter):', items);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateMenuItems();
|
||||
}, [uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
console.log('[MenuBar] Plugin installed, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
console.log('[MenuBar] Plugin enabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
console.log('[MenuBar] Plugin disabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeInstalled();
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub, uiRegistry, pluginManager]);
|
||||
|
||||
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',
|
||||
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: '创建插件',
|
||||
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 },
|
||||
{ separator: true },
|
||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
{ label: t('settings'), onClick: onOpenSettings }
|
||||
],
|
||||
help: [
|
||||
{ label: t('documentation'), disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('checkForUpdates'), onClick: onOpenAbout },
|
||||
{ label: t('about'), onClick: onOpenAbout }
|
||||
]
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
setPluginMenuItems(filteredItems);
|
||||
console.log('[MenuBar] Updated menu items:', filteredItems);
|
||||
} else if (uiRegistry) {
|
||||
// 如果没有 pluginManager,显示所有菜单项
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
console.log('[MenuBar] Updated menu items (no filter):', items);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
useEffect(() => {
|
||||
updateMenuItems();
|
||||
}, [uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
console.log('[MenuBar] Plugin installed, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
console.log('[MenuBar] Plugin enabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
console.log('[MenuBar] Plugin disabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeInstalled();
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub, uiRegistry, pluginManager]);
|
||||
|
||||
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',
|
||||
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: '创建插件',
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (menuKey: string) => {
|
||||
setOpenMenu(openMenu === menuKey ? null : menuKey);
|
||||
};
|
||||
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 },
|
||||
{ separator: true },
|
||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
{ label: t('settings'), onClick: onOpenSettings }
|
||||
],
|
||||
help: [
|
||||
{ label: t('documentation'), disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('checkForUpdates'), onClick: onOpenAbout },
|
||||
{ label: t('about'), onClick: onOpenAbout }
|
||||
]
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (!item.disabled && !item.separator && item.onClick && item.label) {
|
||||
item.onClick();
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="menu-bar" ref={menuRef}>
|
||||
{Object.keys(menus).map(menuKey => (
|
||||
<div key={menuKey} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${openMenu === menuKey ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(menuKey)}
|
||||
>
|
||||
{t(menuKey)}
|
||||
</button>
|
||||
{openMenu === menuKey && menus[menuKey] && (
|
||||
<div className="menu-dropdown">
|
||||
{menus[menuKey].map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="menu-separator" />;
|
||||
}
|
||||
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`menu-dropdown-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="menu-item-content">
|
||||
{IconComponent && <IconComponent size={16} />}
|
||||
<span>{item.label || ''}</span>
|
||||
</span>
|
||||
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="menu-bar" ref={menuRef}>
|
||||
{Object.keys(menus).map((menuKey) => (
|
||||
<div key={menuKey} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${openMenu === menuKey ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(menuKey)}
|
||||
>
|
||||
{t(menuKey)}
|
||||
</button>
|
||||
{openMenu === menuKey && menus[menuKey] && (
|
||||
<div className="menu-dropdown">
|
||||
{menus[menuKey].map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="menu-separator" />;
|
||||
}
|
||||
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`menu-dropdown-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="menu-item-content">
|
||||
{IconComponent && <IconComponent size={16} />}
|
||||
<span>{item.label || ''}</span>
|
||||
</span>
|
||||
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function PluginGeneratorWindow({ onClose, projectPath, locale, onSuccess
|
||||
const response = await fetch('/@plugin-generator', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pluginName,
|
||||
@@ -149,7 +149,7 @@ export function PluginGeneratorWindow({ onClose, projectPath, locale, onSuccess
|
||||
<input
|
||||
type="text"
|
||||
value={pluginName}
|
||||
onChange={e => setPluginName(e.target.value)}
|
||||
onChange={(e) => setPluginName(e.target.value)}
|
||||
placeholder={t('pluginNamePlaceholder')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
@@ -160,7 +160,7 @@ export function PluginGeneratorWindow({ onClose, projectPath, locale, onSuccess
|
||||
<input
|
||||
type="text"
|
||||
value={pluginVersion}
|
||||
onChange={e => setPluginVersion(e.target.value)}
|
||||
onChange={(e) => setPluginVersion(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@ export function PluginGeneratorWindow({ onClose, projectPath, locale, onSuccess
|
||||
<input
|
||||
type="text"
|
||||
value={outputPath}
|
||||
onChange={e => setOutputPath(e.target.value)}
|
||||
onChange={(e) => setOutputPath(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<button
|
||||
@@ -190,7 +190,7 @@ export function PluginGeneratorWindow({ onClose, projectPath, locale, onSuccess
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeExample}
|
||||
onChange={e => setIncludeExample(e.target.checked)}
|
||||
onChange={(e) => setIncludeExample(e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<span>{t('includeExample')}</span>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
const filteredPlugins = plugins.filter((plugin) => {
|
||||
if (!filter) return true;
|
||||
const searchLower = filter.toLowerCase();
|
||||
return (
|
||||
@@ -162,8 +162,8 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
|
||||
return acc;
|
||||
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
|
||||
|
||||
const enabledCount = plugins.filter(p => p.enabled).length;
|
||||
const disabledCount = plugins.filter(p => !p.enabled).length;
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const disabledCount = plugins.filter((p) => !p.enabled).length;
|
||||
|
||||
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
|
||||
@@ -65,7 +65,7 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
const filteredPlugins = plugins.filter((plugin) => {
|
||||
if (!filter) return true;
|
||||
const searchLower = filter.toLowerCase();
|
||||
return (
|
||||
@@ -83,81 +83,81 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
|
||||
return acc;
|
||||
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
|
||||
|
||||
const enabledCount = plugins.filter(p => p.enabled).length;
|
||||
const disabledCount = plugins.filter(p => !p.enabled).length;
|
||||
const enabledCount = plugins.filter((p) => p.enabled).length;
|
||||
const disabledCount = plugins.filter((p) => !p.enabled).length;
|
||||
|
||||
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-card-header">
|
||||
<div className="plugin-card-icon">
|
||||
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
|
||||
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-card-header">
|
||||
<div className="plugin-card-icon">
|
||||
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
|
||||
</div>
|
||||
<div className="plugin-card-info">
|
||||
<div className="plugin-card-title">{plugin.displayName}</div>
|
||||
<div className="plugin-card-version">v{plugin.version}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="plugin-card-info">
|
||||
<div className="plugin-card-title">{plugin.displayName}</div>
|
||||
<div className="plugin-card-version">v{plugin.version}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-card-description">{plugin.description}</div>
|
||||
)}
|
||||
<div className="plugin-card-footer">
|
||||
<span className="plugin-card-category">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
|
||||
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
|
||||
})()}
|
||||
{categoryNames[plugin.category]}
|
||||
</span>
|
||||
{plugin.installedAt && (
|
||||
<span className="plugin-card-installed">
|
||||
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{plugin.description && (
|
||||
<div className="plugin-card-description">{plugin.description}</div>
|
||||
)}
|
||||
<div className="plugin-card-footer">
|
||||
<span className="plugin-card-category">
|
||||
{(() => {
|
||||
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
|
||||
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
|
||||
})()}
|
||||
{categoryNames[plugin.category]}
|
||||
</span>
|
||||
{plugin.installedAt && (
|
||||
<span className="plugin-card-installed">
|
||||
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginList = (plugin: IEditorPluginMetadata) => {
|
||||
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
|
||||
return (
|
||||
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-list-icon">
|
||||
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
|
||||
</div>
|
||||
<div className="plugin-list-info">
|
||||
<div className="plugin-list-name">
|
||||
{plugin.displayName}
|
||||
<span className="plugin-list-version">v{plugin.version}</span>
|
||||
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
|
||||
<div className="plugin-list-icon">
|
||||
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-list-description">{plugin.description}</div>
|
||||
)}
|
||||
<div className="plugin-list-info">
|
||||
<div className="plugin-list-name">
|
||||
{plugin.displayName}
|
||||
<span className="plugin-list-version">v{plugin.version}</span>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<div className="plugin-list-description">{plugin.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-list-status">
|
||||
{plugin.enabled ? (
|
||||
<span className="status-badge enabled">Enabled</span>
|
||||
) : (
|
||||
<span className="status-badge disabled">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="plugin-list-toggle"
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="plugin-list-status">
|
||||
{plugin.enabled ? (
|
||||
<span className="status-badge enabled">Enabled</span>
|
||||
) : (
|
||||
<span className="status-badge disabled">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="plugin-list-toggle"
|
||||
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
>
|
||||
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,152 +10,152 @@ interface PortManagerProps {
|
||||
}
|
||||
|
||||
export function PortManager({ onClose }: PortManagerProps) {
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [serverPort, setServerPort] = useState<string>('8080');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [serverPort, setServerPort] = useState<string>('8080');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setServerPort(settings.get('profiler.port', '8080'));
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setServerPort(settings.get('profiler.port', '8080'));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setServerPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setServerPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkServerStatus();
|
||||
}, []);
|
||||
|
||||
const checkServerStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await invoke<boolean>('get_profiler_status');
|
||||
setIsServerRunning(status);
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to check server status:', error);
|
||||
setIsServerRunning(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkServerStatus();
|
||||
}, []);
|
||||
const handleStopServer = async () => {
|
||||
setIsStopping(true);
|
||||
try {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
await profilerService.manualStopServer();
|
||||
setIsServerRunning(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to stop server:', error);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkServerStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await invoke<boolean>('get_profiler_status');
|
||||
setIsServerRunning(status);
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to check server status:', error);
|
||||
setIsServerRunning(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
const handleStartServer = async () => {
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
await profilerService.manualStartServer();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await checkServerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to start server:', error);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopServer = async () => {
|
||||
setIsStopping(true);
|
||||
try {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
await profilerService.manualStopServer();
|
||||
setIsServerRunning(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to stop server:', error);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartServer = async () => {
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
await profilerService.manualStartServer();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await checkServerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to start server:', error);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="port-manager-overlay" onClick={onClose}>
|
||||
<div className="port-manager" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="port-manager-header">
|
||||
<div className="port-manager-title">
|
||||
<Server size={20} />
|
||||
<h2>Port Manager</h2>
|
||||
</div>
|
||||
<button className="port-manager-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="port-manager-content">
|
||||
<div className="port-section">
|
||||
<h3>Profiler Server</h3>
|
||||
<div className="port-info">
|
||||
<div className="port-item">
|
||||
<span className="port-label">Status:</span>
|
||||
<span className={`port-status ${isServerRunning ? 'running' : 'stopped'}`}>
|
||||
{isChecking ? 'Checking...' : isServerRunning ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
{isServerRunning && (
|
||||
<div className="port-item">
|
||||
<span className="port-label">Port:</span>
|
||||
<span className="port-value">{serverPort}</span>
|
||||
return (
|
||||
<div className="port-manager-overlay" onClick={onClose}>
|
||||
<div className="port-manager" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="port-manager-header">
|
||||
<div className="port-manager-title">
|
||||
<Server size={20} />
|
||||
<h2>Port Manager</h2>
|
||||
</div>
|
||||
<button className="port-manager-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="port-manager-content">
|
||||
<div className="port-section">
|
||||
<h3>Profiler Server</h3>
|
||||
<div className="port-info">
|
||||
<div className="port-item">
|
||||
<span className="port-label">Status:</span>
|
||||
<span className={`port-status ${isServerRunning ? 'running' : 'stopped'}`}>
|
||||
{isChecking ? 'Checking...' : isServerRunning ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
{isServerRunning && (
|
||||
<div className="port-item">
|
||||
<span className="port-label">Port:</span>
|
||||
<span className="port-value">{serverPort}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isServerRunning && (
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn danger"
|
||||
onClick={handleStopServer}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<WifiOff size={16} />
|
||||
<span>{isStopping ? 'Stopping...' : 'Stop Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isServerRunning && (
|
||||
<>
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn primary"
|
||||
onClick={handleStartServer}
|
||||
disabled={isStarting}
|
||||
>
|
||||
<Wifi size={16} />
|
||||
<span>{isStarting ? 'Starting...' : 'Start Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="port-hint">
|
||||
<p>No server is currently running.</p>
|
||||
<p className="hint-text">Click "Start Server" to start the profiler server.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="port-tips">
|
||||
<h4>Tips</h4>
|
||||
<ul>
|
||||
<li>Use this when the Profiler server port is stuck and cannot be restarted</li>
|
||||
<li>The server will automatically stop when the Profiler window is closed</li>
|
||||
<li>Current configured port: {serverPort}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isServerRunning && (
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn danger"
|
||||
onClick={handleStopServer}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<WifiOff size={16} />
|
||||
<span>{isStopping ? 'Stopping...' : 'Stop Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isServerRunning && (
|
||||
<>
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn primary"
|
||||
onClick={handleStartServer}
|
||||
disabled={isStarting}
|
||||
>
|
||||
<Wifi size={16} />
|
||||
<span>{isStarting ? 'Starting...' : 'Start Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="port-hint">
|
||||
<p>No server is currently running.</p>
|
||||
<p className="hint-text">Click "Start Server" to start the profiler server.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="port-tips">
|
||||
<h4>Tips</h4>
|
||||
<ul>
|
||||
<li>Use this when the Profiler server port is stuck and cannot be restarted</li>
|
||||
<li>The server will automatically stop when the Profiler window is closed</li>
|
||||
<li>Current configured port: {serverPort}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,220 +7,220 @@ import { MessageHub } from '@esengine/editor-core';
|
||||
import '../styles/ProfilerDockPanel.css';
|
||||
|
||||
export function ProfilerDockPanel() {
|
||||
const [profilerData, setProfilerData] = useState<ProfilerData | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [profilerData, setProfilerData] = useState<ProfilerData | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setPort(settings.get('profiler.port', '8080'));
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setPort(settings.get('profiler.port', '8080'));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
useEffect(() => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
|
||||
if (!profilerService) {
|
||||
console.warn('[ProfilerDockPanel] ProfilerService not available - plugin may be disabled');
|
||||
setIsServerRunning(false);
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
if (!profilerService) {
|
||||
console.warn('[ProfilerDockPanel] ProfilerService not available - plugin may be disabled');
|
||||
setIsServerRunning(false);
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 订阅数据更新
|
||||
const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
|
||||
if (!isPaused) {
|
||||
setProfilerData(data);
|
||||
}
|
||||
});
|
||||
// 订阅数据更新
|
||||
const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
|
||||
if (!isPaused) {
|
||||
setProfilerData(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 定期检查连接状态
|
||||
const checkStatus = () => {
|
||||
setIsConnected(profilerService.isConnected());
|
||||
setIsServerRunning(profilerService.isServerActive());
|
||||
// 定期检查连接状态
|
||||
const checkStatus = () => {
|
||||
setIsConnected(profilerService.isConnected());
|
||||
setIsServerRunning(profilerService.isServerActive());
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 1000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const fps = profilerData?.fps || 0;
|
||||
const totalFrameTime = profilerData?.totalFrameTime || 0;
|
||||
const systems = (profilerData?.systems || []).slice(0, 5); // Only show top 5 systems in dock panel
|
||||
const entityCount = profilerData?.entityCount || 0;
|
||||
const componentCount = profilerData?.componentCount || 0;
|
||||
const targetFrameTime = 16.67;
|
||||
|
||||
const handleOpenDetails = () => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 1000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(interval);
|
||||
const handleTogglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const fps = profilerData?.fps || 0;
|
||||
const totalFrameTime = profilerData?.totalFrameTime || 0;
|
||||
const systems = (profilerData?.systems || []).slice(0, 5); // Only show top 5 systems in dock panel
|
||||
const entityCount = profilerData?.entityCount || 0;
|
||||
const componentCount = profilerData?.componentCount || 0;
|
||||
const targetFrameTime = 16.67;
|
||||
|
||||
const handleOpenDetails = () => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profiler-dock-panel">
|
||||
<div className="profiler-dock-header">
|
||||
<h3>Performance Monitor</h3>
|
||||
<div className="profiler-dock-header-actions">
|
||||
{isConnected && (
|
||||
<>
|
||||
<button
|
||||
className="profiler-dock-pause-btn"
|
||||
onClick={handleTogglePause}
|
||||
title={isPaused ? 'Resume data updates' : 'Pause data updates'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenDetails}
|
||||
title="Open detailed profiler"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="profiler-dock-status">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
<span className="status-text connected">Connected</span>
|
||||
</>
|
||||
) : isServerRunning ? (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text waiting">Waiting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text disconnected">Server Off</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isServerRunning ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Cpu size={32} />
|
||||
<p>Profiler server not running</p>
|
||||
<p className="hint">Open Profiler window and connect to start monitoring</p>
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Activity size={32} />
|
||||
<p>Waiting for game connection...</p>
|
||||
<p className="hint">Connect to: <code>ws://localhost:{port}</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-dock-content">
|
||||
<div className="profiler-dock-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">FPS</div>
|
||||
<div className={`stat-value ${fps < 55 ? 'warning' : ''}`}>{fps}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Cpu size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Frame Time</div>
|
||||
<div className={`stat-value ${totalFrameTime > targetFrameTime ? 'warning' : ''}`}>
|
||||
{totalFrameTime.toFixed(1)}ms
|
||||
return (
|
||||
<div className="profiler-dock-panel">
|
||||
<div className="profiler-dock-header">
|
||||
<h3>Performance Monitor</h3>
|
||||
<div className="profiler-dock-header-actions">
|
||||
{isConnected && (
|
||||
<>
|
||||
<button
|
||||
className="profiler-dock-pause-btn"
|
||||
onClick={handleTogglePause}
|
||||
title={isPaused ? 'Resume data updates' : 'Pause data updates'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenDetails}
|
||||
title="Open detailed profiler"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="profiler-dock-status">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
<span className="status-text connected">Connected</span>
|
||||
</>
|
||||
) : isServerRunning ? (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text waiting">Waiting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text disconnected">Server Off</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Entities</div>
|
||||
<div className="stat-value">{entityCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isServerRunning ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Cpu size={32} />
|
||||
<p>Profiler server not running</p>
|
||||
<p className="hint">Open Profiler window and connect to start monitoring</p>
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Activity size={32} />
|
||||
<p>Waiting for game connection...</p>
|
||||
<p className="hint">Connect to: <code>ws://localhost:{port}</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-dock-content">
|
||||
<div className="profiler-dock-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">FPS</div>
|
||||
<div className={`stat-value ${fps < 55 ? 'warning' : ''}`}>{fps}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Package size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Components</div>
|
||||
<div className="stat-value">{componentCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Cpu size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Frame Time</div>
|
||||
<div className={`stat-value ${totalFrameTime > targetFrameTime ? 'warning' : ''}`}>
|
||||
{totalFrameTime.toFixed(1)}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{systems.length > 0 && (
|
||||
<div className="profiler-dock-systems">
|
||||
<h4>Top Systems</h4>
|
||||
<div className="systems-list">
|
||||
{systems.map((system) => (
|
||||
<div key={system.name} className="system-item">
|
||||
<div className="system-item-header">
|
||||
<span className="system-item-name">{system.name}</span>
|
||||
<span className="system-item-time">
|
||||
{system.executionTime.toFixed(2)}ms
|
||||
</span>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Entities</div>
|
||||
<div className="stat-value">{entityCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Package size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Components</div>
|
||||
<div className="stat-value">{componentCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-item-bar">
|
||||
<div
|
||||
className="system-item-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-item-footer">
|
||||
<span className="system-item-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-item-entities">{system.entityCount} entities</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{systems.length > 0 && (
|
||||
<div className="profiler-dock-systems">
|
||||
<h4>Top Systems</h4>
|
||||
<div className="systems-list">
|
||||
{systems.map((system) => (
|
||||
<div key={system.name} className="system-item">
|
||||
<div className="system-item-header">
|
||||
<span className="system-item-name">{system.name}</span>
|
||||
<span className="system-item-time">
|
||||
{system.executionTime.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-item-bar">
|
||||
<div
|
||||
className="system-item-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-item-footer">
|
||||
<span className="system-item-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-item-entities">{system.entityCount} entities</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,221 +14,221 @@ interface SystemPerformanceData {
|
||||
}
|
||||
|
||||
export function ProfilerPanel() {
|
||||
const [systems, setSystems] = useState<SystemPerformanceData[]>([]);
|
||||
const [totalFrameTime, setTotalFrameTime] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<'time' | 'average' | 'name'>('time');
|
||||
const animationRef = useRef<number>();
|
||||
const [systems, setSystems] = useState<SystemPerformanceData[]>([]);
|
||||
const [totalFrameTime, setTotalFrameTime] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<'time' | 'average' | 'name'>('time');
|
||||
const animationRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const updateProfilerData = () => {
|
||||
if (isPaused) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
|
||||
const coreInstance = Core.Instance;
|
||||
if (!coreInstance || !coreInstance._performanceMonitor?.isEnabled) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
|
||||
const performanceMonitor = coreInstance._performanceMonitor;
|
||||
const systemDataMap = performanceMonitor.getAllSystemData();
|
||||
const systemStatsMap = performanceMonitor.getAllSystemStats();
|
||||
|
||||
const systemsData: SystemPerformanceData[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const [name, data] of systemDataMap.entries()) {
|
||||
const stats = systemStatsMap.get(name);
|
||||
if (stats) {
|
||||
systemsData.push({
|
||||
name,
|
||||
executionTime: data.executionTime,
|
||||
entityCount: data.entityCount,
|
||||
averageTime: stats.averageTime,
|
||||
minTime: stats.minTime,
|
||||
maxTime: stats.maxTime,
|
||||
percentage: 0
|
||||
});
|
||||
total += data.executionTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
systemsData.forEach((system) => {
|
||||
system.percentage = total > 0 ? (system.executionTime / total) * 100 : 0;
|
||||
});
|
||||
|
||||
// Sort systems
|
||||
systemsData.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'time':
|
||||
return b.executionTime - a.executionTime;
|
||||
case 'average':
|
||||
return b.averageTime - a.averageTime;
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
setSystems(systemsData);
|
||||
setTotalFrameTime(total);
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateProfilerData = () => {
|
||||
if (isPaused) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
|
||||
const coreInstance = Core.Instance;
|
||||
if (!coreInstance || !coreInstance._performanceMonitor?.isEnabled) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPaused, sortBy]);
|
||||
|
||||
const performanceMonitor = coreInstance._performanceMonitor;
|
||||
const systemDataMap = performanceMonitor.getAllSystemData();
|
||||
const systemStatsMap = performanceMonitor.getAllSystemStats();
|
||||
|
||||
const systemsData: SystemPerformanceData[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const [name, data] of systemDataMap.entries()) {
|
||||
const stats = systemStatsMap.get(name);
|
||||
if (stats) {
|
||||
systemsData.push({
|
||||
name,
|
||||
executionTime: data.executionTime,
|
||||
entityCount: data.entityCount,
|
||||
averageTime: stats.averageTime,
|
||||
minTime: stats.minTime,
|
||||
maxTime: stats.maxTime,
|
||||
percentage: 0
|
||||
});
|
||||
total += data.executionTime;
|
||||
const handleReset = () => {
|
||||
const coreInstance = Core.Instance;
|
||||
if (coreInstance && coreInstance._performanceMonitor) {
|
||||
coreInstance._performanceMonitor.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
systemsData.forEach(system => {
|
||||
system.percentage = total > 0 ? (system.executionTime / total) * 100 : 0;
|
||||
});
|
||||
|
||||
// Sort systems
|
||||
systemsData.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'time':
|
||||
return b.executionTime - a.executionTime;
|
||||
case 'average':
|
||||
return b.averageTime - a.averageTime;
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
setSystems(systemsData);
|
||||
setTotalFrameTime(total);
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
const targetFrameTime = 16.67; // 60 FPS
|
||||
const isOverBudget = totalFrameTime > targetFrameTime;
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPaused, sortBy]);
|
||||
|
||||
const handleReset = () => {
|
||||
const coreInstance = Core.Instance;
|
||||
if (coreInstance && coreInstance._performanceMonitor) {
|
||||
coreInstance._performanceMonitor.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
const targetFrameTime = 16.67; // 60 FPS
|
||||
const isOverBudget = totalFrameTime > targetFrameTime;
|
||||
|
||||
return (
|
||||
<div className="profiler-panel">
|
||||
<div className="profiler-toolbar">
|
||||
<div className="profiler-toolbar-left">
|
||||
<div className="profiler-stats-summary">
|
||||
<div className="summary-item">
|
||||
<Clock size={14} />
|
||||
<span className="summary-label">Frame:</span>
|
||||
<span className={`summary-value ${isOverBudget ? 'over-budget' : ''}`}>
|
||||
{totalFrameTime.toFixed(2)}ms
|
||||
</span>
|
||||
return (
|
||||
<div className="profiler-panel">
|
||||
<div className="profiler-toolbar">
|
||||
<div className="profiler-toolbar-left">
|
||||
<div className="profiler-stats-summary">
|
||||
<div className="summary-item">
|
||||
<Clock size={14} />
|
||||
<span className="summary-label">Frame:</span>
|
||||
<span className={`summary-value ${isOverBudget ? 'over-budget' : ''}`}>
|
||||
{totalFrameTime.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Activity size={14} />
|
||||
<span className="summary-label">FPS:</span>
|
||||
<span className={`summary-value ${fps < 55 ? 'low-fps' : ''}`}>{fps}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<BarChart3 size={14} />
|
||||
<span className="summary-label">Systems:</span>
|
||||
<span className="summary-value">{systems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-toolbar-right">
|
||||
<select
|
||||
className="profiler-sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="time">Sort by Time</option>
|
||||
<option value="average">Sort by Average</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
</select>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={handleReset}
|
||||
title="Reset Statistics"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Activity size={14} />
|
||||
<span className="summary-label">FPS:</span>
|
||||
<span className={`summary-value ${fps < 55 ? 'low-fps' : ''}`}>{fps}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<BarChart3 size={14} />
|
||||
<span className="summary-label">Systems:</span>
|
||||
<span className="summary-value">{systems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-toolbar-right">
|
||||
<select
|
||||
className="profiler-sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="time">Sort by Time</option>
|
||||
<option value="average">Sort by Average</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
</select>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={handleReset}
|
||||
title="Reset Statistics"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-content">
|
||||
{systems.length === 0 ? (
|
||||
<div className="profiler-empty">
|
||||
<Cpu size={48} />
|
||||
<p>No performance data available</p>
|
||||
<p className="profiler-empty-hint">
|
||||
<div className="profiler-content">
|
||||
{systems.length === 0 ? (
|
||||
<div className="profiler-empty">
|
||||
<Cpu size={48} />
|
||||
<p>No performance data available</p>
|
||||
<p className="profiler-empty-hint">
|
||||
Make sure Core debug mode is enabled and systems are running
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-systems">
|
||||
{systems.map((system, index) => (
|
||||
<div key={system.name} className="system-row">
|
||||
<div className="system-header">
|
||||
<div className="system-info">
|
||||
<span className="system-rank">#{index + 1}</span>
|
||||
<span className="system-name">{system.name}</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-entities">
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-systems">
|
||||
{systems.map((system, index) => (
|
||||
<div key={system.name} className="system-row">
|
||||
<div className="system-header">
|
||||
<div className="system-info">
|
||||
<span className="system-rank">#{index + 1}</span>
|
||||
<span className="system-name">{system.name}</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-entities">
|
||||
({system.entityCount} entities)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-metrics">
|
||||
<span className="metric-time">{system.executionTime.toFixed(2)}ms</span>
|
||||
<span className="metric-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-bar">
|
||||
<div
|
||||
className="system-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Avg:</span>
|
||||
<span className="stat-value">{system.averageTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Min:</span>
|
||||
<span className="stat-value">{system.minTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Max:</span>
|
||||
<span className="stat-value">{system.maxTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-metrics">
|
||||
<span className="metric-time">{system.executionTime.toFixed(2)}ms</span>
|
||||
<span className="metric-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-bar">
|
||||
<div
|
||||
className="system-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Avg:</span>
|
||||
<span className="stat-value">{system.averageTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Min:</span>
|
||||
<span className="stat-value">{system.minTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Max:</span>
|
||||
<span className="stat-value">{system.maxTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profiler-footer">
|
||||
<div className="profiler-legend">
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-success)' }} />
|
||||
<span>Good (<8ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-warning)' }} />
|
||||
<span>Warning (8-16ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-danger)' }} />
|
||||
<span>Critical (>16ms)</span>
|
||||
</div>
|
||||
<div className="profiler-footer">
|
||||
<div className="profiler-legend">
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-success)' }} />
|
||||
<span>Good (<8ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-warning)' }} />
|
||||
<span>Warning (8-16ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-danger)' }} />
|
||||
<span>Critical (>16ms)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,136 +10,136 @@ interface PropertyInspectorProps {
|
||||
}
|
||||
|
||||
export function PropertyInspector({ component, onChange }: PropertyInspectorProps) {
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
useEffect(() => {
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const metadata = propertyMetadataService.getEditableProperties(component);
|
||||
setProperties(metadata);
|
||||
const metadata = propertyMetadataService.getEditableProperties(component);
|
||||
setProperties(metadata);
|
||||
|
||||
const componentAsAny = component as any;
|
||||
const currentValues: Record<string, any> = {};
|
||||
for (const key in metadata) {
|
||||
currentValues[key] = componentAsAny[key];
|
||||
}
|
||||
setValues(currentValues);
|
||||
}, [component]);
|
||||
const componentAsAny = component as any;
|
||||
const currentValues: Record<string, any> = {};
|
||||
for (const key in metadata) {
|
||||
currentValues[key] = componentAsAny[key];
|
||||
}
|
||||
setValues(currentValues);
|
||||
}, [component]);
|
||||
|
||||
const handleChange = (propertyName: string, value: any) => {
|
||||
const componentAsAny = component as any;
|
||||
componentAsAny[propertyName] = value;
|
||||
const handleChange = (propertyName: string, value: any) => {
|
||||
const componentAsAny = component as any;
|
||||
componentAsAny[propertyName] = value;
|
||||
|
||||
setValues(prev => ({
|
||||
...prev,
|
||||
[propertyName]: value
|
||||
}));
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[propertyName]: value
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(propertyName, value);
|
||||
}
|
||||
};
|
||||
if (onChange) {
|
||||
onChange(propertyName, value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
|
||||
const value = values[propertyName];
|
||||
const label = metadata.label || propertyName;
|
||||
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
|
||||
const value = values[propertyName];
|
||||
const label = metadata.label || propertyName;
|
||||
|
||||
switch (metadata.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<NumberField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? 0}
|
||||
min={metadata.min}
|
||||
max={metadata.max}
|
||||
step={metadata.step}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
switch (metadata.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<NumberField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? 0}
|
||||
min={metadata.min}
|
||||
max={metadata.max}
|
||||
step={metadata.step}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<StringField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? ''}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
return (
|
||||
<StringField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? ''}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? false}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? false}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'color':
|
||||
return (
|
||||
<ColorField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? '#ffffff'}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'color':
|
||||
return (
|
||||
<ColorField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? '#ffffff'}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector2':
|
||||
return (
|
||||
<Vector2Field
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'vector2':
|
||||
return (
|
||||
<Vector2Field
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector3':
|
||||
return (
|
||||
<Vector3Field
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'vector3':
|
||||
return (
|
||||
<Vector3Field
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'enum':
|
||||
return (
|
||||
<EnumField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value}
|
||||
options={metadata.options || []}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
case 'enum':
|
||||
return (
|
||||
<EnumField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value}
|
||||
options={metadata.options || []}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-inspector">
|
||||
{Object.entries(properties).map(([propertyName, metadata]) =>
|
||||
renderProperty(propertyName, metadata)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="property-inspector">
|
||||
{Object.entries(properties).map(([propertyName, metadata]) =>
|
||||
renderProperty(propertyName, metadata)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
@@ -153,69 +153,69 @@ interface NumberFieldProps {
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }: NumberFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.1 : 1;
|
||||
let newValue = dragStartValue + delta * step * sensitivity;
|
||||
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(parseFloat(newValue.toFixed(3)));
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.1 : 1;
|
||||
let newValue = dragStartValue + delta * step * sensitivity;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label
|
||||
className="property-label property-label-draggable"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onChange(parseFloat(newValue.toFixed(3)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label
|
||||
className="property-label property-label-draggable"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StringFieldProps {
|
||||
@@ -226,19 +226,19 @@ interface StringFieldProps {
|
||||
}
|
||||
|
||||
function StringField({ label, value, readOnly, onChange }: StringFieldProps) {
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-text"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-text"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BooleanFieldProps {
|
||||
@@ -249,18 +249,18 @@ interface BooleanFieldProps {
|
||||
}
|
||||
|
||||
function BooleanField({ label, value, readOnly, onChange }: BooleanFieldProps) {
|
||||
return (
|
||||
<div className="property-field property-field-boolean">
|
||||
<label className="property-label">{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="property-field property-field-boolean">
|
||||
<label className="property-label">{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorFieldProps {
|
||||
@@ -271,29 +271,29 @@ interface ColorFieldProps {
|
||||
}
|
||||
|
||||
function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-color-wrapper">
|
||||
<div className="property-color-preview" style={{ backgroundColor: value }} />
|
||||
<input
|
||||
type="color"
|
||||
className="property-input property-input-color"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-color-wrapper">
|
||||
<div className="property-color-preview" style={{ backgroundColor: value }} />
|
||||
<input
|
||||
type="color"
|
||||
className="property-input property-input-color"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Vector2FieldProps {
|
||||
@@ -304,76 +304,76 @@ interface Vector2FieldProps {
|
||||
}
|
||||
|
||||
function Vector2Field({ label, value, readOnly, onChange }: Vector2FieldProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<div className="property-label-row">
|
||||
<button
|
||||
className="property-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<label className="property-label">{label}</label>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="property-field">
|
||||
<div className="property-label-row">
|
||||
<button
|
||||
className="property-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<label className="property-label">{label}</label>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
interface Vector3FieldProps {
|
||||
@@ -384,100 +384,100 @@ interface Vector3FieldProps {
|
||||
}
|
||||
|
||||
function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<div className="property-label-row">
|
||||
<button
|
||||
className="property-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<label className="property-label">{label}</label>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="property-field">
|
||||
<div className="property-label-row">
|
||||
<button
|
||||
className="property-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<label className="property-label">{label}</label>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
interface EnumFieldProps {
|
||||
@@ -489,29 +489,29 @@ interface EnumFieldProps {
|
||||
}
|
||||
|
||||
function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) {
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<select
|
||||
className="property-input property-input-select"
|
||||
value={value ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const selectedOption = options.find(opt => String(opt.value) === e.target.value);
|
||||
if (selectedOption) {
|
||||
onChange(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<option value="">No options</option>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<option key={index} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<select
|
||||
className="property-input property-input-select"
|
||||
value={value ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const selectedOption = options.find((opt) => String(opt.value) === e.target.value);
|
||||
if (selectedOption) {
|
||||
onChange(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<option value="">No options</option>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<option key={index} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,160 +13,160 @@ interface ResizablePanelProps {
|
||||
}
|
||||
|
||||
export function ResizablePanel({
|
||||
direction,
|
||||
leftOrTop,
|
||||
rightOrBottom,
|
||||
defaultSize = 250,
|
||||
minSize = 150,
|
||||
maxSize = 600,
|
||||
side = 'left',
|
||||
storageKey
|
||||
direction,
|
||||
leftOrTop,
|
||||
rightOrBottom,
|
||||
defaultSize = 250,
|
||||
minSize = 150,
|
||||
maxSize = 600,
|
||||
side = 'left',
|
||||
storageKey
|
||||
}: ResizablePanelProps) {
|
||||
const getInitialSize = () => {
|
||||
if (storageKey) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsedSize = parseInt(saved, 10);
|
||||
if (!isNaN(parsedSize)) {
|
||||
return Math.max(minSize, Math.min(maxSize, parsedSize));
|
||||
const getInitialSize = () => {
|
||||
if (storageKey) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsedSize = parseInt(saved, 10);
|
||||
if (!isNaN(parsedSize)) {
|
||||
return Math.max(minSize, Math.min(maxSize, parsedSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const [size, setSize] = useState(getInitialSize);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState(getInitialSize);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
localStorage.setItem(storageKey, size.toString());
|
||||
}
|
||||
}, [size, isDragging, storageKey]);
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
localStorage.setItem(storageKey, size.toString());
|
||||
}
|
||||
}, [size, isDragging, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
let newSize: number;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
let newSize: number;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
if (direction === 'horizontal') {
|
||||
if (side === 'right') {
|
||||
newSize = rect.right - e.clientX;
|
||||
} else {
|
||||
newSize = e.clientX - rect.left;
|
||||
}
|
||||
} else {
|
||||
if (side === 'bottom') {
|
||||
newSize = rect.bottom - e.clientY;
|
||||
} else {
|
||||
newSize = e.clientY - rect.top;
|
||||
}
|
||||
}
|
||||
|
||||
newSize = Math.max(minSize, Math.min(maxSize, newSize));
|
||||
setSize(newSize);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, direction, minSize, maxSize, side]);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const className = `resizable-panel resizable-panel-${direction}`;
|
||||
const resizerClassName = `resizer resizer-${direction}`;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
if (side === 'right') {
|
||||
newSize = rect.right - e.clientX;
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
newSize = e.clientX - rect.left;
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
if (side === 'bottom') {
|
||||
newSize = rect.bottom - e.clientY;
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
newSize = e.clientY - rect.top;
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
newSize = Math.max(minSize, Math.min(maxSize, newSize));
|
||||
setSize(newSize);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, direction, minSize, maxSize, side]);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const className = `resizable-panel resizable-panel-${direction}`;
|
||||
const resizerClassName = `resizer resizer-${direction}`;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
if (side === 'right') {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (side === 'bottom') {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,358 +13,358 @@ interface SceneHierarchyProps {
|
||||
}
|
||||
|
||||
export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sceneName, setSceneName] = useState<string>('Untitled');
|
||||
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
||||
const [sceneFilePath, setSceneFilePath] = useState<string | null>(null);
|
||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||
const { t, locale } = useLocale();
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sceneName, setSceneName] = useState<string>('Untitled');
|
||||
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
||||
const [sceneFilePath, setSceneFilePath] = useState<string | null>(null);
|
||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
// Subscribe to scene changes
|
||||
useEffect(() => {
|
||||
const sceneManager = Core.services.resolve(SceneManagerService);
|
||||
// Subscribe to scene changes
|
||||
useEffect(() => {
|
||||
const sceneManager = Core.services.resolve(SceneManagerService);
|
||||
|
||||
const updateSceneInfo = () => {
|
||||
if (sceneManager) {
|
||||
const state = sceneManager.getSceneState();
|
||||
setSceneName(state.sceneName);
|
||||
setIsSceneModified(state.isModified);
|
||||
}
|
||||
};
|
||||
const updateSceneInfo = () => {
|
||||
if (sceneManager) {
|
||||
const state = sceneManager.getSceneState();
|
||||
setSceneName(state.sceneName);
|
||||
setIsSceneModified(state.isModified);
|
||||
}
|
||||
};
|
||||
|
||||
updateSceneInfo();
|
||||
|
||||
const unsubLoaded = messageHub.subscribe('scene:loaded', (data: any) => {
|
||||
if (data.sceneName) {
|
||||
setSceneName(data.sceneName);
|
||||
setSceneFilePath(data.path || null);
|
||||
setIsSceneModified(data.isModified || false);
|
||||
} else {
|
||||
updateSceneInfo();
|
||||
}
|
||||
});
|
||||
const unsubNew = messageHub.subscribe('scene:new', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
const unsubSaved = messageHub.subscribe('scene:saved', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
const unsubModified = messageHub.subscribe('scene:modified', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubLoaded();
|
||||
unsubNew();
|
||||
unsubSaved();
|
||||
unsubModified();
|
||||
};
|
||||
}, [messageHub]);
|
||||
const unsubLoaded = messageHub.subscribe('scene:loaded', (data: any) => {
|
||||
if (data.sceneName) {
|
||||
setSceneName(data.sceneName);
|
||||
setSceneFilePath(data.path || null);
|
||||
setIsSceneModified(data.isModified || false);
|
||||
} else {
|
||||
updateSceneInfo();
|
||||
}
|
||||
});
|
||||
const unsubNew = messageHub.subscribe('scene:new', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
const unsubSaved = messageHub.subscribe('scene:saved', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
const unsubModified = messageHub.subscribe('scene:modified', () => {
|
||||
updateSceneInfo();
|
||||
});
|
||||
|
||||
// Subscribe to local entity changes
|
||||
useEffect(() => {
|
||||
const updateEntities = () => {
|
||||
setEntities(entityStore.getRootEntities());
|
||||
};
|
||||
return () => {
|
||||
unsubLoaded();
|
||||
unsubNew();
|
||||
unsubSaved();
|
||||
unsubModified();
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedId(data.entity?.id ?? null);
|
||||
};
|
||||
// Subscribe to local entity changes
|
||||
useEffect(() => {
|
||||
const updateEntities = () => {
|
||||
setEntities(entityStore.getRootEntities());
|
||||
};
|
||||
|
||||
updateEntities();
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedId(data.entity?.id ?? null);
|
||||
};
|
||||
|
||||
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
||||
const unsubRemove = messageHub.subscribe('entity:removed', updateEntities);
|
||||
const unsubClear = messageHub.subscribe('entities:cleared', updateEntities);
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
|
||||
updateEntities();
|
||||
|
||||
return () => {
|
||||
unsubAdd();
|
||||
unsubRemove();
|
||||
unsubClear();
|
||||
unsubSelect();
|
||||
};
|
||||
}, [entityStore, messageHub]);
|
||||
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
||||
const unsubRemove = messageHub.subscribe('entity:removed', updateEntities);
|
||||
const unsubClear = messageHub.subscribe('entities:cleared', updateEntities);
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
|
||||
|
||||
// Subscribe to remote entity data from ProfilerService
|
||||
useEffect(() => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
return () => {
|
||||
unsubAdd();
|
||||
unsubRemove();
|
||||
unsubClear();
|
||||
unsubSelect();
|
||||
};
|
||||
}, [entityStore, messageHub]);
|
||||
|
||||
if (!profilerService) {
|
||||
return;
|
||||
}
|
||||
// Subscribe to remote entity data from ProfilerService
|
||||
useEffect(() => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
|
||||
const initiallyConnected = profilerService.isConnected();
|
||||
setIsRemoteConnected(initiallyConnected);
|
||||
if (!profilerService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = profilerService.subscribe((data) => {
|
||||
const connected = profilerService.isConnected();
|
||||
setIsRemoteConnected(connected);
|
||||
const initiallyConnected = profilerService.isConnected();
|
||||
setIsRemoteConnected(initiallyConnected);
|
||||
|
||||
if (connected && data.entities && data.entities.length > 0) {
|
||||
// 只在实体列表发生实质性变化时才更新
|
||||
setRemoteEntities(prev => {
|
||||
if (prev.length !== data.entities!.length) {
|
||||
return data.entities!;
|
||||
}
|
||||
const unsubscribe = profilerService.subscribe((data) => {
|
||||
const connected = profilerService.isConnected();
|
||||
setIsRemoteConnected(connected);
|
||||
|
||||
// 检查实体ID和名称是否变化
|
||||
const hasChanged = data.entities!.some((entity, index) => {
|
||||
const prevEntity = prev[index];
|
||||
return !prevEntity ||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
return hasChanged ? data.entities! : prev;
|
||||
return hasChanged ? data.entities! : prev;
|
||||
});
|
||||
|
||||
// 请求第一个实体的详情以获取场景名称
|
||||
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
|
||||
profilerService.requestEntityDetails(data.entities[0].id);
|
||||
}
|
||||
} else if (!connected) {
|
||||
setRemoteEntities([]);
|
||||
setRemoteSceneName(null);
|
||||
}
|
||||
});
|
||||
|
||||
// 请求第一个实体的详情以获取场景名称
|
||||
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
|
||||
profilerService.requestEntityDetails(data.entities[0].id);
|
||||
}
|
||||
} else if (!connected) {
|
||||
setRemoteEntities([]);
|
||||
setRemoteSceneName(null);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [remoteSceneName]);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [remoteSceneName]);
|
||||
// Listen for entity details to get remote scene name
|
||||
useEffect(() => {
|
||||
const handleEntityDetails = ((event: CustomEvent) => {
|
||||
const details = event.detail;
|
||||
if (details && details.sceneName) {
|
||||
setRemoteSceneName(details.sceneName);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
// Listen for entity details to get remote scene name
|
||||
useEffect(() => {
|
||||
const handleEntityDetails = ((event: CustomEvent) => {
|
||||
const details = event.detail;
|
||||
if (details && details.sceneName) {
|
||||
setRemoteSceneName(details.sceneName);
|
||||
}
|
||||
}) as EventListener;
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
return () => window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
}, []);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
return () => window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
}, []);
|
||||
|
||||
const handleEntityClick = (entity: Entity) => {
|
||||
entityStore.selectEntity(entity);
|
||||
};
|
||||
|
||||
const handleRemoteEntityClick = (entity: RemoteEntity) => {
|
||||
setSelectedId(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,
|
||||
name: entity.name,
|
||||
enabled: entity.enabled,
|
||||
componentCount: entity.componentCount,
|
||||
componentTypes: entity.componentTypes
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSceneNameClick = () => {
|
||||
if (sceneFilePath) {
|
||||
messageHub.publish('asset:reveal', { path: sceneFilePath });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEntity = () => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Entity ${entityCount + 1}`;
|
||||
const entity = scene.createEntity(entityName);
|
||||
entityStore.addEntity(entity);
|
||||
entityStore.selectEntity(entity);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async () => {
|
||||
if (!selectedId) return;
|
||||
|
||||
const entity = entityStore.getEntity(selectedId);
|
||||
if (!entity) return;
|
||||
|
||||
const confirmed = await confirm(
|
||||
locale === 'zh'
|
||||
? `确定要删除实体 "${entity.name}" 吗?此操作无法撤销。`
|
||||
: `Are you sure you want to delete entity "${entity.name}"? This action cannot be undone.`,
|
||||
{
|
||||
title: locale === 'zh' ? '删除实体' : 'Delete Entity',
|
||||
kind: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
entity.destroy();
|
||||
entityStore.removeEntity(entity);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for Delete key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' && selectedId && !isRemoteConnected) {
|
||||
handleDeleteEntity();
|
||||
}
|
||||
const handleEntityClick = (entity: Entity) => {
|
||||
entityStore.selectEntity(entity);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isRemoteConnected]);
|
||||
const handleRemoteEntityClick = (entity: RemoteEntity) => {
|
||||
setSelectedId(entity.id);
|
||||
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
if (!searchQuery.trim()) return entityList;
|
||||
// 请求完整的实体详情(包含组件属性)
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
|
||||
if (profilerService) {
|
||||
profilerService.requestEntityDetails(entity.id);
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entityList.filter(entity => {
|
||||
const name = entity.name;
|
||||
const id = entity.id.toString();
|
||||
// 先发布基本信息,详细信息稍后通过 ProfilerService 异步返回
|
||||
messageHub.publish('remote-entity:selected', {
|
||||
entity: {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
enabled: entity.enabled,
|
||||
componentCount: entity.componentCount,
|
||||
componentTypes: entity.componentTypes
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Search by name or ID
|
||||
if (name.toLowerCase().includes(query) || id.includes(query)) {
|
||||
return true;
|
||||
}
|
||||
const handleSceneNameClick = () => {
|
||||
if (sceneFilePath) {
|
||||
messageHub.publish('asset:reveal', { path: sceneFilePath });
|
||||
}
|
||||
};
|
||||
|
||||
// Search by component types
|
||||
if (Array.isArray(entity.componentTypes)) {
|
||||
return entity.componentTypes.some(type =>
|
||||
type.toLowerCase().includes(query)
|
||||
const handleCreateEntity = () => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Entity ${entityCount + 1}`;
|
||||
const entity = scene.createEntity(entityName);
|
||||
entityStore.addEntity(entity);
|
||||
entityStore.selectEntity(entity);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async () => {
|
||||
if (!selectedId) return;
|
||||
|
||||
const entity = entityStore.getEntity(selectedId);
|
||||
if (!entity) return;
|
||||
|
||||
const confirmed = await confirm(
|
||||
locale === 'zh'
|
||||
? `确定要删除实体 "${entity.name}" 吗?此操作无法撤销。`
|
||||
: `Are you sure you want to delete entity "${entity.name}"? This action cannot be undone.`,
|
||||
{
|
||||
title: locale === 'zh' ? '删除实体' : 'Delete Entity',
|
||||
kind: 'warning'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
if (confirmed) {
|
||||
entity.destroy();
|
||||
entityStore.removeEntity(entity);
|
||||
}
|
||||
};
|
||||
|
||||
const filterLocalEntities = (entityList: Entity[]): Entity[] => {
|
||||
if (!searchQuery.trim()) return entityList;
|
||||
// Listen for Delete key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' && selectedId && !isRemoteConnected) {
|
||||
handleDeleteEntity();
|
||||
}
|
||||
};
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entityList.filter(entity => {
|
||||
const id = entity.id.toString();
|
||||
return id.includes(query);
|
||||
});
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isRemoteConnected]);
|
||||
|
||||
// Determine which entities to display
|
||||
const displayEntities = isRemoteConnected
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0;
|
||||
const displaySceneName = isRemoteConnected && remoteSceneName ? remoteSceneName : sceneName;
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
if (!searchQuery.trim()) return entityList;
|
||||
|
||||
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' : ''}`}
|
||||
onClick={!isRemoteConnected ? handleSceneNameClick : undefined}
|
||||
title={!isRemoteConnected && sceneFilePath ? `${displaySceneName} - 点击跳转到文件` : displaySceneName}
|
||||
>
|
||||
<span className="scene-name">
|
||||
{displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
|
||||
</span>
|
||||
</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>
|
||||
{!isRemoteConnected && (
|
||||
<div className="hierarchy-toolbar">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleDeleteEntity}
|
||||
disabled={!selectedId}
|
||||
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hierarchy-content scrollable">
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-title">{t('hierarchy.empty')}</div>
|
||||
<div className="empty-hint">
|
||||
{isRemoteConnected
|
||||
? 'No entities in remote game'
|
||||
: 'Create an entity to get started'}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entityList.filter((entity) => {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const filterLocalEntities = (entityList: Entity[]): Entity[] => {
|
||||
if (!searchQuery.trim()) return entityList;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entityList.filter((entity) => {
|
||||
const id = entity.id.toString();
|
||||
return id.includes(query);
|
||||
});
|
||||
};
|
||||
|
||||
// Determine which entities to display
|
||||
const displayEntities = isRemoteConnected
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0;
|
||||
const displaySceneName = isRemoteConnected && remoteSceneName ? remoteSceneName : sceneName;
|
||||
|
||||
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' : ''}`}
|
||||
onClick={!isRemoteConnected ? handleSceneNameClick : undefined}
|
||||
title={!isRemoteConnected && sceneFilePath ? `${displaySceneName} - 点击跳转到文件` : displaySceneName}
|
||||
>
|
||||
<span className="scene-name">
|
||||
{displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{showRemoteIndicator && (
|
||||
<div className="remote-indicator" title="Showing remote entities">
|
||||
<Wifi size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isRemoteConnected ? (
|
||||
<ul className="entity-list">
|
||||
{(displayEntities as RemoteEntity[]).map(entity => (
|
||||
<li
|
||||
key={entity.id}
|
||||
className={`entity-item remote-entity ${selectedId === entity.id ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
title={`${entity.name} - ${entity.componentTypes.join(', ')}`}
|
||||
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}`}>
|
||||
<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>
|
||||
{!isRemoteConnected && (
|
||||
<div className="hierarchy-toolbar">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleDeleteEntity}
|
||||
disabled={!selectedId}
|
||||
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hierarchy-content scrollable">
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-title">{t('hierarchy.empty')}</div>
|
||||
<div className="empty-hint">
|
||||
{isRemoteConnected
|
||||
? 'No entities in remote game'
|
||||
: 'Create an entity to get started'}
|
||||
</div>
|
||||
</div>
|
||||
) : isRemoteConnected ? (
|
||||
<ul className="entity-list">
|
||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||
<li
|
||||
key={entity.id}
|
||||
className={`entity-item remote-entity ${selectedId === entity.id ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
title={`${entity.name} - ${entity.componentTypes.join(', ')}`}
|
||||
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>
|
||||
</span>
|
||||
)}
|
||||
{entity.componentCount > 0 && (
|
||||
<span className="component-count">{entity.componentCount}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="entity-list">
|
||||
{entities.map((entity) => (
|
||||
<li
|
||||
key={entity.id}
|
||||
className={`entity-item ${selectedId === entity.id ? 'selected' : ''}`}
|
||||
onClick={() => handleEntityClick(entity)}
|
||||
>
|
||||
<Box size={14} className="entity-icon" />
|
||||
<span className="entity-name">Entity {entity.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{entity.componentCount > 0 && (
|
||||
<span className="component-count">{entity.componentCount}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="entity-list">
|
||||
{entities.map(entity => (
|
||||
<li
|
||||
key={entity.id}
|
||||
className={`entity-item ${selectedId === entity.id ? 'selected' : ''}`}
|
||||
onClick={() => handleEntityClick(entity)}
|
||||
>
|
||||
<Box size={14} className="entity-icon" />
|
||||
<span className="entity-name">Entity {entity.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,286 +10,286 @@ interface SettingsWindowProps {
|
||||
}
|
||||
|
||||
export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProps) {
|
||||
const [categories, setCategories] = useState<SettingCategory[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Map<string, any>>(new Map());
|
||||
const [errors, setErrors] = useState<Map<string, string>>(new Map());
|
||||
const [categories, setCategories] = useState<SettingCategory[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Map<string, any>>(new Map());
|
||||
const [errors, setErrors] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const allCategories = settingsRegistry.getAllCategories();
|
||||
setCategories(allCategories);
|
||||
useEffect(() => {
|
||||
const allCategories = settingsRegistry.getAllCategories();
|
||||
setCategories(allCategories);
|
||||
|
||||
if (allCategories.length > 0 && !selectedCategoryId) {
|
||||
const firstCategory = allCategories[0];
|
||||
if (firstCategory) {
|
||||
setSelectedCategoryId(firstCategory.id);
|
||||
}
|
||||
}
|
||||
if (allCategories.length > 0 && !selectedCategoryId) {
|
||||
const firstCategory = allCategories[0];
|
||||
if (firstCategory) {
|
||||
setSelectedCategoryId(firstCategory.id);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
const allSettings = settingsRegistry.getAllSettings();
|
||||
const initialValues = new Map<string, any>();
|
||||
const settings = SettingsService.getInstance();
|
||||
const allSettings = settingsRegistry.getAllSettings();
|
||||
const initialValues = new Map<string, any>();
|
||||
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
}
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
}
|
||||
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, selectedCategoryId]);
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, selectedCategoryId]);
|
||||
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
newValues.set(key, value);
|
||||
setValues(newValues);
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
newValues.set(key, value);
|
||||
setValues(newValues);
|
||||
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value');
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
setErrors(newErrors);
|
||||
};
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value');
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
setErrors(newErrors);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (errors.size > 0) {
|
||||
return;
|
||||
}
|
||||
const handleSave = () => {
|
||||
if (errors.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
const changedSettings: Record<string, any> = {};
|
||||
const settings = SettingsService.getInstance();
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
settings.set(key, value);
|
||||
changedSettings[key] = value;
|
||||
}
|
||||
for (const [key, value] of values.entries()) {
|
||||
settings.set(key, value);
|
||||
changedSettings[key] = value;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
}));
|
||||
|
||||
onClose();
|
||||
};
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderSettingInput = (setting: SettingDescriptor) => {
|
||||
const value = values.get(setting.key) ?? setting.defaultValue;
|
||||
const error = errors.get(setting.key);
|
||||
const renderSettingInput = (setting: SettingDescriptor) => {
|
||||
const value = values.get(setting.key) ?? setting.defaultValue;
|
||||
const error = errors.get(setting.key);
|
||||
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label settings-label-checkbox">
|
||||
<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>
|
||||
);
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label settings-label-checkbox">
|
||||
<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>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
case 'string':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'range':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="settings-range-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="settings-range"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseFloat(e.target.value), setting)}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
<span className="settings-range-value">{value}</span>
|
||||
</div>
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'range':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="settings-range-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="settings-range"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseFloat(e.target.value), setting)}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
<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}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
case 'color':
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
{setting.label}
|
||||
{setting.description && (
|
||||
<span className="settings-hint">{setting.description}</span>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCategory = categories.find(c => c.id === selectedCategoryId);
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!selectedCategory && (
|
||||
<div className="settings-empty">
|
||||
<SettingsIcon size={48} />
|
||||
<p>请选择一个设置分类</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!selectedCategory && (
|
||||
<div className="settings-empty">
|
||||
<SettingsIcon size={48} />
|
||||
<p>请选择一个设置分类</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<button className="settings-btn settings-btn-cancel" onClick={handleCancel}>
|
||||
<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>
|
||||
<button
|
||||
className="settings-btn settings-btn-save"
|
||||
onClick={handleSave}
|
||||
disabled={errors.size > 0}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,98 +11,98 @@ interface StartupPageProps {
|
||||
}
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'ECS Framework Editor',
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create Project',
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
version: 'Version 1.0.0',
|
||||
comingSoon: 'Coming Soon'
|
||||
},
|
||||
zh: {
|
||||
title: 'ECS 框架编辑器',
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
version: '版本 1.0.0',
|
||||
comingSoon: '即将推出'
|
||||
}
|
||||
};
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'ECS Framework Editor',
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create Project',
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
version: 'Version 1.0.0',
|
||||
comingSoon: 'Coming Soon'
|
||||
},
|
||||
zh: {
|
||||
title: 'ECS 框架编辑器',
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
version: '版本 1.0.0',
|
||||
comingSoon: '即将推出'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
|
||||
return (
|
||||
<div className="startup-page">
|
||||
<div className="startup-header">
|
||||
<h1 className="startup-title">{t.title}</h1>
|
||||
<p className="startup-subtitle">{t.subtitle}</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="startup-page">
|
||||
<div className="startup-header">
|
||||
<h1 className="startup-title">{t.title}</h1>
|
||||
<p className="startup-subtitle">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="startup-content">
|
||||
<div className="startup-actions">
|
||||
<button className="startup-action-btn primary" onClick={onOpenProject}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
||||
</svg>
|
||||
<span>{t.openProject}</span>
|
||||
</button>
|
||||
<div className="startup-content">
|
||||
<div className="startup-actions">
|
||||
<button className="startup-action-btn primary" onClick={onOpenProject}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
||||
</svg>
|
||||
<span>{t.openProject}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onCreateProject}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.createProject}</span>
|
||||
</button>
|
||||
<button className="startup-action-btn" onClick={onCreateProject}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.createProject}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onProfilerMode}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.profilerMode}</span>
|
||||
</button>
|
||||
<button className="startup-action-btn" onClick={onProfilerMode}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.profilerMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="startup-recent">
|
||||
<h2 className="recent-title">{t.recentProjects}</h2>
|
||||
{recentProjects.length === 0 ? (
|
||||
<p className="recent-empty">{t.noRecentProjects}</p>
|
||||
) : (
|
||||
<ul className="recent-list">
|
||||
{recentProjects.map((project, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredProject(project)}
|
||||
onMouseLeave={() => setHoveredProject(null)}
|
||||
onClick={() => onOpenRecentProject?.(project)}
|
||||
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
|
||||
>
|
||||
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
||||
</svg>
|
||||
<div className="recent-info">
|
||||
<div className="recent-name">{project.split(/[\\/]/).pop()}</div>
|
||||
<div className="recent-path">{project}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{t.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="startup-recent">
|
||||
<h2 className="recent-title">{t.recentProjects}</h2>
|
||||
{recentProjects.length === 0 ? (
|
||||
<p className="recent-empty">{t.noRecentProjects}</p>
|
||||
) : (
|
||||
<ul className="recent-list">
|
||||
{recentProjects.map((project, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredProject(project)}
|
||||
onMouseLeave={() => setHoveredProject(null)}
|
||||
onClick={() => onOpenRecentProject?.(project)}
|
||||
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
|
||||
>
|
||||
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
||||
</svg>
|
||||
<div className="recent-info">
|
||||
<div className="recent-name">{project.split(/[\\/]/).pop()}</div>
|
||||
<div className="recent-path">{project}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{t.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const id = `toast-${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
setToasts(prev => [...prev, toast]);
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
@@ -47,7 +47,7 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
const hideToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const getIcon = (type: ToastType) => {
|
||||
@@ -67,7 +67,7 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
<ToastContext.Provider value={{ showToast, hideToast }}>
|
||||
{children}
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
<div className="toast-icon">
|
||||
{getIcon(toast.type)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,7 +183,7 @@ export const ${opts.constantsName} = {} as const;`;
|
||||
if (Object.keys(grouped).length === 1 && grouped[''] !== undefined) {
|
||||
// 无命名空间,扁平结构
|
||||
const entries = variables
|
||||
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
@@ -200,13 +200,13 @@ ${entries}
|
||||
if (namespace === '') {
|
||||
// 根级别变量
|
||||
return vars
|
||||
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
} else {
|
||||
// 命名空间变量
|
||||
const nsName = this.toPascalCase(namespace);
|
||||
const entries = vars
|
||||
.map(v => {
|
||||
.map((v) => {
|
||||
const shortName = v.name.substring(namespace.length + 1);
|
||||
return ` ${this.transformName(shortName, opts.constantCase)}: ${quote}${v.name}${quote}`;
|
||||
})
|
||||
@@ -238,7 +238,7 @@ export interface ${opts.interfaceName} {}`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map(v => {
|
||||
.map((v) => {
|
||||
const tsType = this.mapBlackboardTypeToTS(v.type);
|
||||
const comment = v.description ? ` /** ${v.description} */\n` : '';
|
||||
return `${comment} ${v.name}: ${tsType};`;
|
||||
@@ -334,7 +334,7 @@ export const ${opts.defaultsName}: ${opts.interfaceName} = {};`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map(v => {
|
||||
.map((v) => {
|
||||
const value = this.formatValue(v.value, v.type, opts);
|
||||
return ` ${v.name}: ${value}`;
|
||||
})
|
||||
@@ -407,7 +407,7 @@ ${properties}
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ ${properties}
|
||||
const parts = str.split(/[._-]/);
|
||||
if (parts.length === 0) return str;
|
||||
return (parts[0] || '').toLowerCase() + parts.slice(1)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { BlackboardValueType } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 局部黑板变量信息
|
||||
*/
|
||||
@@ -243,7 +241,7 @@ export function is${this.toPascalCase(treeName)}Variable(
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(quoteStyle === 'single' ? /'/g : /"/g,
|
||||
quoteStyle === 'single' ? "\\'" : '\\"');
|
||||
quoteStyle === 'single' ? "\\'" : '\\"');
|
||||
return `${quote}${escaped}${quote}`;
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
@@ -253,7 +251,7 @@ export function is${this.toPascalCase(treeName)}Variable(
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
const items = value.map(v => this.formatValue(v, quoteStyle)).join(', ');
|
||||
const items = value.map((v) => this.formatValue(v, quoteStyle)).join(', ');
|
||||
return `[${items}]`;
|
||||
}
|
||||
// Vector2/Vector3
|
||||
@@ -286,7 +284,7 @@ export function is${this.toPascalCase(treeName)}Variable(
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,28 @@ import { Core } from '@esengine/ecs-framework';
|
||||
import { LocaleService, type Locale } from '@esengine/editor-core';
|
||||
|
||||
export function useLocale() {
|
||||
const localeService = useMemo(() => Core.services.resolve(LocaleService), []);
|
||||
const [locale, setLocale] = useState<Locale>(() => localeService.getCurrentLocale());
|
||||
const localeService = useMemo(() => Core.services.resolve(LocaleService), []);
|
||||
const [locale, setLocale] = useState<Locale>(() => localeService.getCurrentLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = localeService.onChange((newLocale) => {
|
||||
setLocale(newLocale);
|
||||
});
|
||||
useEffect(() => {
|
||||
const unsubscribe = localeService.onChange((newLocale) => {
|
||||
setLocale(newLocale);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [localeService]);
|
||||
return unsubscribe;
|
||||
}, [localeService]);
|
||||
|
||||
const t = useCallback((key: string, fallback?: string) => {
|
||||
return localeService.t(key, fallback);
|
||||
}, [localeService]);
|
||||
const t = useCallback((key: string, fallback?: string) => {
|
||||
return localeService.t(key, fallback);
|
||||
}, [localeService]);
|
||||
|
||||
const changeLocale = useCallback((newLocale: Locale) => {
|
||||
localeService.setLocale(newLocale);
|
||||
}, [localeService]);
|
||||
const changeLocale = useCallback((newLocale: Locale) => {
|
||||
localeService.setLocale(newLocale);
|
||||
}, [localeService]);
|
||||
|
||||
return {
|
||||
locale,
|
||||
t,
|
||||
changeLocale
|
||||
};
|
||||
return {
|
||||
locale,
|
||||
t,
|
||||
changeLocale
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import './styles/index.css';
|
||||
import './i18n/config';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -11,87 +11,87 @@ const logger = createLogger('EditorAppearancePlugin');
|
||||
* Manages editor appearance settings like font size
|
||||
*/
|
||||
export class EditorAppearancePlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/editor-appearance';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Editor Appearance';
|
||||
readonly category = EditorPluginCategory.System;
|
||||
readonly description = 'Configure editor appearance settings';
|
||||
readonly icon = '🎨';
|
||||
readonly name = '@esengine/editor-appearance';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Editor Appearance';
|
||||
readonly category = EditorPluginCategory.System;
|
||||
readonly description = 'Configure editor appearance settings';
|
||||
readonly icon = '🎨';
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'appearance',
|
||||
title: '外观',
|
||||
description: '配置编辑器的外观设置',
|
||||
sections: [
|
||||
{
|
||||
id: 'font',
|
||||
title: '字体设置',
|
||||
description: '配置编辑器字体样式',
|
||||
settings: [
|
||||
{
|
||||
key: 'editor.fontSize',
|
||||
label: '字体大小 (px)',
|
||||
type: 'range',
|
||||
defaultValue: 13,
|
||||
description: '编辑器界面的字体大小',
|
||||
min: 11,
|
||||
max: 18,
|
||||
step: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'appearance',
|
||||
title: '外观',
|
||||
description: '配置编辑器的外观设置',
|
||||
sections: [
|
||||
{
|
||||
id: 'font',
|
||||
title: '字体设置',
|
||||
description: '配置编辑器字体样式',
|
||||
settings: [
|
||||
{
|
||||
key: 'editor.fontSize',
|
||||
label: '字体大小 (px)',
|
||||
type: 'range',
|
||||
defaultValue: 13,
|
||||
description: '编辑器界面的字体大小',
|
||||
min: 11,
|
||||
max: 18,
|
||||
step: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
this.applyFontSettings();
|
||||
this.setupSettingsListener();
|
||||
this.applyFontSettings();
|
||||
this.setupSettingsListener();
|
||||
|
||||
logger.info('Installed');
|
||||
}
|
||||
logger.info('Installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
logger.info('Uninstalled');
|
||||
}
|
||||
async uninstall(): Promise<void> {
|
||||
logger.info('Uninstalled');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
logger.info('Editor is ready');
|
||||
}
|
||||
async onEditorReady(): Promise<void> {
|
||||
logger.info('Editor is ready');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Apply font settings from settings
|
||||
*/
|
||||
private applyFontSettings(): void {
|
||||
const settings = SettingsService.getInstance();
|
||||
const baseFontSize = settings.get<number>('editor.fontSize', 13);
|
||||
private applyFontSettings(): void {
|
||||
const settings = SettingsService.getInstance();
|
||||
const baseFontSize = settings.get<number>('editor.fontSize', 13);
|
||||
|
||||
logger.info(`Applying font size: ${baseFontSize}px`);
|
||||
logger.info(`Applying font size: ${baseFontSize}px`);
|
||||
|
||||
const root = document.documentElement;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply font sizes
|
||||
root.style.setProperty('--font-size-xs', `${baseFontSize - 2}px`);
|
||||
root.style.setProperty('--font-size-sm', `${baseFontSize - 1}px`);
|
||||
root.style.setProperty('--font-size-base', `${baseFontSize}px`);
|
||||
root.style.setProperty('--font-size-md', `${baseFontSize + 1}px`);
|
||||
root.style.setProperty('--font-size-lg', `${baseFontSize + 3}px`);
|
||||
root.style.setProperty('--font-size-xl', `${baseFontSize + 5}px`);
|
||||
}
|
||||
// Apply font sizes
|
||||
root.style.setProperty('--font-size-xs', `${baseFontSize - 2}px`);
|
||||
root.style.setProperty('--font-size-sm', `${baseFontSize - 1}px`);
|
||||
root.style.setProperty('--font-size-base', `${baseFontSize}px`);
|
||||
root.style.setProperty('--font-size-md', `${baseFontSize + 1}px`);
|
||||
root.style.setProperty('--font-size-lg', `${baseFontSize + 3}px`);
|
||||
root.style.setProperty('--font-size-xl', `${baseFontSize + 5}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Listen for settings changes
|
||||
*/
|
||||
private setupSettingsListener(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const changedSettings = event.detail;
|
||||
logger.info('Settings changed event received', changedSettings);
|
||||
private setupSettingsListener(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const changedSettings = event.detail;
|
||||
logger.info('Settings changed event received', changedSettings);
|
||||
|
||||
if ('editor.fontSize' in changedSettings) {
|
||||
logger.info('Font size changed, applying...');
|
||||
this.applyFontSettings();
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
if ('editor.fontSize' in changedSettings) {
|
||||
logger.info('Font size changed, applying...');
|
||||
this.applyFontSettings();
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,135 +9,135 @@ import { ProfilerService } from '../services/ProfilerService';
|
||||
* Displays real-time performance metrics for ECS systems
|
||||
*/
|
||||
export class ProfilerPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/profiler';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Performance Profiler';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
readonly description = 'Real-time performance monitoring for ECS systems';
|
||||
readonly icon = '📊';
|
||||
readonly name = '@esengine/profiler';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Performance Profiler';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
readonly description = 'Real-time performance monitoring for ECS systems';
|
||||
readonly icon = '📊';
|
||||
|
||||
private messageHub: MessageHub | null = null;
|
||||
private profilerService: ProfilerService | null = null;
|
||||
private messageHub: MessageHub | null = null;
|
||||
private profilerService: ProfilerService | null = null;
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
this.messageHub = services.resolve(MessageHub);
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
this.messageHub = services.resolve(MessageHub);
|
||||
|
||||
// 注册设置
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'profiler',
|
||||
title: '性能分析器',
|
||||
description: '配置性能分析器的行为和显示选项',
|
||||
sections: [
|
||||
{
|
||||
id: 'connection',
|
||||
title: '连接设置',
|
||||
description: '配置WebSocket服务器连接参数',
|
||||
settings: [
|
||||
{
|
||||
key: 'profiler.port',
|
||||
label: '监听端口',
|
||||
type: 'number',
|
||||
defaultValue: 8080,
|
||||
description: '性能分析器WebSocket服务器监听的端口号',
|
||||
placeholder: '8080',
|
||||
min: 1024,
|
||||
max: 65535,
|
||||
validator: {
|
||||
validate: (value: number) => value >= 1024 && value <= 65535,
|
||||
errorMessage: '端口号必须在1024到65535之间'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'profiler.autoStart',
|
||||
label: '自动启动服务器',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
description: '编辑器启动时自动启动性能分析器服务器'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
title: '显示设置',
|
||||
description: '配置性能数据的显示选项',
|
||||
settings: [
|
||||
{
|
||||
key: 'profiler.refreshInterval',
|
||||
label: '刷新间隔 (毫秒)',
|
||||
type: 'range',
|
||||
defaultValue: 100,
|
||||
description: '性能数据刷新的时间间隔',
|
||||
min: 50,
|
||||
max: 1000,
|
||||
step: 50
|
||||
},
|
||||
{
|
||||
key: 'profiler.maxDataPoints',
|
||||
label: '最大数据点数',
|
||||
type: 'number',
|
||||
defaultValue: 100,
|
||||
description: '图表中保留的最大历史数据点数量',
|
||||
min: 10,
|
||||
max: 500
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
// 注册设置
|
||||
const settingsRegistry = services.resolve(SettingsRegistry);
|
||||
settingsRegistry.registerCategory({
|
||||
id: 'profiler',
|
||||
title: '性能分析器',
|
||||
description: '配置性能分析器的行为和显示选项',
|
||||
sections: [
|
||||
{
|
||||
id: 'connection',
|
||||
title: '连接设置',
|
||||
description: '配置WebSocket服务器连接参数',
|
||||
settings: [
|
||||
{
|
||||
key: 'profiler.port',
|
||||
label: '监听端口',
|
||||
type: 'number',
|
||||
defaultValue: 8080,
|
||||
description: '性能分析器WebSocket服务器监听的端口号',
|
||||
placeholder: '8080',
|
||||
min: 1024,
|
||||
max: 65535,
|
||||
validator: {
|
||||
validate: (value: number) => value >= 1024 && value <= 65535,
|
||||
errorMessage: '端口号必须在1024到65535之间'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'profiler.autoStart',
|
||||
label: '自动启动服务器',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
description: '编辑器启动时自动启动性能分析器服务器'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
title: '显示设置',
|
||||
description: '配置性能数据的显示选项',
|
||||
settings: [
|
||||
{
|
||||
key: 'profiler.refreshInterval',
|
||||
label: '刷新间隔 (毫秒)',
|
||||
type: 'range',
|
||||
defaultValue: 100,
|
||||
description: '性能数据刷新的时间间隔',
|
||||
min: 50,
|
||||
max: 1000,
|
||||
step: 50
|
||||
},
|
||||
{
|
||||
key: 'profiler.maxDataPoints',
|
||||
label: '最大数据点数',
|
||||
type: 'number',
|
||||
defaultValue: 100,
|
||||
description: '图表中保留的最大历史数据点数量',
|
||||
min: 10,
|
||||
max: 500
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 创建并启动 ProfilerService
|
||||
this.profilerService = new ProfilerService();
|
||||
// 创建并启动 ProfilerService
|
||||
this.profilerService = new ProfilerService();
|
||||
|
||||
// 将服务实例存储到全局,供组件访问
|
||||
(window as any).__PROFILER_SERVICE__ = this.profilerService;
|
||||
// 将服务实例存储到全局,供组件访问
|
||||
(window as any).__PROFILER_SERVICE__ = this.profilerService;
|
||||
|
||||
console.log('[ProfilerPlugin] Installed and ProfilerService started');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 ProfilerService
|
||||
if (this.profilerService) {
|
||||
this.profilerService.destroy();
|
||||
this.profilerService = null;
|
||||
console.log('[ProfilerPlugin] Installed and ProfilerService started');
|
||||
}
|
||||
|
||||
delete (window as any).__PROFILER_SERVICE__;
|
||||
|
||||
console.log('[ProfilerPlugin] Uninstalled and ProfilerService stopped');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[ProfilerPlugin] Editor is ready');
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
const items = [
|
||||
{
|
||||
id: 'window.profiler',
|
||||
label: 'Profiler',
|
||||
parentId: 'window',
|
||||
order: 100,
|
||||
onClick: () => {
|
||||
console.log('[ProfilerPlugin] Menu item clicked!');
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 ProfilerService
|
||||
if (this.profilerService) {
|
||||
this.profilerService.destroy();
|
||||
this.profilerService = null;
|
||||
}
|
||||
}
|
||||
];
|
||||
console.log('[ProfilerPlugin] Registering menu items:', items);
|
||||
return items;
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'profiler-monitor',
|
||||
title: 'Performance Monitor',
|
||||
position: 'center' as any,
|
||||
closable: true,
|
||||
component: ProfilerDockPanel,
|
||||
order: 200
|
||||
}
|
||||
];
|
||||
}
|
||||
delete (window as any).__PROFILER_SERVICE__;
|
||||
|
||||
console.log('[ProfilerPlugin] Uninstalled and ProfilerService stopped');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[ProfilerPlugin] Editor is ready');
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
const items = [
|
||||
{
|
||||
id: 'window.profiler',
|
||||
label: 'Profiler',
|
||||
parentId: 'window',
|
||||
order: 100,
|
||||
onClick: () => {
|
||||
console.log('[ProfilerPlugin] Menu item clicked!');
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
console.log('[ProfilerPlugin] Registering menu items:', items);
|
||||
return items;
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'profiler-monitor',
|
||||
title: 'Performance Monitor',
|
||||
position: 'center' as any,
|
||||
closable: true,
|
||||
component: ProfilerDockPanel,
|
||||
order: 200
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,123 +8,123 @@ import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@eseng
|
||||
* 提供场景层级视图和实体检视功能
|
||||
*/
|
||||
export class SceneInspectorPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/scene-inspector';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Scene Inspector';
|
||||
readonly category = EditorPluginCategory.Inspector;
|
||||
readonly description = 'Scene hierarchy and entity inspector';
|
||||
readonly icon = '🔍';
|
||||
readonly name = '@esengine/scene-inspector';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Scene Inspector';
|
||||
readonly category = EditorPluginCategory.Inspector;
|
||||
readonly description = 'Scene hierarchy and entity inspector';
|
||||
readonly icon = '🔍';
|
||||
|
||||
async install(_core: Core, _services: ServiceContainer): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Installed');
|
||||
}
|
||||
async install(_core: Core, _services: ServiceContainer): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Uninstalled');
|
||||
}
|
||||
async uninstall(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Uninstalled');
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-scene-inspector',
|
||||
label: 'Scene Inspector',
|
||||
parentId: 'view',
|
||||
onClick: () => {
|
||||
console.log('Toggle Scene Inspector');
|
||||
},
|
||||
shortcut: 'Ctrl+Shift+I',
|
||||
order: 100
|
||||
},
|
||||
{
|
||||
id: 'scene-create-entity',
|
||||
label: 'Create Entity',
|
||||
parentId: 'scene',
|
||||
onClick: () => {
|
||||
console.log('Create new entity');
|
||||
},
|
||||
shortcut: 'Ctrl+N',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
registerMenuItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-scene-inspector',
|
||||
label: 'Scene Inspector',
|
||||
parentId: 'view',
|
||||
onClick: () => {
|
||||
console.log('Toggle Scene Inspector');
|
||||
},
|
||||
shortcut: 'Ctrl+Shift+I',
|
||||
order: 100
|
||||
},
|
||||
{
|
||||
id: 'scene-create-entity',
|
||||
label: 'Create Entity',
|
||||
parentId: 'scene',
|
||||
onClick: () => {
|
||||
console.log('Create new entity');
|
||||
},
|
||||
shortcut: 'Ctrl+N',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerToolbar(): ToolbarItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'toolbar-create-entity',
|
||||
label: 'New Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '➕',
|
||||
onClick: () => {
|
||||
console.log('Create entity from toolbar');
|
||||
},
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'toolbar-delete-entity',
|
||||
label: 'Delete Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '🗑️',
|
||||
onClick: () => {
|
||||
console.log('Delete entity from toolbar');
|
||||
},
|
||||
order: 20
|
||||
}
|
||||
];
|
||||
}
|
||||
registerToolbar(): ToolbarItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'toolbar-create-entity',
|
||||
label: 'New Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '➕',
|
||||
onClick: () => {
|
||||
console.log('Create entity from toolbar');
|
||||
},
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'toolbar-delete-entity',
|
||||
label: 'Delete Entity',
|
||||
groupId: 'entity-tools',
|
||||
icon: '🗑️',
|
||||
onClick: () => {
|
||||
console.log('Delete entity from toolbar');
|
||||
},
|
||||
order: 20
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'panel-scene-hierarchy',
|
||||
title: 'Scene Hierarchy',
|
||||
position: PanelPosition.Left,
|
||||
defaultSize: 250,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '📋',
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'panel-entity-inspector',
|
||||
title: 'Entity Inspector',
|
||||
position: PanelPosition.Right,
|
||||
defaultSize: 300,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '🔎',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'panel-scene-hierarchy',
|
||||
title: 'Scene Hierarchy',
|
||||
position: PanelPosition.Left,
|
||||
defaultSize: 250,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '📋',
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'panel-entity-inspector',
|
||||
title: 'Entity Inspector',
|
||||
position: PanelPosition.Right,
|
||||
defaultSize: 300,
|
||||
resizable: true,
|
||||
closable: false,
|
||||
icon: '🔎',
|
||||
order: 10
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getSerializers(): ISerializer[] {
|
||||
return [
|
||||
{
|
||||
serialize: (data: any) => {
|
||||
const json = JSON.stringify(data);
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(json);
|
||||
},
|
||||
deserialize: (data: Uint8Array) => {
|
||||
const decoder = new TextDecoder();
|
||||
const json = decoder.decode(data);
|
||||
return JSON.parse(json);
|
||||
},
|
||||
getSupportedType: () => 'scene'
|
||||
}
|
||||
];
|
||||
}
|
||||
getSerializers(): ISerializer[] {
|
||||
return [
|
||||
{
|
||||
serialize: (data: any) => {
|
||||
const json = JSON.stringify(data);
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(json);
|
||||
},
|
||||
deserialize: (data: Uint8Array) => {
|
||||
const decoder = new TextDecoder();
|
||||
const json = decoder.decode(data);
|
||||
return JSON.parse(json);
|
||||
},
|
||||
getSupportedType: () => 'scene'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Editor is ready');
|
||||
}
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Editor is ready');
|
||||
}
|
||||
|
||||
async onProjectOpen(projectPath: string): Promise<void> {
|
||||
console.log(`[SceneInspectorPlugin] Project opened: ${projectPath}`);
|
||||
}
|
||||
async onProjectOpen(projectPath: string): Promise<void> {
|
||||
console.log(`[SceneInspectorPlugin] Project opened: ${projectPath}`);
|
||||
}
|
||||
|
||||
async onProjectClose(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Project closed');
|
||||
}
|
||||
async onProjectClose(): Promise<void> {
|
||||
console.log('[SceneInspectorPlugin] Project closed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
const entries = await TauriAPI.listDirectory(pluginsPath);
|
||||
const pluginDirs = entries.filter(entry => entry.is_dir && !entry.name.startsWith('.'));
|
||||
console.log('[PluginLoader] Found plugin directories:', pluginDirs.map(d => d.name));
|
||||
const pluginDirs = entries.filter((entry) => entry.is_dir && !entry.name.startsWith('.'));
|
||||
console.log('[PluginLoader] Found plugin directories:', pluginDirs.map((d) => d.name));
|
||||
|
||||
for (const entry of pluginDirs) {
|
||||
const pluginPath = `${pluginsPath}/${entry.name}`;
|
||||
@@ -101,14 +101,14 @@ export class PluginLoader {
|
||||
console.log(`[PluginLoader] Loading plugin from: ${moduleUrl}`);
|
||||
|
||||
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||
console.log(`[PluginLoader] Module loaded successfully`);
|
||||
console.log('[PluginLoader] Module loaded successfully');
|
||||
|
||||
let pluginInstance: IEditorPlugin | null = null;
|
||||
try {
|
||||
pluginInstance = this.findPluginInstance(module);
|
||||
} catch (findError) {
|
||||
console.error(`[PluginLoader] Error finding plugin instance:`, findError);
|
||||
console.error(`[PluginLoader] Module object:`, module);
|
||||
console.error('[PluginLoader] Error finding plugin instance:', findError);
|
||||
console.error('[PluginLoader] Module object:', module);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,14 +139,14 @@ export class PluginLoader {
|
||||
messageHub.publish('locale:changed', { locale: localeService.getCurrentLocale() });
|
||||
console.log(`[PluginLoader] Published locale:changed event for plugin ${packageJson.name}`);
|
||||
} catch (error) {
|
||||
console.warn(`[PluginLoader] Failed to publish locale:changed event:`, error);
|
||||
console.warn('[PluginLoader] Failed to publish locale:changed event:', error);
|
||||
}
|
||||
|
||||
console.log(`[PluginLoader] Successfully loaded plugin: ${packageJson.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);
|
||||
if (error instanceof Error) {
|
||||
console.error(`[PluginLoader] Error stack:`, error.stack);
|
||||
console.error('[PluginLoader] Error stack:', error.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,410 +57,410 @@ export interface ProfilerData {
|
||||
type ProfilerDataListener = (data: ProfilerData) => void;
|
||||
|
||||
export class ProfilerService {
|
||||
private ws: WebSocket | null = null;
|
||||
private isServerRunning = false;
|
||||
private wsPort: string;
|
||||
private listeners: Set<ProfilerDataListener> = new Set();
|
||||
private currentData: ProfilerData | null = null;
|
||||
private checkServerInterval: NodeJS.Timeout | null = null;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private clientIdMap: Map<string, string> = new Map(); // 客户端地址 -> 客户端ID映射
|
||||
private autoStart: boolean;
|
||||
private ws: WebSocket | null = null;
|
||||
private isServerRunning = false;
|
||||
private wsPort: string;
|
||||
private listeners: Set<ProfilerDataListener> = new Set();
|
||||
private currentData: ProfilerData | null = null;
|
||||
private checkServerInterval: NodeJS.Timeout | null = null;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private clientIdMap: Map<string, string> = new Map(); // 客户端地址 -> 客户端ID映射
|
||||
private autoStart: boolean;
|
||||
|
||||
constructor() {
|
||||
const settings = SettingsService.getInstance();
|
||||
this.wsPort = settings.get('profiler.port', '8080');
|
||||
this.autoStart = settings.get('profiler.autoStart', true);
|
||||
constructor() {
|
||||
const settings = SettingsService.getInstance();
|
||||
this.wsPort = settings.get('profiler.port', '8080');
|
||||
this.autoStart = settings.get('profiler.autoStart', true);
|
||||
|
||||
this.startServerCheck();
|
||||
this.listenToSettingsChanges();
|
||||
this.startServerCheck();
|
||||
this.listenToSettingsChanges();
|
||||
|
||||
// 如果设置了自动启动,则启动服务器
|
||||
if (this.autoStart) {
|
||||
this.manualStartServer();
|
||||
}
|
||||
}
|
||||
|
||||
private listenToSettingsChanges(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort && newPort !== this.wsPort) {
|
||||
this.wsPort = newPort;
|
||||
this.reconnectWithNewPort();
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
private async reconnectWithNewPort(): Promise<void> {
|
||||
this.disconnect();
|
||||
|
||||
if (this.checkServerInterval) {
|
||||
clearInterval(this.checkServerInterval);
|
||||
this.checkServerInterval = null;
|
||||
// 如果设置了自动启动,则启动服务器
|
||||
if (this.autoStart) {
|
||||
this.manualStartServer();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('stop_profiler_server');
|
||||
this.isServerRunning = false;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to stop server:', error);
|
||||
private listenToSettingsChanges(): void {
|
||||
window.addEventListener('settings:changed', ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort && newPort !== this.wsPort) {
|
||||
this.wsPort = newPort;
|
||||
this.reconnectWithNewPort();
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
this.startServerCheck();
|
||||
}
|
||||
private async reconnectWithNewPort(): Promise<void> {
|
||||
this.disconnect();
|
||||
|
||||
public subscribe(listener: ProfilerDataListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
if (this.checkServerInterval) {
|
||||
clearInterval(this.checkServerInterval);
|
||||
this.checkServerInterval = null;
|
||||
}
|
||||
|
||||
// 如果已有数据,立即发送给新订阅者
|
||||
if (this.currentData) {
|
||||
listener(this.currentData);
|
||||
try {
|
||||
await invoke('stop_profiler_server');
|
||||
this.isServerRunning = false;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to stop server:', error);
|
||||
}
|
||||
|
||||
this.startServerCheck();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
public subscribe(listener: ProfilerDataListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
// 如果已有数据,立即发送给新订阅者
|
||||
if (this.currentData) {
|
||||
listener(this.currentData);
|
||||
}
|
||||
|
||||
public isServerActive(): boolean {
|
||||
return this.isServerRunning;
|
||||
}
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
public isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
public isServerActive(): boolean {
|
||||
return this.isServerRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动启动服务器
|
||||
*/
|
||||
public async manualStartServer(): Promise<void> {
|
||||
await this.startServer();
|
||||
}
|
||||
public async manualStartServer(): Promise<void> {
|
||||
await this.startServer();
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 手动停止服务器
|
||||
*/
|
||||
public async manualStopServer(): Promise<void> {
|
||||
try {
|
||||
await invoke('stop_profiler_server');
|
||||
this.isServerRunning = false;
|
||||
this.disconnect();
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to stop server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private startServerCheck(): void {
|
||||
this.checkServerStatus();
|
||||
this.checkServerInterval = setInterval(() => {
|
||||
this.checkServerStatus();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async checkServerStatus(): Promise<void> {
|
||||
try {
|
||||
const status = await invoke<boolean>('get_profiler_status');
|
||||
const wasRunning = this.isServerRunning;
|
||||
this.isServerRunning = status;
|
||||
|
||||
// 服务器启动了,尝试连接
|
||||
if (status && !this.ws) {
|
||||
this.connectToServer();
|
||||
}
|
||||
|
||||
// 服务器从运行变为停止
|
||||
if (wasRunning && !status) {
|
||||
this.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.isServerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(): Promise<void> {
|
||||
try {
|
||||
const port = parseInt(this.wsPort);
|
||||
await invoke<string>('start_profiler_server', { port });
|
||||
this.isServerRunning = true;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private connectToServer(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(`ws://localhost:${this.wsPort}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.notifyListeners(this.createEmptyData());
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
|
||||
// 通知监听器连接已断开
|
||||
if (this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
|
||||
// 如果服务器还在运行,尝试重连
|
||||
if (this.isServerRunning && !this.reconnectTimeout) {
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.connectToServer();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[ProfilerService] WebSocket error:', error);
|
||||
this.ws = null;
|
||||
|
||||
// 通知监听器连接出错
|
||||
if (this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
public async manualStopServer(): Promise<void> {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'debug_data' && message.data) {
|
||||
this.handleDebugData(message.data);
|
||||
} else if (message.type === 'get_raw_entity_list_response' && message.data) {
|
||||
this.handleRawEntityListResponse(message.data);
|
||||
} else if (message.type === 'get_entity_details_response' && message.data) {
|
||||
this.handleEntityDetailsResponse(message.data);
|
||||
} else if (message.type === 'log' && message.data) {
|
||||
this.handleRemoteLog(message.data);
|
||||
}
|
||||
await invoke('stop_profiler_server');
|
||||
this.isServerRunning = false;
|
||||
this.disconnect();
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to parse message:', error);
|
||||
console.error('[ProfilerService] Failed to stop server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws = ws;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to create WebSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public requestEntityDetails(entityId: number): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = {
|
||||
type: 'get_entity_details',
|
||||
requestId: `entity_details_${entityId}_${Date.now()}`,
|
||||
entityId
|
||||
};
|
||||
this.ws.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to request entity details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDebugData(debugData: any): void {
|
||||
const performance = debugData.performance;
|
||||
if (!performance) return;
|
||||
|
||||
const totalFrameTime = performance.frameTime || 0;
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
|
||||
let systems: SystemPerformanceData[] = [];
|
||||
if (performance.systemPerformance && Array.isArray(performance.systemPerformance)) {
|
||||
systems = performance.systemPerformance
|
||||
.map((sys: any) => ({
|
||||
name: sys.systemName,
|
||||
executionTime: sys.lastExecutionTime || sys.averageTime || 0,
|
||||
entityCount: sys.entityCount || 0,
|
||||
averageTime: sys.averageTime || 0,
|
||||
percentage: 0
|
||||
}))
|
||||
.sort((a: SystemPerformanceData, b: SystemPerformanceData) =>
|
||||
b.executionTime - a.executionTime
|
||||
);
|
||||
|
||||
const totalTime = performance.frameTime || 1;
|
||||
systems.forEach((sys: SystemPerformanceData) => {
|
||||
sys.percentage = (sys.executionTime / totalTime) * 100;
|
||||
});
|
||||
private startServerCheck(): void {
|
||||
this.checkServerStatus();
|
||||
this.checkServerInterval = setInterval(() => {
|
||||
this.checkServerStatus();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const entityCount = debugData.entities?.totalEntities || debugData.entities?.totalCount || 0;
|
||||
const componentTypes = debugData.components?.types || [];
|
||||
const componentCount = componentTypes.length;
|
||||
private async checkServerStatus(): Promise<void> {
|
||||
try {
|
||||
const status = await invoke<boolean>('get_profiler_status');
|
||||
const wasRunning = this.isServerRunning;
|
||||
this.isServerRunning = status;
|
||||
|
||||
this.currentData = {
|
||||
totalFrameTime,
|
||||
systems,
|
||||
entityCount,
|
||||
componentCount,
|
||||
fps,
|
||||
entities: []
|
||||
};
|
||||
// 服务器启动了,尝试连接
|
||||
if (status && !this.ws) {
|
||||
this.connectToServer();
|
||||
}
|
||||
|
||||
this.notifyListeners(this.currentData);
|
||||
|
||||
// 请求完整的实体列表
|
||||
this.requestRawEntityList();
|
||||
}
|
||||
|
||||
private requestRawEntityList(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
// 服务器从运行变为停止
|
||||
if (wasRunning && !status) {
|
||||
this.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.isServerRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const request = {
|
||||
type: 'get_raw_entity_list',
|
||||
requestId: `entity_list_${Date.now()}`
|
||||
};
|
||||
this.ws.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to request entity list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRawEntityListResponse(data: any): void {
|
||||
if (!data || !Array.isArray(data)) {
|
||||
return;
|
||||
private async startServer(): Promise<void> {
|
||||
try {
|
||||
const port = parseInt(this.wsPort);
|
||||
await invoke<string>('start_profiler_server', { port });
|
||||
this.isServerRunning = true;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const entities: RemoteEntity[] = data.map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || `Entity ${e.id}`,
|
||||
enabled: e.enabled !== false,
|
||||
active: e.active !== false,
|
||||
activeInHierarchy: e.activeInHierarchy !== false,
|
||||
componentCount: e.componentCount || 0,
|
||||
componentTypes: e.componentTypes || [],
|
||||
parentId: e.parentId || null,
|
||||
childIds: e.childIds || [],
|
||||
depth: e.depth || 0,
|
||||
tag: e.tag || 0,
|
||||
updateOrder: e.updateOrder || 0
|
||||
}));
|
||||
private connectToServer(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
if (this.currentData) {
|
||||
this.currentData.entities = entities;
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ws = new WebSocket(`ws://localhost:${this.wsPort}`);
|
||||
|
||||
private handleEntityDetailsResponse(data: any): void {
|
||||
if (!data) {
|
||||
return;
|
||||
ws.onopen = () => {
|
||||
this.notifyListeners(this.createEmptyData());
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
|
||||
// 通知监听器连接已断开
|
||||
if (this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
|
||||
// 如果服务器还在运行,尝试重连
|
||||
if (this.isServerRunning && !this.reconnectTimeout) {
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.connectToServer();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[ProfilerService] WebSocket error:', error);
|
||||
this.ws = null;
|
||||
|
||||
// 通知监听器连接出错
|
||||
if (this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'debug_data' && message.data) {
|
||||
this.handleDebugData(message.data);
|
||||
} else if (message.type === 'get_raw_entity_list_response' && message.data) {
|
||||
this.handleRawEntityListResponse(message.data);
|
||||
} else if (message.type === 'get_entity_details_response' && message.data) {
|
||||
this.handleEntityDetailsResponse(message.data);
|
||||
} else if (message.type === 'log' && message.data) {
|
||||
this.handleRemoteLog(message.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws = ws;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to create WebSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const entityDetails: RemoteEntityDetails = {
|
||||
id: data.id,
|
||||
name: data.name || `Entity ${data.id}`,
|
||||
enabled: data.enabled !== false,
|
||||
active: data.active !== false,
|
||||
activeInHierarchy: data.activeInHierarchy !== false,
|
||||
scene: data.scene || '',
|
||||
sceneName: data.sceneName || '',
|
||||
sceneType: data.sceneType || '',
|
||||
componentCount: data.componentCount || 0,
|
||||
componentTypes: data.componentTypes || [],
|
||||
components: data.components || [],
|
||||
parentName: data.parentName || null
|
||||
};
|
||||
public requestEntityDetails(entityId: number): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('profiler:entity-details', {
|
||||
detail: entityDetails
|
||||
}));
|
||||
}
|
||||
|
||||
private handleRemoteLog(data: any): void {
|
||||
if (!data) {
|
||||
return;
|
||||
try {
|
||||
const request = {
|
||||
type: 'get_entity_details',
|
||||
requestId: `entity_details_${entityId}_${Date.now()}`,
|
||||
entityId
|
||||
};
|
||||
this.ws.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to request entity details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const levelMap: Record<string, LogLevel> = {
|
||||
'debug': LogLevel.Debug,
|
||||
'info': LogLevel.Info,
|
||||
'warn': LogLevel.Warn,
|
||||
'error': LogLevel.Error,
|
||||
'fatal': LogLevel.Fatal
|
||||
};
|
||||
private handleDebugData(debugData: any): void {
|
||||
const performance = debugData.performance;
|
||||
if (!performance) return;
|
||||
|
||||
const level = levelMap[data.level?.toLowerCase() || 'info'] || LogLevel.Info;
|
||||
const totalFrameTime = performance.frameTime || 0;
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
|
||||
let message = data.message || '';
|
||||
if (typeof message === 'object') {
|
||||
try {
|
||||
message = JSON.stringify(message, null, 2);
|
||||
} catch {
|
||||
message = String(message);
|
||||
}
|
||||
let systems: SystemPerformanceData[] = [];
|
||||
if (performance.systemPerformance && Array.isArray(performance.systemPerformance)) {
|
||||
systems = performance.systemPerformance
|
||||
.map((sys: any) => ({
|
||||
name: sys.systemName,
|
||||
executionTime: sys.lastExecutionTime || sys.averageTime || 0,
|
||||
entityCount: sys.entityCount || 0,
|
||||
averageTime: sys.averageTime || 0,
|
||||
percentage: 0
|
||||
}))
|
||||
.sort((a: SystemPerformanceData, b: SystemPerformanceData) =>
|
||||
b.executionTime - a.executionTime
|
||||
);
|
||||
|
||||
const totalTime = performance.frameTime || 1;
|
||||
systems.forEach((sys: SystemPerformanceData) => {
|
||||
sys.percentage = (sys.executionTime / totalTime) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
const entityCount = debugData.entities?.totalEntities || debugData.entities?.totalCount || 0;
|
||||
const componentTypes = debugData.components?.types || [];
|
||||
const componentCount = componentTypes.length;
|
||||
|
||||
this.currentData = {
|
||||
totalFrameTime,
|
||||
systems,
|
||||
entityCount,
|
||||
componentCount,
|
||||
fps,
|
||||
entities: []
|
||||
};
|
||||
|
||||
this.notifyListeners(this.currentData);
|
||||
|
||||
// 请求完整的实体列表
|
||||
this.requestRawEntityList();
|
||||
}
|
||||
|
||||
const clientId = data.clientId || data.client_id || 'unknown';
|
||||
private requestRawEntityList(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('profiler:remote-log', {
|
||||
detail: {
|
||||
level,
|
||||
message,
|
||||
timestamp: data.timestamp ? new Date(data.timestamp) : new Date(),
|
||||
clientId
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private createEmptyData(): ProfilerData {
|
||||
return {
|
||||
totalFrameTime: 0,
|
||||
systems: [],
|
||||
entityCount: 0,
|
||||
componentCount: 0,
|
||||
fps: 0
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(data: ProfilerData): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Error in listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
const hadConnection = this.ws !== null;
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
try {
|
||||
const request = {
|
||||
type: 'get_raw_entity_list',
|
||||
requestId: `entity_list_${Date.now()}`
|
||||
};
|
||||
this.ws.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to request entity list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
private handleRawEntityListResponse(data: any): void {
|
||||
if (!data || !Array.isArray(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entities: RemoteEntity[] = data.map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || `Entity ${e.id}`,
|
||||
enabled: e.enabled !== false,
|
||||
active: e.active !== false,
|
||||
activeInHierarchy: e.activeInHierarchy !== false,
|
||||
componentCount: e.componentCount || 0,
|
||||
componentTypes: e.componentTypes || [],
|
||||
parentId: e.parentId || null,
|
||||
childIds: e.childIds || [],
|
||||
depth: e.depth || 0,
|
||||
tag: e.tag || 0,
|
||||
updateOrder: e.updateOrder || 0
|
||||
}));
|
||||
|
||||
if (this.currentData) {
|
||||
this.currentData.entities = entities;
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有连接且手动断开,通知监听器
|
||||
if (hadConnection && this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
}
|
||||
private handleEntityDetailsResponse(data: any): void {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.disconnect();
|
||||
const entityDetails: RemoteEntityDetails = {
|
||||
id: data.id,
|
||||
name: data.name || `Entity ${data.id}`,
|
||||
enabled: data.enabled !== false,
|
||||
active: data.active !== false,
|
||||
activeInHierarchy: data.activeInHierarchy !== false,
|
||||
scene: data.scene || '',
|
||||
sceneName: data.sceneName || '',
|
||||
sceneType: data.sceneType || '',
|
||||
componentCount: data.componentCount || 0,
|
||||
componentTypes: data.componentTypes || [],
|
||||
components: data.components || [],
|
||||
parentName: data.parentName || null
|
||||
};
|
||||
|
||||
if (this.checkServerInterval) {
|
||||
clearInterval(this.checkServerInterval);
|
||||
this.checkServerInterval = null;
|
||||
window.dispatchEvent(new CustomEvent('profiler:entity-details', {
|
||||
detail: entityDetails
|
||||
}));
|
||||
}
|
||||
|
||||
this.listeners.clear();
|
||||
this.currentData = null;
|
||||
}
|
||||
private handleRemoteLog(data: any): void {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levelMap: Record<string, LogLevel> = {
|
||||
'debug': LogLevel.Debug,
|
||||
'info': LogLevel.Info,
|
||||
'warn': LogLevel.Warn,
|
||||
'error': LogLevel.Error,
|
||||
'fatal': LogLevel.Fatal
|
||||
};
|
||||
|
||||
const level = levelMap[data.level?.toLowerCase() || 'info'] || LogLevel.Info;
|
||||
|
||||
let message = data.message || '';
|
||||
if (typeof message === 'object') {
|
||||
try {
|
||||
message = JSON.stringify(message, null, 2);
|
||||
} catch {
|
||||
message = String(message);
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = data.clientId || data.client_id || 'unknown';
|
||||
|
||||
window.dispatchEvent(new CustomEvent('profiler:remote-log', {
|
||||
detail: {
|
||||
level,
|
||||
message,
|
||||
timestamp: data.timestamp ? new Date(data.timestamp) : new Date(),
|
||||
clientId
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private createEmptyData(): ProfilerData {
|
||||
return {
|
||||
totalFrameTime: 0,
|
||||
systems: [],
|
||||
entityCount: 0,
|
||||
componentCount: 0,
|
||||
fps: 0
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(data: ProfilerData): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Error in listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
const hadConnection = this.ws !== null;
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
// 如果有连接且手动断开,通知监听器
|
||||
if (hadConnection && this.currentData) {
|
||||
this.notifyListeners(this.currentData);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.disconnect();
|
||||
|
||||
if (this.checkServerInterval) {
|
||||
clearInterval(this.checkServerInterval);
|
||||
this.checkServerInterval = null;
|
||||
}
|
||||
|
||||
this.listeners.clear();
|
||||
this.currentData = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
export class SettingsService {
|
||||
private static instance: SettingsService;
|
||||
private settings: Map<string, any> = new Map();
|
||||
private storageKey = 'editor-settings';
|
||||
private static instance: SettingsService;
|
||||
private settings: Map<string, any> = new Map();
|
||||
private storageKey = 'editor-settings';
|
||||
|
||||
private constructor() {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
public static getInstance(): SettingsService {
|
||||
if (!SettingsService.instance) {
|
||||
SettingsService.instance = new SettingsService();
|
||||
private constructor() {
|
||||
this.loadSettings();
|
||||
}
|
||||
return SettingsService.instance;
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
this.settings = new Map(Object.entries(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Failed to load settings:', error);
|
||||
public static getInstance(): SettingsService {
|
||||
if (!SettingsService.instance) {
|
||||
SettingsService.instance = new SettingsService();
|
||||
}
|
||||
return SettingsService.instance;
|
||||
}
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
try {
|
||||
const data = Object.fromEntries(this.settings);
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Failed to save settings:', error);
|
||||
private loadSettings(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
this.settings = new Map(Object.entries(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Failed to load settings:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get<T>(key: string, defaultValue: T): T {
|
||||
if (this.settings.has(key)) {
|
||||
return this.settings.get(key) as T;
|
||||
private saveSettings(): void {
|
||||
try {
|
||||
const data = Object.fromEntries(this.settings);
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public set<T>(key: string, value: T): void {
|
||||
this.settings.set(key, value);
|
||||
this.saveSettings();
|
||||
}
|
||||
public get<T>(key: string, defaultValue: T): T {
|
||||
if (this.settings.has(key)) {
|
||||
return this.settings.get(key) as T;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
return this.settings.has(key);
|
||||
}
|
||||
public set<T>(key: string, value: T): void {
|
||||
this.settings.set(key, value);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
public delete(key: string): void {
|
||||
this.settings.delete(key);
|
||||
this.saveSettings();
|
||||
}
|
||||
public has(key: string): boolean {
|
||||
return this.settings.has(key);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.settings.clear();
|
||||
this.saveSettings();
|
||||
}
|
||||
public delete(key: string): void {
|
||||
this.settings.delete(key);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
public getAll(): Record<string, any> {
|
||||
return Object.fromEntries(this.settings);
|
||||
}
|
||||
public clear(): void {
|
||||
this.settings.clear();
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
public getRecentProjects(): string[] {
|
||||
return this.get<string[]>('recentProjects', []);
|
||||
}
|
||||
public getAll(): Record<string, any> {
|
||||
return Object.fromEntries(this.settings);
|
||||
}
|
||||
|
||||
public addRecentProject(projectPath: string): void {
|
||||
const recentProjects = this.getRecentProjects();
|
||||
const filtered = recentProjects.filter(p => p !== projectPath);
|
||||
const updated = [projectPath, ...filtered].slice(0, 10);
|
||||
this.set('recentProjects', updated);
|
||||
}
|
||||
public getRecentProjects(): string[] {
|
||||
return this.get<string[]>('recentProjects', []);
|
||||
}
|
||||
|
||||
public removeRecentProject(projectPath: string): void {
|
||||
const recentProjects = this.getRecentProjects();
|
||||
const filtered = recentProjects.filter(p => p !== projectPath);
|
||||
this.set('recentProjects', filtered);
|
||||
}
|
||||
public addRecentProject(projectPath: string): void {
|
||||
const recentProjects = this.getRecentProjects();
|
||||
const filtered = recentProjects.filter((p) => p !== projectPath);
|
||||
const updated = [projectPath, ...filtered].slice(0, 10);
|
||||
this.set('recentProjects', updated);
|
||||
}
|
||||
|
||||
public clearRecentProjects(): void {
|
||||
this.set('recentProjects', []);
|
||||
}
|
||||
public removeRecentProject(projectPath: string): void {
|
||||
const recentProjects = this.getRecentProjects();
|
||||
const filtered = recentProjects.filter((p) => p !== projectPath);
|
||||
this.set('recentProjects', filtered);
|
||||
}
|
||||
|
||||
public clearRecentProjects(): void {
|
||||
this.set('recentProjects', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,24 +207,24 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
updateNodePosition: (nodeId: string, position: { x: number; y: number }) => set((state: BehaviorTreeState) => ({
|
||||
nodes: state.nodes.map((n: BehaviorTreeNode) =>
|
||||
n.id === nodeId ? { ...n, position } : n
|
||||
),
|
||||
)
|
||||
})),
|
||||
|
||||
updateNodesPosition: (updates: Map<string, { x: number; y: number }>) => set((state: BehaviorTreeState) => ({
|
||||
nodes: state.nodes.map((node: BehaviorTreeNode) => {
|
||||
const newPos = updates.get(node.id);
|
||||
return newPos ? { ...node, position: newPos } : node;
|
||||
}),
|
||||
})
|
||||
})),
|
||||
|
||||
setConnections: (connections: Connection[]) => set({ connections }),
|
||||
|
||||
addConnection: (connection: Connection) => set((state: BehaviorTreeState) => ({
|
||||
connections: [...state.connections, connection],
|
||||
connections: [...state.connections, connection]
|
||||
})),
|
||||
|
||||
removeConnections: (filter: (conn: Connection) => boolean) => set((state: BehaviorTreeState) => ({
|
||||
connections: state.connections.filter(filter),
|
||||
connections: state.connections.filter(filter)
|
||||
})),
|
||||
|
||||
setSelectedNodeIds: (nodeIds: string[]) => set({ selectedNodeIds: nodeIds }),
|
||||
@@ -232,14 +232,14 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
toggleNodeSelection: (nodeId: string) => set((state: BehaviorTreeState) => ({
|
||||
selectedNodeIds: state.selectedNodeIds.includes(nodeId)
|
||||
? state.selectedNodeIds.filter((id: string) => id !== nodeId)
|
||||
: [...state.selectedNodeIds, nodeId],
|
||||
: [...state.selectedNodeIds, nodeId]
|
||||
})),
|
||||
|
||||
clearSelection: () => set({ selectedNodeIds: [] }),
|
||||
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => set({
|
||||
draggingNodeId: nodeId,
|
||||
dragStartPositions: startPositions,
|
||||
dragStartPositions: startPositions
|
||||
}),
|
||||
|
||||
stopDragging: () => set({ draggingNodeId: null }),
|
||||
@@ -267,7 +267,7 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
clearConnecting: () => set({
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null,
|
||||
connectingToPos: null
|
||||
}),
|
||||
|
||||
// 框选 Actions
|
||||
@@ -280,7 +280,7 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
clearBoxSelect: () => set({
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null,
|
||||
boxSelectEnd: null
|
||||
}),
|
||||
|
||||
// 拖动偏移 Actions
|
||||
@@ -306,9 +306,9 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
// 自动排序子节点(按X坐标从左到右)
|
||||
sortChildrenByPosition: () => set((state: BehaviorTreeState) => {
|
||||
const nodeMap = new Map<string, BehaviorTreeNode>();
|
||||
state.nodes.forEach(node => nodeMap.set(node.id, node));
|
||||
state.nodes.forEach((node) => nodeMap.set(node.id, node));
|
||||
|
||||
const sortedNodes = state.nodes.map(node => {
|
||||
const sortedNodes = state.nodes.map((node) => {
|
||||
if (node.children.length <= 1) {
|
||||
return node;
|
||||
}
|
||||
@@ -366,7 +366,7 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
const className = node.template?.className;
|
||||
if (className) {
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
const latestTemplate = allTemplates.find(t => t.className === className);
|
||||
const latestTemplate = allTemplates.find((t) => t.className === className);
|
||||
|
||||
if (latestTemplate) {
|
||||
return {
|
||||
|
||||
@@ -118,7 +118,7 @@ export class BehaviorTreeExecutor {
|
||||
blackboard: Record<string, any>,
|
||||
connections: Array<{ from: string; to: string; fromProperty?: string; toProperty?: string; connectionType: 'node' | 'property' }>
|
||||
): BehaviorTreeData {
|
||||
const rootNode = nodes.find(n => n.id === rootNodeId);
|
||||
const rootNode = nodes.find((n) => n.id === rootNodeId);
|
||||
if (!rootNode) {
|
||||
throw new Error('未找到根节点');
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class BehaviorTreeExecutor {
|
||||
for (const conn of connections) {
|
||||
if (conn.connectionType === 'property' && conn.toProperty) {
|
||||
const targetNodeData = treeData.nodes.get(conn.to);
|
||||
const sourceNode = nodes.find(n => n.id === conn.from);
|
||||
const sourceNode = nodes.find((n) => n.id === conn.from);
|
||||
|
||||
if (targetNodeData && sourceNode) {
|
||||
// 检查源节点是否是黑板变量节点
|
||||
|
||||
Reference in New Issue
Block a user