Dock系统,支持Tab和拖放

This commit is contained in:
YHH
2025-10-15 09:58:45 +08:00
parent 82451e9fd3
commit 00fc6dfd67
7 changed files with 451 additions and 59 deletions

View File

@@ -46,7 +46,7 @@ async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
#[tauri::command] #[tauri::command]
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> { fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
use glob::glob; use glob::glob;
use std::path::{Path, MAIN_SEPARATOR}; use std::path::Path;
let base_path = Path::new(&path); let base_path = Path::new(&path);
if !base_path.exists() { if !base_path.exists() {

View File

@@ -6,7 +6,7 @@ import { StartupPage } from './components/StartupPage';
import { SceneHierarchy } from './components/SceneHierarchy'; import { SceneHierarchy } from './components/SceneHierarchy';
import { EntityInspector } from './components/EntityInspector'; import { EntityInspector } from './components/EntityInspector';
import { AssetBrowser } from './components/AssetBrowser'; import { AssetBrowser } from './components/AssetBrowser';
import { ResizablePanel } from './components/ResizablePanel'; import { DockContainer, DockablePanel } from './components/DockContainer';
import { TauriAPI } from './api/tauri'; import { TauriAPI } from './api/tauri';
import { TransformComponent } from './example-components/TransformComponent'; import { TransformComponent } from './example-components/TransformComponent';
import { SpriteComponent } from './example-components/SpriteComponent'; import { SpriteComponent } from './example-components/SpriteComponent';
@@ -61,6 +61,7 @@ function App() {
const [messageHub, setMessageHub] = useState<MessageHub | null>(null); const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
const { t, locale, changeLocale } = useLocale(); const { t, locale, changeLocale } = useLocale();
const [status, setStatus] = useState(t('header.status.initializing')); const [status, setStatus] = useState(t('header.status.initializing'));
const [panels, setPanels] = useState<DockablePanel[]>([]);
useEffect(() => { useEffect(() => {
const initializeEditor = async () => { const initializeEditor = async () => {
@@ -199,6 +200,63 @@ function App() {
changeLocale(newLocale); 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) { if (!initialized) {
return ( return (
<div className="editor-loading"> <div className="editor-loading">
@@ -237,60 +295,7 @@ function App() {
</div> </div>
<div className="editor-content"> <div className="editor-content">
<ResizablePanel <DockContainer panels={panels} />
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>
}
/>
}
/>
</div> </div>
<div className="editor-footer"> <div className="editor-footer">

View File

@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
name: 'Name', name: 'Name',
type: 'Type', type: 'Type',
file: 'File', file: 'File',
folder: 'Folder' folder: 'Folder',
backToParent: 'Back to parent folder'
}, },
zh: { zh: {
title: '资产', title: '资产',
@@ -39,7 +40,8 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
name: '名称', name: '名称',
type: '类型', type: '类型',
file: '文件', file: '文件',
folder: '文件夹' folder: '文件夹',
backToParent: '返回上一级'
} }
}; };
@@ -93,6 +95,17 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
console.log('Open asset:', asset); 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) => { const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) { switch (extension?.toLowerCase()) {
case 'ts': case 'ts':
@@ -156,7 +169,33 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
return ( return (
<div className="asset-browser"> <div className="asset-browser">
<div className="asset-browser-header"> <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 className="asset-path">{currentPath}</div>
</div> </div>

View 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>;
}

View 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>
);
}

View 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;
}

View 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;
}