Feature/render pipeline (#232)

* refactor(engine): 重构2D渲染管线坐标系统

* feat(engine): 完善2D渲染管线和编辑器视口功能

* feat(editor): 实现Viewport变换工具系统

* feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示

* feat(editor): 实现Run on Device移动预览功能

* feat(editor): 添加组件属性控制和依赖关系系统

* feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器

* feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(ci): 迁移项目到pnpm并修复CI构建问题

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 移除 network 相关包

* chore: 移除 network 相关包
This commit is contained in:
YHH
2025-11-23 14:49:37 +08:00
committed by GitHub
parent b15cbab313
commit a3f7cc38b1
247 changed files with 33561 additions and 52047 deletions

View File

@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
@@ -83,7 +84,7 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
} else {
setAssets([]);
setCurrentPath(null);
setSelectedPath(null);
setSelectedPaths(new Set());
}
}, [projectPath]);
@@ -92,21 +93,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const messageHub = Core.services.resolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
const unsubscribe = messageHub.subscribe('asset:reveal', async (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);
// Load assets first, then set selection after list is populated
await loadAssets(dirPath);
setSelectedPaths(new Set([filePath]));
// Expand tree to reveal the file
if (showDetailView) {
detailViewFileTreeRef.current?.revealPath(filePath);
} else {
treeOnlyViewFileTreeRef.current?.revealPath(filePath);
}
}
}
});
return () => unsubscribe();
}, []);
}, [showDetailView]);
const loadAssets = async (path: string) => {
setLoading(true);
@@ -211,8 +220,57 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
loadAssets(path);
};
const handleAssetClick = (asset: AssetItem) => {
setSelectedPath(asset.path);
const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => {
if (paths.length === 0) return;
const path = paths[0];
if (!path) return;
if (modifiers.shiftKey && paths.length > 1) {
// Range select - paths already contains the range from FileTree
setSelectedPaths(new Set(paths));
} else if (modifiers.ctrlKey) {
const newSelected = new Set(selectedPaths);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
}
setSelectedPaths(newSelected);
setLastSelectedPath(path);
} else {
setSelectedPaths(new Set([path]));
setLastSelectedPath(path);
}
};
const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => {
const filteredAssets = searchQuery.trim() ? searchResults : assets;
if (e.shiftKey && lastSelectedPath) {
// Range select with Shift
const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath);
const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path);
setSelectedPaths(new Set(rangePaths));
}
} else if (e.ctrlKey || e.metaKey) {
// Multi-select with Ctrl/Cmd
const newSelected = new Set(selectedPaths);
if (newSelected.has(asset.path)) {
newSelected.delete(asset.path);
} else {
newSelected.add(asset.path);
}
setSelectedPaths(newSelected);
setLastSelectedPath(asset.path);
} else {
// Single select
setSelectedPaths(new Set([asset.path]));
setLastSelectedPath(asset.path);
}
messageHub?.publish('asset-file:selected', {
fileInfo: {
@@ -275,8 +333,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
// 更新选中路径
if (selectedPath === asset.path) {
setSelectedPath(newPath);
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
newSelected.add(newPath);
setSelectedPaths(newSelected);
}
setRenameDialog(null);
@@ -300,8 +361,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
// 清除选中状态
if (selectedPath === asset.path) {
setSelectedPath(null);
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
setSelectedPaths(newSelected);
}
setDeleteConfirmDialog(null);
@@ -637,100 +700,120 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
</div>
}
rightOrBottom={
<div className="asset-browser-list">
<div className="asset-browser-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => {
setCurrentPath(crumb.path);
loadAssets(crumb.path);
}}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
{(loading || isSearching) ? (
<div className="asset-browser-loading">
<p>{isSearching ? '搜索中...' : t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => {
const relativePath = getRelativePath(asset.path);
const showPath = searchQuery.trim() && relativePath;
return (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
<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);
}}
>
{getFileIcon(asset)}
<div className="asset-info">
<div className="asset-name" title={asset.path}>
{asset.name}
</div>
{showPath && (
<div className="asset-path" style={{
fontSize: '11px',
color: '#666',
marginTop: '2px'
}}>
{relativePath}
</div>
)}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
);
})}
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
)}
</div>
}
/>
{(loading || isSearching) ? (
<div className="asset-browser-loading">
<p>{isSearching ? '搜索中...' : t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => {
const relativePath = getRelativePath(asset.path);
const showPath = searchQuery.trim() && relativePath;
return (
<div
key={index}
className={`asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
onClick={(e) => handleAssetClick(asset, e)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// Get all selected file assets
const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1
? Array.from(selectedPaths)
.filter((p) => {
const a = assets?.find((item) => item.path === p);
return a && a.type === 'file';
})
.map((p) => {
const a = assets?.find((item) => item.path === p);
return { type: 'file', path: p, name: a?.name, extension: a?.extension };
})
: [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }];
// Set drag data as JSON array for multi-file support
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
if (selectedFiles.length > 1) {
dragImage.textContent = `${selectedFiles.length} files`;
}
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
}}
>
{getFileIcon(asset)}
<div className="asset-info">
<div className="asset-name" title={asset.path}>
{asset.name}
</div>
{showPath && (
<div className="asset-path" style={{
fontSize: '11px',
color: '#666',
marginTop: '2px'
}}>
{relativePath}
</div>
)}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
);
})}
</div>
)}
</div>
}
/>
) : (
<div className="asset-browser-tree-only">
<FileTree
ref={treeOnlyViewFileTreeRef}
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
onSelectFiles={handleTreeMultiSelect}
selectedPath={Array.from(selectedPaths)[0] || currentPath}
selectedPaths={selectedPaths}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={true}

View File

@@ -40,7 +40,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
try {
const registry = Core.services.resolve(CompilerRegistry);
console.log('[CompilerConfigDialog] Registry resolved:', registry);
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
const comp = registry.get(compilerId);
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
setCompiler(comp || null);
@@ -74,7 +74,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
},
listDirectory: async (path: string): Promise<FileEntry[]> => {
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
return entries.map(e => ({
return entries.map((e) => ({
name: e.name,
path: e.path,
isDirectory: e.is_dir
@@ -96,8 +96,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
const ext = pattern.replace(/\*/g, '');
return entries
.filter(e => !e.is_dir && e.name.endsWith(ext))
.map(e => e.name.replace(ext, ''));
.filter((e) => !e.is_dir && e.name.endsWith(ext))
.map((e) => e.name.replace(ext, ''));
}
});

View File

@@ -19,10 +19,20 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
useEffect(() => {
const handleSelection = (data: { entity: Entity | null }) => {
setSelectedEntity(data.entity);
setSelectedEntity((prev) => {
// Only reset version when selecting a different entity
// 只在选择不同实体时重置版本
if (prev?.id !== data.entity?.id) {
setComponentVersion(0);
} else {
// Same entity re-selected, trigger refresh
// 同一实体重新选择,触发刷新
setComponentVersion((v) => v + 1);
}
return data.entity;
});
setRemoteEntity(null);
setRemoteEntityDetails(null);
setComponentVersion(0);
};
const handleRemoteSelection = (data: { entity: any }) => {
@@ -45,6 +55,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
@@ -53,6 +64,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
unsubRemoteSelect();
unsubComponentAdded();
unsubComponentRemoved();
unsubPropertyChanged();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub]);
@@ -80,6 +92,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
if (!selectedEntity) return;
// Actually update the component property
// 实际更新组件属性
component[propertyName] = value;
messageHub.publish('component:property:changed', {
entity: selectedEntity,
component,
@@ -500,6 +517,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
{isExpanded && (
<div className="component-properties animate-slideDown">
<PropertyInspector
key={`${index}-${componentVersion}`}
component={component}
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
/>

View File

@@ -22,7 +22,9 @@ interface TreeNode {
interface FileTreeProps {
rootPath: string | null;
onSelectFile?: (path: string) => void;
onSelectFiles?: (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => void;
selectedPath?: string | null;
selectedPaths?: Set<string>;
messageHub?: MessageHub;
searchQuery?: string;
showFiles?: boolean;
@@ -31,12 +33,30 @@ interface FileTreeProps {
export interface FileTreeHandle {
collapseAll: () => void;
refresh: () => void;
revealPath: (targetPath: string) => Promise<void>;
}
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, onSelectFiles, selectedPath, selectedPaths, messageHub, searchQuery, showFiles = true }, ref) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null);
// Flatten visible file nodes for range selection
const getVisibleFilePaths = (nodes: TreeNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: TreeNode[]) => {
for (const node of nodeList) {
if (node.type === 'file') {
paths.push(node.path);
} else if (node.type === 'folder' && node.expanded && node.children) {
traverse(node.children);
}
}
};
traverse(nodes);
return paths;
};
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
node: TreeNode | null;
@@ -49,7 +69,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
parentPath: string;
templateExtension?: string;
templateContent?: (fileName: string) => Promise<string>;
} | null>(null);
} | null>(null);
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
@@ -65,13 +85,84 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return node;
};
const collapsedTree = tree.map(node => collapseNode(node));
const collapsedTree = tree.map((node) => collapseNode(node));
setTree(collapsedTree);
};
// Expand tree to reveal a specific file path
const revealPath = async (targetPath: string) => {
if (!rootPath || !targetPath.startsWith(rootPath)) return;
// Get path segments between root and target
const relativePath = targetPath.substring(rootPath.length).replace(/^[/\\]/, '');
const segments = relativePath.split(/[/\\]/);
// Build list of folder paths to expand
const pathsToExpand: string[] = [];
let currentPath = rootPath;
for (let i = 0; i < segments.length - 1; i++) {
currentPath = `${currentPath}/${segments[i]}`;
pathsToExpand.push(currentPath.replace(/\//g, '\\'));
}
// Recursively expand nodes and load children
const expandToPath = async (nodes: TreeNode[], pathSet: Set<string>): Promise<TreeNode[]> => {
const result: TreeNode[] = [];
for (const node of nodes) {
const normalizedPath = node.path.replace(/\//g, '\\');
if (node.type === 'folder' && pathSet.has(normalizedPath)) {
// Load children if not loaded
let children = node.children;
if (!node.loaded || !children) {
try {
const entries = await TauriAPI.listDirectory(node.path);
children = entries.map((entry: DirectoryEntry) => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
size: entry.size,
modified: entry.modified,
expanded: false,
loaded: false
})).sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
} catch (error) {
children = [];
}
}
// Recursively expand children
const expandedChildren = await expandToPath(children, pathSet);
result.push({
...node,
expanded: true,
loaded: true,
children: expandedChildren
});
} else if (node.type === 'folder' && node.children) {
// Keep existing state for non-target folders
result.push({
...node,
children: await expandToPath(node.children, pathSet)
});
} else {
result.push(node);
}
}
return result;
};
const pathSet = new Set(pathsToExpand);
const expandedTree = await expandToPath(tree, pathSet);
setTree(expandedTree);
setInternalSelectedPath(targetPath);
};
useImperativeHandle(ref, () => ({
collapseAll,
refresh: refreshTree
refresh: refreshTree,
revealPath
}));
useEffect(() => {
@@ -92,8 +183,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
const performSearch = async () => {
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter(node => showFiles || node.type === 'folder')
.map(node => ({
.filter((node) => showFiles || node.type === 'folder')
.map((node) => ({
...node,
children: node.children ? filterByFileType(node.children) : node.children
}));
@@ -280,7 +371,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
children = await loadChildren(node);
}
const restoredChildren = await Promise.all(
children.map(child => restoreExpandedState(child))
children.map((child) => restoreExpandedState(child))
);
return {
...node,
@@ -290,7 +381,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
} else if (node.type === 'folder' && node.children) {
const restoredChildren = await Promise.all(
node.children.map(child => restoreExpandedState(child))
node.children.map((child) => restoreExpandedState(child))
);
return {
...node,
@@ -325,7 +416,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
}
const expandedChildren = await Promise.all(
children.map(child => expandNode(child))
children.map((child) => expandNode(child))
);
return {
@@ -338,7 +429,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return node;
};
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
const expandedTree = await Promise.all(tree.map((node) => expandNode(node)));
setTree(expandedTree);
};
@@ -574,13 +665,39 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return items;
};
const handleNodeClick = (node: TreeNode) => {
const handleNodeClick = (node: TreeNode, e: React.MouseEvent) => {
if (node.type === 'folder') {
setInternalSelectedPath(node.path);
onSelectFile?.(node.path);
toggleNode(node.path);
} else {
setInternalSelectedPath(node.path);
// Support multi-select with Ctrl/Cmd or Shift
if (onSelectFiles) {
if (e.shiftKey && lastSelectedFilePath) {
// Range select with Shift
const treeToUse = searchQuery ? filteredTree : tree;
const visiblePaths = getVisibleFilePaths(treeToUse);
const lastIndex = visiblePaths.indexOf(lastSelectedFilePath);
const currentIndex = visiblePaths.indexOf(node.path);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangePaths = visiblePaths.slice(start, end + 1);
onSelectFiles(rangePaths, { ctrlKey: false, shiftKey: true });
} else {
onSelectFiles([node.path], { ctrlKey: false, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
onSelectFiles([node.path], { ctrlKey: e.ctrlKey || e.metaKey, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
setLastSelectedFilePath(node.path);
}
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
messageHub?.publish('asset-file:selected', {
fileInfo: {
@@ -622,7 +739,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
const renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = (internalSelectedPath || selectedPath) === node.path;
const isSelected = selectedPaths
? selectedPaths.has(node.path)
: (internalSelectedPath || selectedPath) === node.path;
const isRenaming = renamingNode === node.path;
const indent = level * 16;
@@ -631,14 +750,30 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
onClick={() => !isRenaming && handleNodeClick(node)}
onClick={(e) => !isRenaming && handleNodeClick(node, e)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
draggable={node.type === 'file' && !isRenaming}
onDragStart={(e) => {
if (node.type === 'file' && !isRenaming) {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
// Get all selected files for multi-file drag
const selectedFiles = selectedPaths && selectedPaths.has(node.path) && selectedPaths.size > 1
? Array.from(selectedPaths).map((p) => {
const name = p.split(/[/\\]/).pop() || '';
const ext = name.includes('.') ? name.split('.').pop() : '';
return { type: 'file', path: p, name, extension: ext };
})
: [{
type: 'file',
path: node.path,
name: node.name,
extension: node.name.includes('.') ? node.name.split('.').pop() : ''
}];
// Set drag data as JSON array for multi-file support
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
e.dataTransfer.setData('asset-path', node.path);
e.dataTransfer.setData('asset-name', node.name);
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
@@ -748,18 +883,18 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<PromptDialog
title={
promptDialog.type === 'create-file' ? '新建文件' :
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
}
message={
promptDialog.type === 'create-file' ? '请输入文件名:' :
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
}
placeholder={
promptDialog.type === 'create-file' ? '例如: config.json' :
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
}
confirmText="创建"
cancelText="取消"

View File

@@ -33,11 +33,11 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
useEffect(() => {
try {
// 检查面板ID列表是否真的变化了而不只是标题等属性变化
const currentPanelIds = panels.map(p => p.id).sort().join(',');
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
const previousIds = previousPanelIdsRef.current;
// 检查标题是否变化
const currentTitles = new Map(panels.map(p => [p.id, p.title]));
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
const titleChanges: Array<{ id: string; newTitle: string }> = [];
for (const panel of panels) {
@@ -66,17 +66,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
}
// 计算新增和移除的面板
const prevSet = new Set(previousIds.split(',').filter(id => id));
const currSet = new Set(currentPanelIds.split(',').filter(id => id));
const newPanelIds = Array.from(currSet).filter(id => !prevSet.has(id));
const removedPanelIds = Array.from(prevSet).filter(id => !currSet.has(id));
const prevSet = new Set(previousIds.split(',').filter((id) => id));
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
previousPanelIdsRef.current = currentPanelIds;
// 如果已经有布局且只是添加新面板使用Action动态添加
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
// 找到要添加的面板
const newPanels = panels.filter(p => newPanelIds.includes(p.id));
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
// 找到中心区域的tabset ID
let centerTabsetId: string | null = null;
@@ -101,7 +101,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
if (centerTabsetId) {
// 动态添加tab到中心tabset
newPanels.forEach(panel => {
newPanels.forEach((panel) => {
model.doAction(Actions.addNode(
{
type: 'tab',

View File

@@ -868,7 +868,7 @@ function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: Proper
const keys = Object.keys(val);
if (keys.length === 0) return '{}';
if (keys.length <= 2) {
const preview = keys.map(k => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
return `{${preview}}`;
}
return `{${keys.slice(0, 2).join(', ')}...}`;
@@ -996,7 +996,7 @@ function ImagePreview({ src, alt }: ImagePreviewProps) {
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setScale(prev => Math.min(Math.max(prev * delta, 0.1), 10));
setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10));
};
const handleMouseDown = (e: React.MouseEvent) => {

View File

@@ -83,12 +83,10 @@ export function MenuBar({
});
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);
}
};
@@ -99,17 +97,14 @@ export function MenuBar({
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();
});

View File

@@ -555,7 +555,7 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
};
const wizardContent = (
<div className={inline ? "plugin-publish-wizard inline" : "plugin-publish-wizard"} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className={inline ? 'plugin-publish-wizard inline' : 'plugin-publish-wizard'} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className="plugin-publish-header">
<h2>{t('title')}</h2>
{!inline && (
@@ -566,67 +566,67 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
</div>
<div className="plugin-publish-content">
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
npm run build
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
# ZIP 结构:
@@ -634,309 +634,309 @@ npm run build
# ├── package.json
# └── dist/
# └── index.esm.js`}
</pre>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
</pre>
</div>
</details>
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
)}
</div>
</details>
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
)}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
</button>
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
)}
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
className="btn-link"
onClick={() => open(existingPR.url)}
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
{t('suggestedVersion')}: {suggestedVersion}
</button>
</div>
)}
</div>
)}
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
{t('suggestedVersion')}: {suggestedVersion}
</button>
)}
</div>
)}
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
)}
<div className="confirm-details">
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="confirm-details">
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
)}
</div>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@@ -36,6 +36,8 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
const [isServerRunning, setIsServerRunning] = useState(false);
const [port, setPort] = useState('8080');
const animationRef = useRef<number>();
const frameTimesRef = useRef<number[]>([]);
const lastFpsRef = useRef<number>(0);
useEffect(() => {
const settings = SettingsService.getInstance();
@@ -298,7 +300,29 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
return result;
};
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
// Calculate FPS using rolling average for stability
// 使用滑动平均计算 FPS 以保持稳定
const calculateFps = () => {
// Add any positive frame time
// 添加任何正数的帧时间
if (totalFrameTime > 0) {
frameTimesRef.current.push(totalFrameTime);
// Keep last 60 samples
if (frameTimesRef.current.length > 60) {
frameTimesRef.current.shift();
}
}
if (frameTimesRef.current.length > 0) {
const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length;
// Cap FPS between 0-999, and ensure avgFrameTime is reasonable
if (avgFrameTime > 0.01) {
lastFpsRef.current = Math.min(999, Math.round(1000 / avgFrameTime));
}
}
return lastFpsRef.current;
};
const fps = calculateFps();
const targetFrameTime = 16.67;
const isOverBudget = totalFrameTime > targetFrameTime;

View File

@@ -1,17 +1,69 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata } from '@esengine/editor-core';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
interface PropertyInspectorProps {
component: Component;
entity?: any;
version?: number;
onChange?: (propertyName: string, value: any) => void;
onAction?: (actionId: string, propertyName: string, component: Component) => void;
}
export function PropertyInspector({ component, onChange }: PropertyInspectorProps) {
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
const [values, setValues] = useState<Record<string, any>>({});
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
void version;
// Scan entity for components that control this component's properties
useEffect(() => {
if (!entity) return;
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
if (!propertyMetadataService) return;
const componentName = component.constructor.name;
const controlled = new Map<string, string>();
// Check all components on this entity
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent);
const otherComponentName = otherComponent.constructor.name;
// Check if any property has controls declaration
for (const [, propMeta] of Object.entries(otherMetadata)) {
if (propMeta.controls) {
for (const control of propMeta.controls) {
if (control.component === componentName ||
control.component === componentName.replace('Component', '')) {
controlled.set(control.property, otherComponentName.replace('Component', ''));
}
}
}
}
}
setControlledFields(controlled);
}, [component, entity, version]);
const getControlledBy = (propertyName: string): string | undefined => {
return controlledFields.get(propertyName);
};
const handleAction = (actionId: string, propertyName: string) => {
if (onAction) {
onAction(actionId, propertyName, component);
}
};
useEffect(() => {
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
@@ -19,35 +71,29 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
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 handleChange = (propertyName: string, value: any) => {
const componentAsAny = component as any;
componentAsAny[propertyName] = value;
setValues((prev) => ({
...prev,
[propertyName]: value
}));
if (onChange) {
onChange(propertyName, value);
}
};
// Read value directly from component to avoid state sync issues
const getValue = (propertyName: string) => {
return (component as any)[propertyName];
};
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
const value = values[propertyName];
const value = getValue(propertyName);
const label = metadata.label || propertyName;
switch (metadata.type) {
case 'number':
case 'integer':
return (
<NumberField
key={propertyName}
@@ -55,9 +101,12 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
value={value ?? 0}
min={metadata.min}
max={metadata.max}
step={metadata.step}
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
isInteger={metadata.type === 'integer'}
readOnly={metadata.readOnly}
actions={metadata.actions}
onChange={(newValue) => handleChange(propertyName, newValue)}
onAction={(actionId) => handleAction(actionId, propertyName)}
/>
);
@@ -128,6 +177,39 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
/>
);
case 'asset': {
const controlledBy = getControlledBy(propertyName);
return (
<AssetDropField
key={propertyName}
label={label}
value={value ?? ''}
fileExtension={metadata.fileExtension}
readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
}
case 'animationClips':
return (
<div key={propertyName}>
{animationClipsEditor.render({
label,
value: value ?? [],
onChange: (newValue) => handleChange(propertyName, newValue),
context: {
readonly: metadata.readOnly ?? false,
metadata: {
component,
onDefaultAnimationChange: (val: string) => handleChange('defaultAnimation', val)
}
}
})}
</div>
);
default:
return null;
}
@@ -148,16 +230,33 @@ interface NumberFieldProps {
min?: number;
max?: number;
step?: number;
isInteger?: boolean;
readOnly?: boolean;
actions?: PropertyAction[];
onChange: (value: number) => void;
onAction?: (actionId: string) => void;
}
function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }: NumberFieldProps) {
function NumberField({ label, value, min, max, step = 0.1, isInteger = false, readOnly, actions, onChange, onAction }: NumberFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const renderActionButton = (action: PropertyAction) => {
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
return (
<button
key={action.id}
className="property-action-btn"
title={action.tooltip || action.label}
onClick={() => onAction?.(action.id)}
>
{IconComponent ? <IconComponent size={12} /> : action.label}
</button>
);
};
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
setIsDragging(true);
@@ -177,7 +276,14 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
if (min !== undefined) newValue = Math.max(min, newValue);
if (max !== undefined) newValue = Math.min(max, newValue);
onChange(parseFloat(newValue.toFixed(3)));
// 整数类型取整
if (isInteger) {
newValue = Math.round(newValue);
} else {
newValue = parseFloat(newValue.toFixed(3));
}
onChange(newValue);
};
const handleMouseUp = () => {
@@ -211,9 +317,17 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
max={max}
step={step}
disabled={readOnly}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
onChange(isInteger ? Math.round(val) : val);
}}
onFocus={(e) => e.target.select()}
/>
{actions && actions.length > 0 && (
<div className="property-actions">
{actions.map(renderActionButton)}
</div>
)}
</div>
);
}
@@ -271,27 +385,277 @@ interface ColorFieldProps {
}
function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
const [showPicker, setShowPicker] = useState(false);
const [tempColor, setTempColor] = useState(value);
const [pickerPos, setPickerPos] = useState({ top: 0, left: 0 });
const pickerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// 解析十六进制颜色为 HSV
const hexToHsv = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const s = max === 0 ? 0 : d / max;
const v = max;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, v: v * 100 };
};
// HSV 转十六进制
const hsvToHex = (h: number, s: number, v: number) => {
h = h / 360;
s = s / 100;
v = v / 100;
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
const toHex = (n: number) => Math.round(n * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const hsv = hexToHsv(tempColor);
// 点击外部关闭
useEffect(() => {
if (!showPicker) return;
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowPicker(false);
onChange(tempColor);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPicker, tempColor, onChange]);
useEffect(() => {
setTempColor(value);
}, [value]);
const handleSaturationValueChange = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const v = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
const newColor = hsvToHex(hsv.h, s, v);
setTempColor(newColor);
};
const handleHueChange = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
const newColor = hsvToHex(h, hsv.s, hsv.v);
setTempColor(newColor);
};
return (
<div className="property-field">
<div className="property-field" style={{ position: 'relative' }}>
<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)}
<div
ref={previewRef}
className="property-color-preview"
style={{ backgroundColor: value }}
onClick={() => {
if (readOnly) return;
if (!showPicker && previewRef.current) {
const rect = previewRef.current.getBoundingClientRect();
const pickerWidth = 200;
const pickerHeight = 220;
let top = rect.bottom + 4;
let left = rect.right - pickerWidth;
// Ensure picker stays within viewport
if (left < 8) left = 8;
if (top + pickerHeight > window.innerHeight - 8) {
top = rect.top - pickerHeight - 4;
}
setPickerPos({ top, left });
}
setShowPicker(!showPicker);
}}
/>
<input
type="text"
className="property-input property-input-color-text"
value={value.toUpperCase()}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
const val = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
onChange(val);
}
}}
onFocus={(e) => e.target.select()}
/>
</div>
{showPicker && (
<div
ref={pickerRef}
className="color-picker-popup"
style={{
position: 'fixed',
top: pickerPos.top,
left: pickerPos.left,
right: 'auto'
}}
>
<div
className="color-picker-saturation"
style={{ backgroundColor: hsvToHex(hsv.h, 100, 100) }}
onMouseDown={(e) => {
handleSaturationValueChange(e);
const rect = e.currentTarget.getBoundingClientRect();
const onMove = (ev: MouseEvent) => {
const s = Math.max(0, Math.min(100, ((ev.clientX - rect.left) / rect.width) * 100));
const v = Math.max(0, Math.min(100, 100 - ((ev.clientY - rect.top) / rect.height) * 100));
setTempColor(hsvToHex(hsv.h, s, v));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="color-picker-saturation-white" />
<div className="color-picker-saturation-black" />
<div
className="color-picker-cursor"
style={{
left: `${hsv.s}%`,
top: `${100 - hsv.v}%`
}}
/>
</div>
<div
className="color-picker-hue"
onMouseDown={(e) => {
handleHueChange(e);
const rect = e.currentTarget.getBoundingClientRect();
const onMove = (ev: MouseEvent) => {
const h = Math.max(0, Math.min(360, ((ev.clientX - rect.left) / rect.width) * 360));
setTempColor(hsvToHex(h, hsv.s, hsv.v));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div
className="color-picker-hue-cursor"
style={{ left: `${(hsv.h / 360) * 100}%` }}
/>
</div>
<div className="color-picker-preview-row">
<div className="color-picker-preview-box" style={{ backgroundColor: tempColor }} />
<span className="color-picker-hex">{tempColor.toUpperCase()}</span>
</div>
</div>
)}
</div>
);
}
// Draggable axis input component
interface DraggableAxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
readOnly?: boolean;
compact?: boolean;
onChange: (value: number) => void;
}
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, value: 0 });
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartRef.current.x;
const sensitivity = e.shiftKey ? 0.01 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
onChange(Math.round(newValue * 1000) / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const axisClass = `property-vector-axis-${axis}`;
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
return (
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
<span
className={`property-vector-axis-label ${axisClass}`}
onMouseDown={handleMouseDown}
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
>
{axis.toUpperCase()}
</span>
<input
type="number"
className={inputClass}
value={value ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
/>
</div>
);
}
@@ -319,57 +683,35 @@ function Vector2Field({ label, value, readOnly, onChange }: Vector2FieldProps) {
</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>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
onChange={(y) => onChange({ ...value, y })}
/>
</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>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
compact
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
compact
onChange={(y) => onChange({ ...value, y })}
/>
</div>
)}
</div>
@@ -399,81 +741,48 @@ function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
</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>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
onChange={(y) => onChange({ ...value, y })}
/>
<DraggableAxisInput
axis="z"
value={value?.z ?? 0}
readOnly={readOnly}
onChange={(z) => onChange({ ...value, z })}
/>
</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>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
compact
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
compact
onChange={(y) => onChange({ ...value, y })}
/>
<DraggableAxisInput
axis="z"
value={value?.z ?? 0}
readOnly={readOnly}
compact
onChange={(z) => onChange({ ...value, z })}
/>
</div>
)}
</div>
@@ -489,29 +798,165 @@ interface EnumFieldProps {
}
function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || (options.length === 0 ? 'No options' : '');
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
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>
<div className="property-dropdown" ref={dropdownRef}>
<button
className={`property-dropdown-trigger ${isOpen ? 'open' : ''}`}
onClick={() => !readOnly && setIsOpen(!isOpen)}
disabled={readOnly}
type="button"
>
<span className="property-dropdown-value">{displayLabel}</span>
<span className="property-dropdown-arrow"></span>
</button>
{isOpen && (
<div className="property-dropdown-menu">
{options.map((option, index) => (
<button
key={index}
className={`property-dropdown-item ${option.value === value ? 'selected' : ''}`}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
type="button"
>
{option.label}
</button>
))}
</div>
)}
{options.map((option, index) => (
<option key={index} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</div>
</div>
);
}
interface AssetDropFieldProps {
label: string;
value: string;
fileExtension?: string;
readOnly?: boolean;
controlledBy?: string;
onChange: (value: string) => void;
}
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, onChange }: AssetDropFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readOnly) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readOnly) return;
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath) {
if (fileExtension) {
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
const fileExt = assetPath.toLowerCase().split('.').pop();
if (fileExt && extensions.some((ext) => ext === `.${fileExt}` || ext === fileExt)) {
onChange(assetPath);
}
} else {
onChange(assetPath);
}
}
};
const getFileName = (path: string) => {
if (!path) return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1];
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
if (!readOnly) onChange('');
};
const handleNavigate = (e: React.MouseEvent) => {
e.stopPropagation();
if (value) {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path: value });
}
}
};
return (
<div className="property-field">
<label className="property-label">
{label}
{controlledBy && (
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
<Lock size={10} />
</span>
)}
</label>
<div
className={`property-asset-drop ${isDragging ? 'dragging' : ''} ${value ? 'has-value' : ''} ${controlledBy ? 'controlled' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={controlledBy ? `Controlled by ${controlledBy}` : (value || 'Drop asset here')}
>
<span className="property-asset-text">
{value ? getFileName(value) : 'None'}
</span>
<div className="property-asset-actions">
{value && (
<button
className="property-asset-btn"
onClick={handleNavigate}
title="在资产浏览器中显示"
>
<ArrowRight size={12} />
</button>
)}
{value && !readOnly && (
<button className="property-asset-clear" onClick={handleClear}>×</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { X, Copy, Check } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
import '../styles/QRCodeDialog.css';
interface QRCodeDialogProps {
url: string;
isOpen: boolean;
onClose: () => void;
}
export const QRCodeDialog: React.FC<QRCodeDialogProps> = ({ url, isOpen, onClose }) => {
const [qrCodeData, setQrCodeData] = useState<string>('');
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && url) {
setLoading(true);
TauriAPI.generateQRCode(url)
.then((base64) => {
setQrCodeData(`data:image/png;base64,${base64}`);
})
.catch((error) => {
console.error('Failed to generate QR code:', error);
})
.finally(() => {
setLoading(false);
});
}
}, [isOpen, url]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
if (!isOpen) return null;
return (
<div className="qrcode-dialog-overlay">
<div className="qrcode-dialog">
<div className="qrcode-dialog-header">
<h3></h3>
<button className="qrcode-dialog-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="qrcode-dialog-content">
{loading ? (
<div className="qrcode-loading">...</div>
) : qrCodeData ? (
<img src={qrCodeData} alt="QR Code" width={200} height={200} />
) : (
<div className="qrcode-error"></div>
)}
<div className="qrcode-url-container">
<input
type="text"
value={url}
readOnly
className="qrcode-url-input"
/>
<button
className="qrcode-copy-button"
onClick={handleCopy}
title={copied ? '已复制' : '复制链接'}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<p className="qrcode-hint">
</p>
</div>
</div>
</div>
);
};
export default QRCodeDialog;

View File

@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe } from 'lucide-react';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import '../styles/SceneHierarchy.css';
type ViewMode = 'local' | 'remote';
@@ -201,6 +201,43 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
commandManager.execute(command);
};
const handleCreateSpriteEntity = () => {
// Count only Sprite entities for naming
const spriteCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('Sprite ')).length;
const entityName = `Sprite ${spriteCount + 1}`;
const command = new CreateSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateAnimatedSpriteEntity = () => {
const animCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('AnimatedSprite ')).length;
const entityName = `AnimatedSprite ${animCount + 1}`;
const command = new CreateAnimatedSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateCameraEntity = () => {
const entityCount = entityStore.getAllEntities().length;
const entityName = `Camera ${entityCount + 1}`;
const command = new CreateCameraEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleDeleteEntity = async () => {
if (!selectedId) return;
@@ -431,7 +468,19 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
>
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
<Plus size={12} />
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
<span>{locale === 'zh' ? '创建实体' : 'Create Empty Entity'}</span>
</button>
<button onClick={() => { handleCreateSpriteEntity(); closeContextMenu(); }}>
<Image size={12} />
<span>{locale === 'zh' ? '创建 Sprite' : 'Create Sprite'}</span>
</button>
<button onClick={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}>
<Film size={12} />
<span>{locale === 'zh' ? '创建动画 Sprite' : 'Create Animated Sprite'}</span>
</button>
<button onClick={() => { handleCreateCameraEntity(); closeContextMenu(); }}>
<Camera size={12} />
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
</button>
{contextMenu.entityId && (
<>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import '../styles/StartupPage.css';
interface StartupPageProps {
@@ -12,6 +13,11 @@ interface StartupPageProps {
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
useEffect(() => {
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
}, []);
const translations = {
en: {
@@ -22,7 +28,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects',
version: 'Version 1.0.0',
comingSoon: 'Coming Soon'
},
zh: {
@@ -33,12 +38,12 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: '性能分析模式',
recentProjects: '最近的项目',
noRecentProjects: '没有最近的项目',
version: '版本 1.0.0',
comingSoon: '即将推出'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`;
return (
<div className="startup-page">
@@ -101,7 +106,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div>
<div className="startup-footer">
<span className="startup-version">{t.version}</span>
<span className="startup-version">{versionText}</span>
</div>
</div>
);

View File

@@ -293,7 +293,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
deleteReason
);
console.log(`[UserDashboard] Delete PR created:`, prUrl);
console.log('[UserDashboard] Delete PR created:', prUrl);
setConfirmDeletePlugin(null);
setDeleteReason('');
@@ -407,7 +407,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
const pluginName = removeMatch[0];
const plugin = publishedPlugins.find(p => p.name === pluginName);
const plugin = publishedPlugins.find((p) => p.name === pluginName);
if (!plugin) {
alert(t('recreatePRFailed') + ': Plugin not found in published list');
return;
@@ -443,7 +443,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
true
);
console.log(`[UserDashboard] Recreated delete PR:`, prUrl);
console.log('[UserDashboard] Recreated delete PR:', prUrl);
alert(t('recreatePRSuccess'));
await loadData();
@@ -482,12 +482,12 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
if (prFilter === 'all') {
return pendingReviews;
}
return pendingReviews.filter(review => review.status === prFilter);
return pendingReviews.filter((review) => review.status === prFilter);
};
const handleLinkClick = (href: string) => (e: React.MouseEvent) => {
e.preventDefault();
open(href).catch(err => {
open(href).catch((err) => {
console.error('[UserDashboard] Failed to open link:', err);
});
};
@@ -568,7 +568,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
{publishedPlugins.map((plugin) => {
const isExpanded = expandedVersions.has(plugin.id);
const hasMultipleVersions = plugin.versions.length > 1;
const pendingPR = pendingReviews.find(pr => pr.pluginName === plugin.name && pr.status === 'open');
const pendingPR = pendingReviews.find((pr) => pr.pluginName === plugin.name && pr.status === 'open');
return (
<div key={plugin.id} className="plugin-card">
@@ -633,55 +633,55 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
</div>
</div>
)}
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
</div>
</div>
</div>
);
})}
</div>
@@ -729,19 +729,19 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
className={`filter-btn ${prFilter === 'open' ? 'active' : ''}`}
onClick={() => setPRFilter('open')}
>
{t('filterOpen')} ({pendingReviews.filter(r => r.status === 'open').length})
{t('filterOpen')} ({pendingReviews.filter((r) => r.status === 'open').length})
</button>
<button
className={`filter-btn ${prFilter === 'merged' ? 'active' : ''}`}
onClick={() => setPRFilter('merged')}
>
{t('filterMerged')} ({pendingReviews.filter(r => r.status === 'merged').length})
{t('filterMerged')} ({pendingReviews.filter((r) => r.status === 'merged').length})
</button>
<button
className={`filter-btn ${prFilter === 'closed' ? 'active' : ''}`}
onClick={() => setPRFilter('closed')}
>
{t('filterClosed')} ({pendingReviews.filter(r => r.status === 'closed').length})
{t('filterClosed')} ({pendingReviews.filter((r) => r.status === 'closed').length})
</button>
</div>
<div className="review-list">

