可拖动调整大小的面板

This commit is contained in:
YHH
2025-10-15 09:43:48 +08:00
parent d0fcc0e447
commit 82451e9fd3
7 changed files with 365 additions and 56 deletions

View File

@@ -6,6 +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 { TauriAPI } from './api/tauri';
import { TransformComponent } from './example-components/TransformComponent';
import { SpriteComponent } from './example-components/SpriteComponent';
@@ -236,32 +237,60 @@ function App() {
</div>
<div className="editor-content">
<div className="sidebar-left">
{entityStore && messageHub ? (
<SceneHierarchy entityStore={entityStore} messageHub={messageHub} />
) : (
<div className="loading">Loading...</div>
)}
</div>
<div className="main-content">
<div className="viewport">
<h3>{t('viewport.title')}</h3>
<p>{t('viewport.placeholder')}</p>
</div>
<div className="bottom-panel">
<AssetBrowser projectPath={currentProjectPath} locale={locale} />
</div>
</div>
<div className="sidebar-right">
{entityStore && messageHub ? (
<EntityInspector entityStore={entityStore} messageHub={messageHub} />
) : (
<div className="loading">Loading...</div>
)}
</div>
<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>
}
/>
}
/>
</div>
<div className="editor-footer">

View File

@@ -49,6 +49,19 @@ export class TauriAPI {
static async readFileContent(path: string): Promise<string> {
return await invoke<string>('read_file_content', { path });
}
/**
* 列出目录内容
*/
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
return await invoke<DirectoryEntry[]>('list_directory', { path });
}
}
export interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
}
/**

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { TauriAPI } from '../api/tauri';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/AssetBrowser.css';
interface AssetItem {
@@ -58,27 +58,20 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
const loadAssets = async (path: string) => {
setLoading(true);
try {
const files = await TauriAPI.scanDirectory(path, '*');
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = files.map(filePath => {
const name = filePath.split(/[\\/]/).pop() || '';
const extension = name.includes('.') ? name.split('.').pop() : undefined;
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
return {
name,
path: filePath,
type: 'file' as const,
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension
};
});
assetItems.sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name);
}
return a.type === 'folder' ? -1 : 1;
});
setAssets(assetItems);
} catch (error) {
console.error('Failed to load assets:', error);

View File

@@ -0,0 +1,151 @@
import { useState, useRef, useEffect, ReactNode } from 'react';
import '../styles/ResizablePanel.css';
interface ResizablePanelProps {
direction: 'horizontal' | 'vertical';
leftOrTop: ReactNode;
rightOrBottom: ReactNode;
defaultSize?: number;
minSize?: number;
maxSize?: number;
side?: 'left' | 'right' | 'top' | 'bottom';
}
export function ResizablePanel({
direction,
leftOrTop,
rightOrBottom,
defaultSize = 250,
minSize = 150,
maxSize = 600,
side = 'left'
}: ResizablePanelProps) {
const [size, setSize] = useState(defaultSize);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let newSize: number;
if (direction === 'horizontal') {
if (side === 'right') {
newSize = rect.right - e.clientX;
} else {
newSize = e.clientX - rect.left;
}
} else {
if (side === 'bottom') {
newSize = rect.bottom - e.clientY;
} else {
newSize = e.clientY - rect.top;
}
}
newSize = Math.max(minSize, Math.min(maxSize, newSize));
setSize(newSize);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, direction, minSize, maxSize, side]);
const handleMouseDown = () => {
setIsDragging(true);
};
const className = `resizable-panel resizable-panel-${direction}`;
const resizerClassName = `resizer resizer-${direction}`;
if (direction === 'horizontal') {
if (side === 'right') {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ flex: 1 }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ width: `${size}px` }}>
{rightOrBottom}
</div>
</div>
);
} else {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ width: `${size}px` }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ flex: 1 }}>
{rightOrBottom}
</div>
</div>
);
}
} else {
if (side === 'bottom') {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ flex: 1 }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ height: `${size}px` }}>
{rightOrBottom}
</div>
</div>
);
} else {
return (
<div ref={containerRef} className={className}>
<div className="panel-section" style={{ height: `${size}px` }}>
{leftOrTop}
</div>
<div
className={resizerClassName}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
>
<div className="resizer-handle" />
</div>
<div className="panel-section" style={{ flex: 1 }}>
{rightOrBottom}
</div>
</div>
);
}
}
}

View File

@@ -63,17 +63,11 @@
.sidebar-left,
.sidebar-right {
width: 250px;
height: 100%;
background-color: #252526;
border-right: 1px solid #3e3e3e;
overflow: hidden;
}
.sidebar-right {
border-right: none;
border-left: 1px solid #3e3e3e;
}
.sidebar-left h3,
.sidebar-right h3 {
font-size: 14px;
@@ -91,16 +85,13 @@
}
.main-content {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
overflow: hidden;
}
.viewport {
flex: 1;
height: 100%;
background-color: #1e1e1e;
border-bottom: 1px solid #3e3e3e;
padding: 12px;
display: flex;
flex-direction: column;
@@ -113,9 +104,8 @@
}
.bottom-panel {
height: 200px;
height: 100%;
background-color: #252526;
padding: 12px;
overflow-y: auto;
}

View File

@@ -0,0 +1,77 @@
.resizable-panel {
display: flex;
width: 100%;
height: 100%;
position: relative;
}
.resizable-panel-horizontal {
flex-direction: row;
}
.resizable-panel-vertical {
flex-direction: column;
}
.panel-section {
overflow: hidden;
position: relative;
}
.resizer {
background: #252526;
position: relative;
z-index: 10;
transition: background-color 0.2s ease;
}
.resizer:hover {
background: #094771;
}
.resizer-horizontal {
width: 4px;
cursor: col-resize;
flex-shrink: 0;
}
.resizer-vertical {
height: 4px;
cursor: row-resize;
flex-shrink: 0;
}
.resizer-handle {
position: absolute;
background: transparent;
}
.resizer-horizontal .resizer-handle {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 40px;
}
.resizer-vertical .resizer-handle {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 12px;
}
.resizer:active {
background: #0e6caa;
}
body.resizing {
cursor: col-resize !important;
user-select: none !important;
}
body.resizing-vertical {
cursor: row-resize !important;
user-select: none !important;
}