可拖动调整大小的面板
This commit is contained in:
@@ -88,6 +88,61 @@ fn read_file_content(path: String) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct DirectoryEntry {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let dir_path = Path::new(&path);
|
||||||
|
if !dir_path.exists() {
|
||||||
|
return Err(format!("Directory does not exist: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dir_path.is_dir() {
|
||||||
|
return Err(format!("Path is not a directory: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
match fs::read_dir(dir_path) {
|
||||||
|
Ok(read_dir) => {
|
||||||
|
for entry in read_dir {
|
||||||
|
match entry {
|
||||||
|
Ok(entry) => {
|
||||||
|
let entry_path = entry.path();
|
||||||
|
if let Some(name) = entry_path.file_name() {
|
||||||
|
entries.push(DirectoryEntry {
|
||||||
|
name: name.to_string_lossy().to_string(),
|
||||||
|
path: entry_path.to_string_lossy().to_string(),
|
||||||
|
is_dir: entry_path.is_dir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Error reading directory entry: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Failed to read directory: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
match (a.is_dir, b.is_dir) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
|
(false, true) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
@@ -108,7 +163,8 @@ fn main() {
|
|||||||
export_binary,
|
export_binary,
|
||||||
open_project_dialog,
|
open_project_dialog,
|
||||||
scan_directory,
|
scan_directory,
|
||||||
read_file_content
|
read_file_content,
|
||||||
|
list_directory
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -6,6 +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 { 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';
|
||||||
@@ -236,32 +237,60 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-content">
|
<div className="editor-content">
|
||||||
<div className="sidebar-left">
|
<ResizablePanel
|
||||||
{entityStore && messageHub ? (
|
direction="horizontal"
|
||||||
<SceneHierarchy entityStore={entityStore} messageHub={messageHub} />
|
defaultSize={250}
|
||||||
) : (
|
minSize={150}
|
||||||
<div className="loading">Loading...</div>
|
maxSize={400}
|
||||||
)}
|
leftOrTop={
|
||||||
</div>
|
<div className="sidebar-left">
|
||||||
|
{entityStore && messageHub ? (
|
||||||
<div className="main-content">
|
<SceneHierarchy entityStore={entityStore} messageHub={messageHub} />
|
||||||
<div className="viewport">
|
) : (
|
||||||
<h3>{t('viewport.title')}</h3>
|
<div className="loading">Loading...</div>
|
||||||
<p>{t('viewport.placeholder')}</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="bottom-panel">
|
rightOrBottom={
|
||||||
<AssetBrowser projectPath={currentProjectPath} locale={locale} />
|
<ResizablePanel
|
||||||
</div>
|
direction="horizontal"
|
||||||
</div>
|
side="right"
|
||||||
|
defaultSize={280}
|
||||||
<div className="sidebar-right">
|
minSize={200}
|
||||||
{entityStore && messageHub ? (
|
maxSize={500}
|
||||||
<EntityInspector entityStore={entityStore} messageHub={messageHub} />
|
leftOrTop={
|
||||||
) : (
|
<ResizablePanel
|
||||||
<div className="loading">Loading...</div>
|
direction="vertical"
|
||||||
)}
|
defaultSize={200}
|
||||||
</div>
|
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">
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ export class TauriAPI {
|
|||||||
static async readFileContent(path: string): Promise<string> {
|
static async readFileContent(path: string): Promise<string> {
|
||||||
return await invoke<string>('read_file_content', { path });
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { TauriAPI } from '../api/tauri';
|
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||||
import '../styles/AssetBrowser.css';
|
import '../styles/AssetBrowser.css';
|
||||||
|
|
||||||
interface AssetItem {
|
interface AssetItem {
|
||||||
@@ -58,27 +58,20 @@ export function AssetBrowser({ projectPath, locale }: AssetBrowserProps) {
|
|||||||
const loadAssets = async (path: string) => {
|
const loadAssets = async (path: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const files = await TauriAPI.scanDirectory(path, '*');
|
const entries = await TauriAPI.listDirectory(path);
|
||||||
|
|
||||||
const assetItems: AssetItem[] = files.map(filePath => {
|
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => {
|
||||||
const name = filePath.split(/[\\/]/).pop() || '';
|
const extension = entry.is_dir ? undefined :
|
||||||
const extension = name.includes('.') ? name.split('.').pop() : undefined;
|
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name: entry.name,
|
||||||
path: filePath,
|
path: entry.path,
|
||||||
type: 'file' as const,
|
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||||
extension
|
extension
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
assetItems.sort((a, b) => {
|
|
||||||
if (a.type === b.type) {
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
}
|
|
||||||
return a.type === 'folder' ? -1 : 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
setAssets(assetItems);
|
setAssets(assetItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load assets:', error);
|
console.error('Failed to load assets:', error);
|
||||||
|
|||||||
151
packages/editor-app/src/components/ResizablePanel.tsx
Normal file
151
packages/editor-app/src/components/ResizablePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,17 +63,11 @@
|
|||||||
|
|
||||||
.sidebar-left,
|
.sidebar-left,
|
||||||
.sidebar-right {
|
.sidebar-right {
|
||||||
width: 250px;
|
height: 100%;
|
||||||
background-color: #252526;
|
background-color: #252526;
|
||||||
border-right: 1px solid #3e3e3e;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-right {
|
|
||||||
border-right: none;
|
|
||||||
border-left: 1px solid #3e3e3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-left h3,
|
.sidebar-left h3,
|
||||||
.sidebar-right h3 {
|
.sidebar-right h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -91,16 +85,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
flex: 1;
|
height: 100%;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
border-bottom: 1px solid #3e3e3e;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -113,9 +104,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-panel {
|
.bottom-panel {
|
||||||
height: 200px;
|
height: 100%;
|
||||||
background-color: #252526;
|
background-color: #252526;
|
||||||
padding: 12px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
packages/editor-app/src/styles/ResizablePanel.css
Normal file
77
packages/editor-app/src/styles/ResizablePanel.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user