View File

@@ -52,7 +52,6 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
useEffect(() => {
// 监听加载状态变化
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
console.log('[UserProfile] User load state changed:', isLoading);
setIsLoadingUser(isLoading);
});
@@ -65,10 +64,8 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
const currentUser = githubService.getUser();
setUser((prevUser) => {
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
console.log('[UserProfile] User state changed:', currentUser.login);
return currentUser;
} else if (!currentUser && prevUser) {
console.log('[UserProfile] User logged out');
return null;
}
return prevUser;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
.asset-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.asset-picker-dialog {
width: 480px;
max-width: 90vw;
max-height: 70vh;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.asset-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
}
.asset-picker-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
.asset-picker-close {
background: transparent;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-close:hover {
background: #333;
color: #e0e0e0;
}
.asset-picker-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #333;
}
.asset-picker-search svg {
color: #666;
flex-shrink: 0;
}
.asset-picker-search input {
flex: 1;
background: transparent;
border: none;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-picker-search input::placeholder {
color: #666;
}
.asset-picker-content {
flex: 1;
overflow-y: auto;
min-height: 200px;
max-height: 400px;
}
.asset-picker-loading,
.asset-picker-empty {
padding: 32px;
text-align: center;
color: #666;
font-size: 12px;
}
.asset-picker-tree {
padding: 4px 0;
}
.asset-picker-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
}
.asset-picker-item:hover {
background: #2a2a2a;
}
.asset-picker-item.selected {
background: #0d47a1;
}
.asset-picker-item__icon {
display: flex;
align-items: center;
color: #888;
}
.asset-picker-item.selected .asset-picker-item__icon {
color: #90caf9;
}
.asset-picker-item__name {
font-size: 12px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-picker-selected {
flex: 1;
min-width: 0;
font-size: 11px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-selected .placeholder {
color: #666;
font-style: italic;
}
.asset-picker-actions {
display: flex;
gap: 8px;
margin-left: 16px;
}
.asset-picker-actions button {
padding: 6px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
}
.asset-picker-actions .btn-cancel {
background: #333;
color: #e0e0e0;
}
.asset-picker-actions .btn-cancel:hover {
background: #444;
}
.asset-picker-actions .btn-confirm {
background: #1976d2;
color: white;
}
.asset-picker-actions .btn-confirm:hover {
background: #1565c0;
}
.asset-picker-actions .btn-confirm:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProjectService } from '@esengine/editor-core';
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
import './AssetPickerDialog.css';
interface AssetPickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (path: string) => void;
title?: string;
fileExtensions?: string[]; // e.g., ['.png', '.jpg']
placeholder?: string;
}
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
export function AssetPickerDialog({
isOpen,
onClose,
onSelect,
title = 'Select Asset',
fileExtensions = [],
placeholder = 'Search assets...'
}: AssetPickerDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
// Load project assets
useEffect(() => {
if (!isOpen) return;
const loadAssets = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = new TauriFileSystemService();
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject) {
const projectPath = currentProject.path;
const assetsPath = `${projectPath}/assets`;
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory
};
if (entry.isDirectory) {
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
}
nodes.push(node);
}
// Sort: folders first, then files, alphabetically
return nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
};
const tree = await buildTree(assetsPath);
setAssets(tree);
}
} catch (error) {
console.error('Failed to load assets:', error);
} finally {
setLoading(false);
}
};
loadAssets();
setSelectedPath(null);
setSearchTerm('');
}, [isOpen]);
// Filter assets based on search and file extensions
const filteredAssets = useMemo(() => {
const filterNode = (node: FileNode): FileNode | null => {
// Check file extension filter
if (!node.isDirectory && fileExtensions.length > 0) {
const hasValidExtension = fileExtensions.some((ext) =>
node.name.toLowerCase().endsWith(ext.toLowerCase())
);
if (!hasValidExtension) return null;
}
// Check search term
const matchesSearch = !searchTerm ||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.isDirectory && node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
return null;
}
return matchesSearch ? node : null;
};
return assets
.map(filterNode)
.filter((n): n is FileNode => n !== null);
}, [assets, searchTerm, fileExtensions]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleSelect = useCallback((node: FileNode) => {
if (node.isDirectory) {
toggleFolder(node.path);
} else {
setSelectedPath(node.path);
}
}, [toggleFolder]);
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(selectedPath);
onClose();
}
}, [selectedPath, onSelect, onClose]);
const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory) {
onSelect(node.path);
onClose();
}
}, [onSelect, onClose]);
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return <Image size={14} />;
case 'mp3':
case 'wav':
case 'ogg':
return <Music size={14} />;
case 'mp4':
case 'webm':
return <Video size={14} />;
case 'json':
case 'txt':
case 'md':
return <FileText size={14} />;
default:
return <File size={14} />;
}
};
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedPath === node.path;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelect(node)}
onDoubleClick={() => handleDoubleClick(node)}
>
<span className="asset-picker-item__icon">
{node.isDirectory ? (
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
) : (
getFileIcon(node.name)
)}
</span>
<span className="asset-picker-item__name">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div className="asset-picker-children">
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">Loading assets...</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">No assets found</div>
) : (
<div className="asset-picker-tree">
{filteredAssets.map((node) => renderNode(node))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedPath ? (
<span title={selectedPath}>
{selectedPath.split(/[\\/]/).pop()}
</span>
) : (
<span className="placeholder">No asset selected</span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
Cancel
</button>
<button
className="btn-confirm"
onClick={handleConfirm}
disabled={!selectedPath}
>
Select
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -130,6 +130,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
@@ -140,6 +141,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
unsubAssetFileSelect();
unsubComponentAdded();
unsubComponentRemoved();
unsubPropertyChanged();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub]);

View File

@@ -38,7 +38,7 @@ export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemPro
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
<span
style={{
marginLeft: '6px',

View File

@@ -51,4 +51,4 @@ export function PropertyField({
</span>
</div>
);
}
}

View File

@@ -1,10 +1,10 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css';
interface AssetFieldProps {
label: string;
label?: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
@@ -24,6 +24,7 @@ export function AssetField({
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -54,7 +55,7 @@ export function AssetField({
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find(f =>
const file = files.find((f) =>
!fileExtension || f.name.endsWith(fileExtension)
);
@@ -78,25 +79,15 @@ export function AssetField({
}
}, [onChange, fileExtension, readonly]);
const handleBrowse = useCallback(async () => {
const handleBrowse = useCallback(() => {
if (readonly) return;
setShowPicker(true);
}, [readonly]);
try {
const selected = await open({
multiple: false,
filters: fileExtension ? [{
name: `${fileExtension} Files`,
extensions: [fileExtension.replace('.', '')]
}] : []
});
if (selected) {
onChange(selected as string);
}
} catch (error) {
console.error('Failed to open file dialog:', error);
}
}, [onChange, fileExtension, readonly]);
const handlePickerSelect = useCallback((path: string) => {
onChange(path);
setShowPicker(false);
}, [onChange]);
const handleClear = useCallback(() => {
if (!readonly) {
@@ -111,7 +102,7 @@ export function AssetField({
return (
<div className="asset-field">
<label className="asset-field__label">{label}</label>
{label && <label className="asset-field__label">{label}</label>}
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
@@ -160,17 +151,21 @@ export function AssetField({
</button>
)}
{/* 导航按钮 */}
{value && onNavigate && (
{/* 导航/定位按钮 */}
{onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
if (value) {
onNavigate(value);
} else {
handleBrowse();
}
}}
title="在资产浏览器中显示"
title={value ? '在资产浏览器中显示' : '选择资产'}
>
<ArrowRight size={12} />
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
</button>
)}
@@ -189,6 +184,14 @@ export function AssetField({
)}
</div>
</div>
<AssetPickerDialog
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onSelect={handlePickerSelect}
title="Select Asset"
fileExtensions={fileExtension ? [fileExtension] : []}
/>
</div>
);
}
}

View File

@@ -54,8 +54,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
{fileInfo.isDirectory
? '文件夹'
: fileInfo.extension
? `.${fileInfo.extension}`
: '文件'}
? `.${fileInfo.extension}`
: '文件'}
</span>
</div>
{fileInfo.size !== undefined && !fileInfo.isDirectory && (

View File

@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
import { Entity, Component, Core } from '@esengine/ecs-framework';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
interface EntityInspectorProps {
entity: Entity;
@@ -16,6 +18,7 @@ interface EntityInspectorProps {
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [showComponentMenu, setShowComponentMenu] = useState(false);
const [localVersion, setLocalVersion] = useState(0);
const componentRegistry = Core.services.resolve(ComponentRegistry);
const availableComponents = componentRegistry?.getAllComponents() || [];
@@ -40,14 +43,42 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const handleRemoveComponent = (index: number) => {
const component = entity.components[index];
if (component) {
const command = new RemoveComponentCommand(
messageHub,
entity,
component
);
commandManager.execute(command);
if (!component) return;
const componentName = getComponentTypeName(component.constructor as any);
console.log('Removing component:', componentName);
// Check if any other component depends on this one
const dependentComponents: string[] = [];
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const dependencies = getComponentDependencies(otherComponent.constructor as any);
const otherName = getComponentTypeName(otherComponent.constructor as any);
console.log('Checking', otherName, 'dependencies:', dependencies);
if (dependencies && dependencies.includes(componentName)) {
dependentComponents.push(otherName);
}
}
console.log('Dependent components:', dependentComponents);
if (dependentComponents.length > 0) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.warning(
'无法删除组件',
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
);
}
return;
}
const command = new RemoveComponentCommand(
messageHub,
entity,
component
);
commandManager.execute(command);
};
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
@@ -61,6 +92,34 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
commandManager.execute(command);
};
const handlePropertyAction = async (actionId: string, _propertyName: string, component: Component) => {
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
const sprite = component as unknown as { texture: string; width: number; height: number };
if (!sprite.texture) {
console.warn('No texture set for sprite');
return;
}
try {
const { convertFileSrc } = await import('@tauri-apps/api/core');
const assetUrl = convertFileSrc(sprite.texture);
const img = new Image();
img.onload = () => {
handlePropertyChange(component, 'width', img.naturalWidth);
handlePropertyChange(component, 'height', img.naturalHeight);
setLocalVersion((v) => v + 1);
};
img.onerror = () => {
console.error('Failed to load texture for native size:', sprite.texture);
};
img.src = assetUrl;
} catch (error) {
console.error('Error getting texture size:', error);
}
}
};
return (
<div className="entity-inspector">
<div className="inspector-header">
@@ -82,149 +141,123 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
</div>
<div className="inspector-section">
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="section-title section-title-with-action">
<span></span>
<div style={{ position: 'relative' }}>
<div className="component-menu-container">
<button
className="add-component-trigger"
onClick={() => setShowComponentMenu(!showComponentMenu)}
style={{
background: 'transparent',
border: '1px solid #4a4a4a',
borderRadius: '4px',
color: '#e0e0e0',
cursor: 'pointer',
padding: '2px 6px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px'
}}
>
<Plus size={12} />
</button>
{showComponentMenu && (
<div
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '4px',
backgroundColor: '#2a2a2a',
border: '1px solid #4a4a4a',
borderRadius: '4px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
minWidth: '150px',
maxHeight: '200px',
overflowY: 'auto'
}}
>
{availableComponents.length === 0 ? (
<div style={{ padding: '8px 12px', color: '#888', fontSize: '11px' }}>
</div>
) : (
availableComponents.map((info) => (
<button
key={info.name}
onClick={() => info.type && handleAddComponent(info.type)}
style={{
display: 'block',
width: '100%',
padding: '6px 12px',
background: 'transparent',
border: 'none',
color: '#e0e0e0',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#3a3a3a')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
>
{info.name}
</button>
))
)}
</div>
<>
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
<div className="component-dropdown">
<div className="component-dropdown-header"></div>
{availableComponents.length === 0 ? (
<div className="component-dropdown-empty">
</div>
) : (
<div className="component-dropdown-list">
{/* 按分类分组显示 */}
{(() => {
const categories = new Map<string, typeof availableComponents>();
availableComponents.forEach((info) => {
const cat = info.category || 'components.category.other';
if (!categories.has(cat)) {
categories.set(cat, []);
}
categories.get(cat)!.push(info);
});
return Array.from(categories.entries()).map(([category, components]) => (
<div key={category} className="component-category-group">
<div className="component-category-label">{category}</div>
{components.map((info) => (
<button
key={info.name}
className="component-dropdown-item"
onClick={() => info.type && handleAddComponent(info.type)}
>
<span className="component-dropdown-item-name">{info.name}</span>
</button>
))}
</div>
));
})()}
</div>
)}
</div>
</>
)}
</div>
</div>
{entity.components.map((component: Component, index: number) => {
{entity.components.length === 0 ? (
<div className="empty-state-small"></div>
) : (
entity.components.map((component: Component, index: number) => {
const isExpanded = expandedComponents.has(index);
const componentName = component.constructor?.name || 'Component';
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
return (
<div
key={`${componentName}-${index}-${componentVersion}`}
style={{
marginBottom: '2px',
backgroundColor: '#2a2a2a',
borderRadius: '4px',
overflow: 'hidden'
}}
key={`${componentName}-${index}`}
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
>
<div
className="component-item-header"
onClick={() => toggleComponentExpanded(index)}
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
backgroundColor: '#3a3a3a',
cursor: 'pointer',
userSelect: 'none',
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span
style={{
marginLeft: '6px',
fontSize: '12px',
fontWeight: 500,
color: '#e0e0e0',
flex: 1
}}
>
<span className="component-expand-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
{IconComponent ? (
<span className="component-icon">
<IconComponent size={14} />
</span>
) : (
<span className="component-icon">
<Box size={14} />
</span>
)}
<span className="component-item-name">
{componentName}
</span>
<button
className="component-remove-btn"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(index);
}}
title="移除组件"
style={{
background: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
padding: '2px',
borderRadius: '3px',
display: 'flex',
alignItems: 'center'
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#dc2626')}
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
>
<X size={12} />
</button>
</div>
{isExpanded && (
<div style={{ padding: '6px 8px' }}>
<div className="component-item-content">
<PropertyInspector
component={component}
entity={entity}
version={componentVersion + localVersion}
onChange={(propName: string, value: unknown) =>
handlePropertyChange(component, propName, value)
}
onAction={handlePropertyAction}
/>
</div>
)}
</div>
);
})}
})
)}
</div>
</div>
</div>

View File

@@ -242,33 +242,33 @@ export function RemoteEntityInspector({
details.components &&
Array.isArray(details.components) &&
details.components.length > 0 && (
<div className="inspector-section">
<div className="section-title"> ({details.components.length})</div>
{details.components.map((comp, index) => {
const registry = Core.services.resolve(PropertyRendererRegistry);
const context: PropertyContext = {
name: comp.typeName || `Component ${index}`,
decimalPlaces,
readonly: true,
expandByDefault: true,
depth: 0
};
const rendered = registry.render(comp, context);
return rendered ? <div key={index}>{rendered}</div> : null;
})}
</div>
)}
<div className="inspector-section">
<div className="section-title"> ({details.components.length})</div>
{details.components.map((comp, index) => {
const registry = Core.services.resolve(PropertyRendererRegistry);
const context: PropertyContext = {
name: comp.typeName || `Component ${index}`,
decimalPlaces,
readonly: true,
expandByDefault: true,
depth: 0
};
const rendered = registry.render(comp, context);
return rendered ? <div key={index}>{rendered}</div> : null;
})}
</div>
)}
{details &&
Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes')
.length > 0 && (
<div className="inspector-section">
<div className="section-title"></div>
{Object.entries(details)
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
.map(([key, value]) => renderRemoteProperty(key, value))}
</div>
)}
<div className="inspector-section">
<div className="section-title"></div>
{Object.entries(details)
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
.map(([key, value]) => renderRemoteProperty(key, value))}
</div>
)}
</div>
</div>
);