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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user