Dock系统,支持Tab和拖放
This commit is contained in:
@@ -46,7 +46,7 @@ async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
#[tauri::command]
|
||||
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
use std::path::{Path, MAIN_SEPARATOR};
|
||||
use std::path::Path;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StartupPage } from './components/StartupPage';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { EntityInspector } from './components/EntityInspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { ResizablePanel } from './components/ResizablePanel';
|
||||
import { DockContainer, DockablePanel } from './components/DockContainer';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { TransformComponent } from './example-components/TransformComponent';
|
||||
import { SpriteComponent } from './example-components/SpriteComponent';
|
||||
@@ -61,6 +61,7 @@ function App() {
|
||||
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
const [status, setStatus] = useState(t('header.status.initializing'));
|
||||
const [panels, setPanels] = useState<DockablePanel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeEditor = async () => {
|
||||
@@ -199,6 +200,63 @@ function App() {
|
||||
changeLocale(newLocale);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub) {
|
||||
const defaultPanels: DockablePanel[] = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
position: 'left',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
position: 'right',
|
||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
position: 'center',
|
||||
content: (
|
||||
<div className="viewport">
|
||||
<h3>{t('viewport.title')}</h3>
|
||||
<p>{t('viewport.placeholder')}</p>
|
||||
</div>
|
||||
),
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
title: locale === 'zh' ? '资产' : 'Assets',
|
||||
position: 'bottom',
|
||||
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
title: locale === 'zh' ? '控制台' : 'Console',
|
||||
position: 'bottom',
|
||||
content: (
|
||||
<div style={{ padding: '12px', height: '100%', overflow: 'auto' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '12px', color: '#ffffff' }}>
|
||||
{locale === 'zh' ? '控制台' : 'Console'}
|
||||
</h4>
|
||||
<p style={{ fontSize: '12px', color: '#858585' }}>
|
||||
{locale === 'zh' ? '控制台输出将显示在这里...' : 'Console output will appear here...'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
setPanels(defaultPanels);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, locale, currentProjectPath, t]);
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="editor-loading">
|
||||
@@ -237,60 +295,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="editor-content">
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={250}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="sidebar-left">
|
||||
{entityStore && messageHub ? (
|
||||
<SceneHierarchy entityStore={entityStore} messageHub={messageHub} />
|
||||
) : (
|
||||
<div className="loading">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
side="right"
|
||||
defaultSize={280}
|
||||
minSize={200}
|
||||
maxSize={500}
|
||||
leftOrTop={
|
||||
<ResizablePanel
|
||||
direction="vertical"
|
||||
defaultSize={200}
|
||||
minSize={100}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="main-content">
|
||||
<div className="viewport">
|
||||
<h3>{t('viewport.title')}</h3>
|
||||
<p>{t('viewport.placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="bottom-panel">
|
||||
<AssetBrowser projectPath={currentProjectPath} locale={locale} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="sidebar-right">
|
||||
{entityStore && messageHub ? (
|
||||
<EntityInspector entityStore={entityStore} messageHub={messageHub} />
|
||||
) : (
|
||||
<div className="loading">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DockContainer panels={panels} />
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
|
||||
@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
file: 'File',
|
||||
folder: 'Folder'
|
||||
folder: 'Folder',
|
||||
backToParent: 'Back to parent folder'
|
||||
},
|
||||
zh: {
|
||||
title: '资产',
|
||||
@@ -39,7 +40,8 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
file: '文件',
|
||||
folder: '文件夹'
|
||||
folder: '文件夹',
|
||||
backToParent: '返回上一级'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,6 +95,17 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
|
||||
console.log('Open asset:', asset);
|
||||
};
|
||||
|
||||
const handleBackToParent = () => {
|
||||
if (!currentPath || !projectPath) return;
|
||||
if (currentPath === projectPath) return;
|
||||
|
||||
const parentPath = currentPath.split(/[\\/]/).slice(0, -1).join(currentPath.includes('\\') ? '\\' : '/');
|
||||
setCurrentPath(parentPath);
|
||||
loadAssets(parentPath);
|
||||
};
|
||||
|
||||
const canGoBack = currentPath && projectPath && currentPath !== projectPath;
|
||||
|
||||
const getFileIcon = (extension?: string) => {
|
||||
switch (extension?.toLowerCase()) {
|
||||
case 'ts':
|
||||
@@ -156,7 +169,33 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<h3>{t.title}</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0 }}>{t.title}</h3>
|
||||
{canGoBack && (
|
||||
<button
|
||||
onClick={handleBackToParent}
|
||||
className="back-button"
|
||||
title={t.backToParent}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#0e639c',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-path">{currentPath}</div>
|
||||
</div>
|
||||
|
||||
|
||||
165
packages/editor-app/src/components/DockContainer.tsx
Normal file
165
packages/editor-app/src/components/DockContainer.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TabPanel, TabItem } from './TabPanel';
|
||||
import { ResizablePanel } from './ResizablePanel';
|
||||
import '../styles/DockContainer.css';
|
||||
|
||||
export type DockPosition = 'left' | 'right' | 'top' | 'bottom' | 'center';
|
||||
|
||||
export interface DockablePanel {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
position: DockPosition;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
interface DockContainerProps {
|
||||
panels: DockablePanel[];
|
||||
onPanelClose?: (panelId: string) => void;
|
||||
onPanelMove?: (panelId: string, newPosition: DockPosition) => void;
|
||||
}
|
||||
|
||||
export function DockContainer({ panels, onPanelClose }: DockContainerProps) {
|
||||
const groupedPanels = panels.reduce((acc, panel) => {
|
||||
if (!acc[panel.position]) {
|
||||
acc[panel.position] = [];
|
||||
}
|
||||
acc[panel.position].push(panel);
|
||||
return acc;
|
||||
}, {} as Record<DockPosition, DockablePanel[]>);
|
||||
|
||||
const renderPanelGroup = (position: DockPosition) => {
|
||||
const positionPanels = groupedPanels[position];
|
||||
if (!positionPanels || positionPanels.length === 0) return null;
|
||||
|
||||
const tabs: TabItem[] = positionPanels.map(panel => ({
|
||||
id: panel.id,
|
||||
title: panel.title,
|
||||
content: panel.content,
|
||||
closable: panel.closable
|
||||
}));
|
||||
|
||||
return (
|
||||
<TabPanel
|
||||
tabs={tabs}
|
||||
onTabClose={onPanelClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const leftPanel = groupedPanels['left'];
|
||||
const rightPanel = groupedPanels['right'];
|
||||
const topPanel = groupedPanels['top'];
|
||||
const bottomPanel = groupedPanels['bottom'];
|
||||
|
||||
const hasLeft = leftPanel && leftPanel.length > 0;
|
||||
const hasRight = rightPanel && rightPanel.length > 0;
|
||||
const hasTop = topPanel && topPanel.length > 0;
|
||||
const hasBottom = bottomPanel && bottomPanel.length > 0;
|
||||
|
||||
let content = (
|
||||
<div className="dock-center">
|
||||
{renderPanelGroup('center')}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hasTop || hasBottom) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="vertical"
|
||||
defaultSize={200}
|
||||
minSize={100}
|
||||
maxSize={400}
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-bottom">
|
||||
{renderPanelGroup('bottom')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTop) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="vertical"
|
||||
defaultSize={200}
|
||||
minSize={100}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="dock-top">
|
||||
{renderPanelGroup('top')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLeft || hasRight) {
|
||||
if (hasLeft && hasRight) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={250}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="dock-left">
|
||||
{renderPanelGroup('left')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
side="right"
|
||||
defaultSize={280}
|
||||
minSize={200}
|
||||
maxSize={500}
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-right">
|
||||
{renderPanelGroup('right')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (hasLeft) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={250}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="dock-left">
|
||||
{renderPanelGroup('left')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={content}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
side="right"
|
||||
defaultSize={280}
|
||||
minSize={200}
|
||||
maxSize={500}
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-right">
|
||||
{renderPanelGroup('right')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="dock-container">{content}</div>;
|
||||
}
|
||||
62
packages/editor-app/src/components/TabPanel.tsx
Normal file
62
packages/editor-app/src/components/TabPanel.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, ReactNode } from 'react';
|
||||
import '../styles/TabPanel.css';
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
tabs: TabItem[];
|
||||
activeTabId?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
onTabClose?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export function TabPanel({ tabs, activeTabId, onTabChange, onTabClose }: TabPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(activeTabId || tabs[0]?.id);
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
onTabChange?.(tabId);
|
||||
};
|
||||
|
||||
const handleCloseTab = (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
onTabClose?.(tabId);
|
||||
};
|
||||
|
||||
const currentTab = tabs.find(tab => tab.id === activeTab);
|
||||
|
||||
return (
|
||||
<div className="tab-panel">
|
||||
<div className="tab-header">
|
||||
<div className="tab-list">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
>
|
||||
<span className="tab-title">{tab.title}</span>
|
||||
{tab.closable && (
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
{currentTab?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
packages/editor-app/src/styles/DockContainer.css
Normal file
33
packages/editor-app/src/styles/DockContainer.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.dock-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-left,
|
||||
.dock-right,
|
||||
.dock-top,
|
||||
.dock-bottom,
|
||||
.dock-center {
|
||||
background: #1e1e1e;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-left {
|
||||
border-right: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.dock-right {
|
||||
border-left: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.dock-top {
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.dock-bottom {
|
||||
border-top: 1px solid #3e3e3e;
|
||||
}
|
||||
88
packages/editor-app/src/styles/TabPanel.css
Normal file
88
packages/editor-app/src/styles/TabPanel.css
Normal file
@@ -0,0 +1,88 @@
|
||||
.tab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
min-height: 35px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
color: #969696;
|
||||
border-right: 1px solid #3e3e3e;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: #2a2d2e;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-bottom: 2px solid #007acc;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #858585;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #4e4e4e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
Reference in New Issue
Block a user