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:
@@ -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}
|
||||
|
||||
@@ -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, ''));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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="取消"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
89
packages/editor-app/src/components/QRCodeDialog.tsx
Normal file
89
packages/editor-app/src/components/QRCodeDialog.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
199
packages/editor-app/src/components/dialogs/AssetPickerDialog.css
Normal file
199
packages/editor-app/src/components/dialogs/AssetPickerDialog.css
Normal 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;
|
||||
}
|
||||
282
packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx
Normal file
282
packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -51,4 +51,4 @@ export function PropertyField({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user