Feature/editor optimization (#251)
* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
@@ -8,6 +8,19 @@ import '../styles/AdvancedProfiler.css';
|
||||
/**
|
||||
* 高级性能数据接口(与 Core 的 IAdvancedProfilerData 对应)
|
||||
*/
|
||||
interface HotspotItem {
|
||||
name: string;
|
||||
category: string;
|
||||
inclusiveTime: number;
|
||||
inclusiveTimePercent: number;
|
||||
exclusiveTime: number;
|
||||
exclusiveTimePercent: number;
|
||||
callCount: number;
|
||||
avgCallTime: number;
|
||||
depth: number;
|
||||
children?: HotspotItem[];
|
||||
}
|
||||
|
||||
interface AdvancedProfilerData {
|
||||
currentFrame: {
|
||||
frameNumber: number;
|
||||
@@ -41,16 +54,7 @@ interface AdvancedProfilerData {
|
||||
percentOfFrame: number;
|
||||
}>;
|
||||
}>;
|
||||
hotspots: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
inclusiveTime: number;
|
||||
inclusiveTimePercent: number;
|
||||
exclusiveTime: number;
|
||||
exclusiveTimePercent: number;
|
||||
callCount: number;
|
||||
avgCallTime: number;
|
||||
}>;
|
||||
hotspots: HotspotItem[];
|
||||
callGraph: {
|
||||
currentFunction: string | null;
|
||||
callers: Array<{
|
||||
@@ -120,18 +124,72 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
'Custom': '#64748b'
|
||||
};
|
||||
|
||||
type DataMode = 'oneframe' | 'average' | 'maximum';
|
||||
|
||||
export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
const [data, setData] = useState<AdvancedProfilerData | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['ECS']));
|
||||
const [expandedHotspots, setExpandedHotspots] = useState<Set<string>>(new Set());
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('incTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
|
||||
const [dataMode, setDataMode] = useState<DataMode>('average');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const frameHistoryRef = useRef<Array<{ time: number; duration: number }>>([]);
|
||||
const lastDataRef = useRef<AdvancedProfilerData | null>(null);
|
||||
// 用于计算平均值和最大值的历史数据
|
||||
const hotspotHistoryRef = useRef<Map<string, { times: number[]; maxTime: number }>>(new Map());
|
||||
|
||||
// 更新历史数据
|
||||
const updateHotspotHistory = useCallback((hotspots: HotspotItem[]) => {
|
||||
const updateItem = (item: HotspotItem) => {
|
||||
const history = hotspotHistoryRef.current.get(item.name) || { times: [], maxTime: 0 };
|
||||
history.times.push(item.inclusiveTime);
|
||||
// 保留最近 60 帧的数据
|
||||
if (history.times.length > 60) {
|
||||
history.times.shift();
|
||||
}
|
||||
history.maxTime = Math.max(history.maxTime, item.inclusiveTime);
|
||||
hotspotHistoryRef.current.set(item.name, history);
|
||||
|
||||
if (item.children) {
|
||||
item.children.forEach(updateItem);
|
||||
}
|
||||
};
|
||||
hotspots.forEach(updateItem);
|
||||
}, []);
|
||||
|
||||
// 根据数据模式处理 hotspots
|
||||
const processHotspotsWithDataMode = useCallback((hotspots: HotspotItem[], mode: DataMode): HotspotItem[] => {
|
||||
if (mode === 'oneframe') {
|
||||
return hotspots;
|
||||
}
|
||||
|
||||
const processItem = (item: HotspotItem): HotspotItem => {
|
||||
const history = hotspotHistoryRef.current.get(item.name);
|
||||
let processedTime = item.inclusiveTime;
|
||||
|
||||
if (history && history.times.length > 0) {
|
||||
if (mode === 'average') {
|
||||
processedTime = history.times.reduce((a, b) => a + b, 0) / history.times.length;
|
||||
} else if (mode === 'maximum') {
|
||||
processedTime = history.maxTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
inclusiveTime: processedTime,
|
||||
avgCallTime: item.callCount > 0 ? processedTime / item.callCount : 0,
|
||||
children: item.children ? item.children.map(processItem) : undefined
|
||||
};
|
||||
};
|
||||
|
||||
return hotspots.map(processItem);
|
||||
}, []);
|
||||
|
||||
// 订阅数据更新
|
||||
useEffect(() => {
|
||||
@@ -142,18 +200,21 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
|
||||
// 解析高级性能数据
|
||||
if (rawData.advancedProfiler) {
|
||||
// 更新历史数据
|
||||
updateHotspotHistory(rawData.advancedProfiler.hotspots);
|
||||
setData(rawData.advancedProfiler);
|
||||
lastDataRef.current = rawData.advancedProfiler;
|
||||
} else if (rawData.performance) {
|
||||
// 从传统数据构建
|
||||
const advancedData = buildFromLegacyData(rawData);
|
||||
updateHotspotHistory(advancedData.hotspots);
|
||||
setData(advancedData);
|
||||
lastDataRef.current = advancedData;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [profilerService, isPaused]);
|
||||
}, [profilerService, isPaused, updateHotspotHistory]);
|
||||
|
||||
// 当选中函数变化时,通知服务端
|
||||
useEffect(() => {
|
||||
@@ -317,44 +378,90 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
return percent.toFixed(1) + '%';
|
||||
};
|
||||
|
||||
// 展平层级数据用于显示
|
||||
const flattenHotspots = (items: HotspotItem[], result: HotspotItem[] = []): HotspotItem[] => {
|
||||
for (const item of items) {
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchTerm === '' || item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (viewMode === 'flat') {
|
||||
// 扁平模式:显示所有层级的项目
|
||||
if (matchesSearch) {
|
||||
result.push({ ...item, depth: 0 }); // 扁平模式下深度都是0
|
||||
}
|
||||
if (item.children) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
} else {
|
||||
// 层级模式:根据展开状态显示
|
||||
if (matchesSearch || (item.children && item.children.some(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())))) {
|
||||
result.push(item);
|
||||
}
|
||||
if (item.children && expandedHotspots.has(item.name)) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 切换展开状态
|
||||
const toggleHotspotExpand = (name: string) => {
|
||||
setExpandedHotspots(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 排序数据
|
||||
const getSortedHotspots = () => {
|
||||
const getSortedHotspots = (): HotspotItem[] => {
|
||||
if (!data) return [];
|
||||
|
||||
const filtered = data.hotspots.filter(h =>
|
||||
searchTerm === '' || h.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
// 先根据数据模式处理 hotspots
|
||||
const processedHotspots = processHotspotsWithDataMode(data.hotspots, dataMode);
|
||||
const flattened = flattenHotspots(processedHotspots);
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'incTime':
|
||||
comparison = a.inclusiveTime - b.inclusiveTime;
|
||||
break;
|
||||
case 'incPercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
case 'excTime':
|
||||
comparison = a.exclusiveTime - b.exclusiveTime;
|
||||
break;
|
||||
case 'excPercent':
|
||||
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
|
||||
break;
|
||||
case 'calls':
|
||||
comparison = a.callCount - b.callCount;
|
||||
break;
|
||||
case 'avgTime':
|
||||
comparison = a.avgCallTime - b.avgCallTime;
|
||||
break;
|
||||
case 'framePercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
// 扁平模式下排序
|
||||
if (viewMode === 'flat') {
|
||||
return [...flattened].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'incTime':
|
||||
comparison = a.inclusiveTime - b.inclusiveTime;
|
||||
break;
|
||||
case 'incPercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
case 'excTime':
|
||||
comparison = a.exclusiveTime - b.exclusiveTime;
|
||||
break;
|
||||
case 'excPercent':
|
||||
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
|
||||
break;
|
||||
case 'calls':
|
||||
comparison = a.callCount - b.callCount;
|
||||
break;
|
||||
case 'avgTime':
|
||||
comparison = a.avgCallTime - b.avgCallTime;
|
||||
break;
|
||||
case 'framePercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// 层级模式下保持原有层级顺序
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const renderSortIcon = (column: SortColumn) => {
|
||||
@@ -512,7 +619,11 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
<Activity size={14} />
|
||||
<span className="profiler-graph-title">Call Graph</span>
|
||||
<div className="profiler-callgraph-controls">
|
||||
<select className="profiler-callgraph-type-select">
|
||||
<select
|
||||
className="profiler-callgraph-type-select"
|
||||
value={dataMode}
|
||||
onChange={(e) => setDataMode(e.target.value as DataMode)}
|
||||
>
|
||||
<option value="oneframe">One Frame</option>
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
@@ -654,49 +765,67 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-body">
|
||||
{getSortedHotspots().map((item, index) => (
|
||||
<div
|
||||
key={item.name + index}
|
||||
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedFunction(item.name)}
|
||||
>
|
||||
<div className="profiler-table-cell col-name name">
|
||||
<ChevronRight size={12} className="expand-icon" />
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-time numeric">
|
||||
{formatTime(item.inclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-percent percent">
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
|
||||
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
|
||||
{getSortedHotspots().map((item, index) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedHotspots.has(item.name);
|
||||
const indentPadding = viewMode === 'hierarchical' ? item.depth * 16 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.name + index + item.depth}
|
||||
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''} depth-${item.depth}`}
|
||||
onClick={() => setSelectedFunction(item.name)}
|
||||
>
|
||||
<div className="profiler-table-cell col-name name" style={{ paddingLeft: indentPadding }}>
|
||||
{hasChildren && viewMode === 'hierarchical' ? (
|
||||
<span
|
||||
className={`expand-icon clickable ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHotspotExpand(item.name);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="expand-icon placeholder" style={{ width: 12 }} />
|
||||
)}
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
|
||||
/>
|
||||
<span>{formatPercent(item.inclusiveTimePercent)}</span>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-time numeric">
|
||||
{formatTime(item.inclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-percent percent">
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
|
||||
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
|
||||
/>
|
||||
<span>{formatPercent(item.inclusiveTimePercent)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-time numeric">
|
||||
{formatTime(item.exclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-percent percent">
|
||||
{formatPercent(item.exclusiveTimePercent)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-calls numeric">
|
||||
{item.callCount}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-avg-calls numeric">
|
||||
{formatTime(item.avgCallTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-frame-percent percent">
|
||||
{formatPercent(item.inclusiveTimePercent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-time numeric">
|
||||
{formatTime(item.exclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-percent percent">
|
||||
{formatPercent(item.exclusiveTimePercent)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-calls numeric">
|
||||
{item.callCount}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-avg-calls numeric">
|
||||
{formatTime(item.avgCallTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-frame-percent percent">
|
||||
{formatPercent(item.inclusiveTimePercent)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,7 +845,7 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
|
||||
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
|
||||
|
||||
// 构建 hotspots
|
||||
const hotspots = systems.map((sys: any) => ({
|
||||
const hotspots: HotspotItem[] = systems.map((sys: any) => ({
|
||||
name: sys.name || sys.type || 'Unknown',
|
||||
category: 'ECS',
|
||||
inclusiveTime: sys.executionTime || 0,
|
||||
@@ -724,7 +853,8 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
|
||||
exclusiveTime: sys.executionTime || 0,
|
||||
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
|
||||
callCount: 1,
|
||||
avgCallTime: sys.executionTime || 0
|
||||
avgCallTime: sys.executionTime || 0,
|
||||
depth: 0
|
||||
}));
|
||||
|
||||
// 构建 categoryStats
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, BarChart3 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, BarChart3, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { ProfilerService } from '../services/ProfilerService';
|
||||
import { AdvancedProfiler } from './AdvancedProfiler';
|
||||
import '../styles/ProfilerWindow.css';
|
||||
@@ -15,6 +15,7 @@ interface WindowWithProfiler extends Window {
|
||||
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
|
||||
const [profilerService, setProfilerService] = useState<ProfilerService | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const service = (window as WindowWithProfiler).__PROFILER_SERVICE__;
|
||||
@@ -36,12 +37,35 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
|
||||
return () => clearInterval(interval);
|
||||
}, [profilerService]);
|
||||
|
||||
// 处理 ESC 键退出全屏
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const windowStyle = isFullscreen
|
||||
? { width: '100vw', height: '100vh', maxWidth: 'none', borderRadius: 0 }
|
||||
: { width: '90vw', height: '85vh', maxWidth: '1600px' };
|
||||
|
||||
return (
|
||||
<div className="profiler-window-overlay" onClick={onClose}>
|
||||
<div
|
||||
className={`profiler-window-overlay ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={isFullscreen ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className="profiler-window advanced-profiler-window"
|
||||
className={`profiler-window advanced-profiler-window ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: '90vw', height: '85vh', maxWidth: '1600px' }}
|
||||
style={windowStyle}
|
||||
>
|
||||
<div className="profiler-window-header">
|
||||
<div className="profiler-window-title">
|
||||
@@ -53,9 +77,18 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="profiler-window-controls">
|
||||
<button
|
||||
className="profiler-window-btn"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-content" style={{ padding: 0 }}>
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { useState, useEffect, useRef, useMemo, memo } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Wifi } from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/ConsolePanel.css';
|
||||
|
||||
interface ConsolePanelProps {
|
||||
logService: LogService;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
// 提取JSON检测和格式化逻辑
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
// 日志等级图标
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 日志等级样式类
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 单个日志条目组件
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
log: LogEntry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
}) => {
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-message">
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => onOpenJsonViewer(parsed as any)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogEntryItem.displayName = 'LogEntryItem';
|
||||
|
||||
export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
// 状态管理
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
LogLevel.Warn,
|
||||
LogLevel.Error,
|
||||
LogLevel.Fatal
|
||||
]));
|
||||
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 订阅日志更新
|
||||
useEffect(() => {
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, entry];
|
||||
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [logService]);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// 处理滚动
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
};
|
||||
|
||||
// 清空日志
|
||||
const handleClear = () => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
// 切换等级过滤
|
||||
const toggleLevelFilter = (level: LogLevel) => {
|
||||
const newFilter = new Set(levelFilter);
|
||||
if (newFilter.has(level)) {
|
||||
newFilter.delete(level);
|
||||
} else {
|
||||
newFilter.add(level);
|
||||
}
|
||||
setLevelFilter(newFilter);
|
||||
};
|
||||
|
||||
// 过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
if (showRemoteOnly && log.source !== 'remote') return false;
|
||||
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [logs, levelFilter, showRemoteOnly, filter]);
|
||||
|
||||
// 统计各等级日志数量
|
||||
const levelCounts = useMemo(() => ({
|
||||
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
|
||||
[LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length,
|
||||
[LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
|
||||
}), [logs]);
|
||||
|
||||
const remoteLogCount = useMemo(() =>
|
||||
logs.filter((l) => l.source === 'remote').length
|
||||
, [logs]);
|
||||
|
||||
return (
|
||||
<div className="console-panel">
|
||||
<div className="console-toolbar">
|
||||
<div className="console-toolbar-left">
|
||||
<button
|
||||
className="console-btn"
|
||||
onClick={handleClear}
|
||||
title="Clear console"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="console-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-toolbar-right">
|
||||
<button
|
||||
className={`console-filter-btn ${showRemoteOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowRemoteOnly(!showRemoteOnly)}
|
||||
title="Show Remote Logs Only"
|
||||
>
|
||||
<Wifi size={14} />
|
||||
{remoteLogCount > 0 && <span>{remoteLogCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Debug) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Debug)}
|
||||
title="Debug"
|
||||
>
|
||||
<Bug size={14} />
|
||||
{levelCounts[LogLevel.Debug] > 0 && <span>{levelCounts[LogLevel.Debug]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Info) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Info)}
|
||||
title="Info"
|
||||
>
|
||||
<Info size={14} />
|
||||
{levelCounts[LogLevel.Info] > 0 && <span>{levelCounts[LogLevel.Info]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Warn) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Warn)}
|
||||
title="Warnings"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
{levelCounts[LogLevel.Warn] > 0 && <span>{levelCounts[LogLevel.Warn]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Error) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Error)}
|
||||
title="Errors"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
{levelCounts[LogLevel.Error] > 0 && <span>{levelCounts[LogLevel.Error]}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="console-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="console-empty">
|
||||
<AlertCircle size={32} />
|
||||
<p>No logs to display</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="console-scroll-to-bottom"
|
||||
onClick={() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓ Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,15 @@ import {
|
||||
Trash2,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
PanelRightClose
|
||||
PanelRightClose,
|
||||
Tag,
|
||||
Link,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Package,
|
||||
Clipboard,
|
||||
RefreshCw,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
|
||||
@@ -564,9 +572,191 @@ export function ContentBrowser({
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 保存
|
||||
items.push({
|
||||
label: locale === 'zh' ? '保存' : 'Save',
|
||||
icon: <Save size={16} />,
|
||||
shortcut: 'Ctrl+S',
|
||||
onClick: () => {
|
||||
console.log('Save file:', asset.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'F2',
|
||||
onClick: () => {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
// 批量重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '批量重命名' : 'Batch Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'Shift+F2',
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
console.log('Batch rename');
|
||||
}
|
||||
});
|
||||
|
||||
// 复制
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制' : 'Duplicate',
|
||||
icon: <Clipboard size={16} />,
|
||||
shortcut: 'Ctrl+D',
|
||||
onClick: () => {
|
||||
console.log('Duplicate:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
// 删除
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
shortcut: 'Delete',
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 资产操作子菜单
|
||||
items.push({
|
||||
label: locale === 'zh' ? '资产操作' : 'Asset Actions',
|
||||
icon: <Settings size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: locale === 'zh' ? '重新导入' : 'Reimport',
|
||||
icon: <RefreshCw size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Reimport asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导出...' : 'Export...',
|
||||
icon: <Package size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Export asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{ label: '', separator: true, onClick: () => {} },
|
||||
{
|
||||
label: locale === 'zh' ? '迁移资产' : 'Migrate Asset',
|
||||
icon: <Folder size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Migrate asset:', asset.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 资产本地化子菜单
|
||||
items.push({
|
||||
label: locale === 'zh' ? '资产本地化' : 'Asset Localization',
|
||||
icon: <Globe size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: locale === 'zh' ? '创建本地化资产' : 'Create Localized Asset',
|
||||
onClick: () => {
|
||||
console.log('Create localized asset:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导入翻译' : 'Import Translation',
|
||||
onClick: () => {
|
||||
console.log('Import translation:', asset.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: locale === 'zh' ? '导出翻译' : 'Export Translation',
|
||||
onClick: () => {
|
||||
console.log('Export translation:', asset.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 标签管理
|
||||
items.push({
|
||||
label: locale === 'zh' ? '管理标签' : 'Manage Tags',
|
||||
icon: <Tag size={16} />,
|
||||
shortcut: 'Ctrl+T',
|
||||
onClick: () => {
|
||||
console.log('Manage tags:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 路径复制选项
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制引用' : 'Copy Reference',
|
||||
icon: <Link size={16} />,
|
||||
shortcut: 'Ctrl+C',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '拷贝Object路径' : 'Copy Object Path',
|
||||
icon: <Copy size={16} />,
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
onClick: () => {
|
||||
const objectPath = asset.path.replace(/\\/g, '/');
|
||||
navigator.clipboard.writeText(objectPath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '拷贝包路径' : 'Copy Package Path',
|
||||
icon: <Package size={16} />,
|
||||
shortcut: 'Ctrl+Alt+C',
|
||||
onClick: () => {
|
||||
const packagePath = '/' + asset.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
|
||||
navigator.clipboard.writeText(packagePath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 引用查看器
|
||||
items.push({
|
||||
label: locale === 'zh' ? '引用查看器' : 'Reference Viewer',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+R',
|
||||
onClick: () => {
|
||||
console.log('Open reference viewer:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '尺寸信息图' : 'Size Map',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+D',
|
||||
onClick: () => {
|
||||
console.log('Show size map:', asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 在文件管理器中显示
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
icon: <ExternalLink size={16} />,
|
||||
@@ -579,34 +769,8 @@ export function ContentBrowser({
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => navigator.clipboard.writeText(asset.path)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
setRenameDialog({ asset, newName: asset.name });
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
setDeleteConfirmDialog(asset);
|
||||
setContextMenu(null);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]);
|
||||
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder, setRenameDialog, setDeleteConfirmDialog, setContextMenu, setCreateFileDialog]);
|
||||
|
||||
// Render folder tree node
|
||||
const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => {
|
||||
@@ -818,7 +982,10 @@ export function ContentBrowser({
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, asset);
|
||||
}}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import '../styles/ContextMenu.css';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -7,6 +8,10 @@ export interface ContextMenuItem {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
/** 快捷键提示文本 */
|
||||
shortcut?: string;
|
||||
/** 子菜单项 */
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -15,9 +20,113 @@ interface ContextMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SubMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
parentRect: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子菜单组件
|
||||
*/
|
||||
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// 默认在父菜单右侧显示
|
||||
let x = parentRect.right;
|
||||
let y = parentRect.top;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = parentRect.left - rect.width;
|
||||
}
|
||||
|
||||
// 如果底部空间不足,向上调整
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = Math.max(0, viewportHeight - rect.height - 10);
|
||||
}
|
||||
|
||||
setPosition({ x, y });
|
||||
}
|
||||
}, [parentRect]);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu submenu"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
@@ -65,6 +174,17 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -79,19 +199,36 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => {
|
||||
if (!item.children) {
|
||||
setActiveSubmenuIndex(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus } from 'lucide-react';
|
||||
import {
|
||||
Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus,
|
||||
Save, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings
|
||||
} from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
@@ -614,25 +617,187 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 文件操作菜单项
|
||||
items.push({
|
||||
label: '保存',
|
||||
icon: <Save size={16} />,
|
||||
shortcut: 'Ctrl+S',
|
||||
onClick: () => {
|
||||
// TODO: 实现保存功能
|
||||
console.log('Save file:', node.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: '重命名',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'F2',
|
||||
onClick: () => {
|
||||
setRenamingNode(node.path);
|
||||
setNewName(node.name);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '批量重命名',
|
||||
icon: <Edit3 size={16} />,
|
||||
shortcut: 'Shift+F2',
|
||||
disabled: true, // TODO: 实现批量重命名
|
||||
onClick: () => {
|
||||
console.log('Batch rename');
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '复制',
|
||||
icon: <Clipboard size={16} />,
|
||||
shortcut: 'Ctrl+D',
|
||||
onClick: () => {
|
||||
// TODO: 实现复制功能
|
||||
console.log('Duplicate:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '删除',
|
||||
icon: <Trash2 size={16} />,
|
||||
shortcut: 'Delete',
|
||||
onClick: () => handleDeleteClick(node)
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 资产操作子菜单
|
||||
items.push({
|
||||
label: '资产操作',
|
||||
icon: <Settings size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: '重新导入',
|
||||
icon: <RefreshCw size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Reimport asset:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导出...',
|
||||
icon: <Package size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Export asset:', node.path);
|
||||
}
|
||||
},
|
||||
{ label: '', separator: true, onClick: () => {} },
|
||||
{
|
||||
label: '迁移资产',
|
||||
icon: <Folder size={16} />,
|
||||
onClick: () => {
|
||||
console.log('Migrate asset:', node.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 资产本地化子菜单
|
||||
items.push({
|
||||
label: '资产本地化',
|
||||
icon: <Globe size={16} />,
|
||||
onClick: () => {},
|
||||
children: [
|
||||
{
|
||||
label: '创建本地化资产',
|
||||
onClick: () => {
|
||||
console.log('Create localized asset:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导入翻译',
|
||||
onClick: () => {
|
||||
console.log('Import translation:', node.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '导出翻译',
|
||||
onClick: () => {
|
||||
console.log('Export translation:', node.path);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 标签和引用
|
||||
items.push({
|
||||
label: '管理标签',
|
||||
icon: <Tag size={16} />,
|
||||
shortcut: 'Ctrl+T',
|
||||
onClick: () => {
|
||||
console.log('Manage tags:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 路径复制选项
|
||||
items.push({
|
||||
label: '复制引用',
|
||||
icon: <Link size={16} />,
|
||||
shortcut: 'Ctrl+C',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '拷贝Object路径',
|
||||
icon: <Copy size={16} />,
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
onClick: () => {
|
||||
// 生成对象路径格式
|
||||
const objectPath = node.path.replace(/\\/g, '/');
|
||||
navigator.clipboard.writeText(objectPath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '拷贝包路径',
|
||||
icon: <Package size={16} />,
|
||||
shortcut: 'Ctrl+Alt+C',
|
||||
onClick: () => {
|
||||
// 生成包路径格式
|
||||
const packagePath = '/' + node.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
|
||||
navigator.clipboard.writeText(packagePath);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 引用查看器
|
||||
items.push({
|
||||
label: '引用查看器',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+R',
|
||||
onClick: () => {
|
||||
console.log('Open reference viewer:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '尺寸信息图',
|
||||
icon: <FileSearch size={16} />,
|
||||
shortcut: 'Alt+Shift+D',
|
||||
onClick: () => {
|
||||
console.log('Show size map:', node.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
if (node.type === 'folder') {
|
||||
items.push({
|
||||
label: '新建文件',
|
||||
@@ -675,14 +840,6 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: '复制路径',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(node.path);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Search, Filter, Settings, X, Trash2, ChevronDown,
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play, Copy
|
||||
} from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/OutputLogPanel.css';
|
||||
|
||||
interface OutputLogPanelProps {
|
||||
@@ -16,15 +15,6 @@ interface OutputLogPanelProps {
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
return { isJSON: true, parsed };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
@@ -33,103 +23,121 @@ function formatTime(date: Date): string {
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
function getLevelIcon(level: LogLevel) {
|
||||
function getLevelIcon(level: LogLevel, size: number = 14) {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
return <Bug size={size} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
return <Info size={size} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
return <AlertTriangle size={size} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
return <XCircle size={size} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
return <AlertCircle size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelClass(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
return 'output-log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
return 'output-log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
return 'output-log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
return 'output-log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
|
||||
/**
|
||||
* 尝试从消息中提取堆栈信息
|
||||
*/
|
||||
function extractStackTrace(message: string): { message: string; stack: string | null } {
|
||||
const stackPattern = /\n\s*at\s+/;
|
||||
if (stackPattern.test(message)) {
|
||||
const lines = message.split('\n');
|
||||
const messageLines: string[] = [];
|
||||
const stackLines: string[] = [];
|
||||
let inStack = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('at ') || inStack) {
|
||||
inStack = true;
|
||||
stackLines.push(line);
|
||||
} else {
|
||||
messageLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: messageLines.join('\n').trim(),
|
||||
stack: stackLines.length > 0 ? stackLines.join('\n') : null
|
||||
};
|
||||
}
|
||||
|
||||
return { message, stack: null };
|
||||
}
|
||||
|
||||
const LogEntryItem = memo(({ log, isExpanded, onToggle, onCopy }: {
|
||||
log: LogEntry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onOpenJsonViewer: (data: any) => void;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCopy: () => void;
|
||||
}) => {
|
||||
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
|
||||
const shouldTruncate = log.message.length > 200;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// 优先使用 log.stack,否则尝试从 message 中提取
|
||||
const { message, stack } = useMemo(() => {
|
||||
if (log.stack) {
|
||||
return { message: log.message, stack: log.stack };
|
||||
}
|
||||
return extractStackTrace(log.message);
|
||||
}, [log.message, log.stack]);
|
||||
|
||||
const hasStack = !!stack;
|
||||
|
||||
return (
|
||||
<div className={`output-log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
<div
|
||||
className={`output-log-entry ${getLevelClass(log.level)} ${isExpanded ? 'expanded' : ''} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${hasStack ? 'has-stack' : ''}`}
|
||||
>
|
||||
<div className="output-log-entry-main" onClick={hasStack ? onToggle : undefined} style={{ cursor: hasStack ? 'pointer' : 'default' }}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? 'Remote' : log.source}]
|
||||
</div>
|
||||
<div className="output-log-entry-message">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
className="output-log-entry-copy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="output-log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
{isExpanded && stack && (
|
||||
<div className="output-log-entry-stack">
|
||||
<div className="output-log-stack-header">调用堆栈:</div>
|
||||
{stack.split('\n').filter(line => line.trim()).map((line, index) => (
|
||||
<div key={index} className="output-log-stack-line">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="output-log-entry-message">
|
||||
<div className="output-log-message-container">
|
||||
<div className="output-log-message-text">
|
||||
{shouldTruncate && !isExpanded ? (
|
||||
<>
|
||||
<span className="output-log-message-preview">
|
||||
{log.message.substring(0, 200)}...
|
||||
</span>
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{log.message}</span>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
className="output-log-expand-btn"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && parsed !== undefined && (
|
||||
<button
|
||||
className="output-log-json-btn"
|
||||
onClick={() => onOpenJsonViewer(parsed)}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -150,10 +158,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const [showTimestamp, setShowTimestamp] = useState(true);
|
||||
const [showSource, setShowSource] = useState(true);
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -174,7 +179,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Close menus on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
|
||||
@@ -199,6 +203,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
const handleClear = useCallback(() => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
setExpandedLogIds(new Set());
|
||||
}, [logService]);
|
||||
|
||||
const toggleLevelFilter = useCallback((level: LogLevel) => {
|
||||
@@ -213,6 +218,22 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleLogExpanded = useCallback((logId: string) => {
|
||||
setExpandedLogIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId);
|
||||
} else {
|
||||
newSet.add(logId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCopyLog = useCallback((log: LogEntry) => {
|
||||
navigator.clipboard.writeText(log.message);
|
||||
}, []);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
@@ -376,26 +397,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
</button>
|
||||
{showSettingsMenu && (
|
||||
<div className="output-log-menu settings-menu">
|
||||
<div className="output-log-menu-header">
|
||||
{locale === 'zh' ? '显示选项' : 'Display Options'}
|
||||
</div>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTimestamp}
|
||||
onChange={() => setShowTimestamp(!showTimestamp)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示时间戳' : 'Show Timestamp'}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSource}
|
||||
onChange={() => setShowSource(!showSource)}
|
||||
/>
|
||||
<span>{locale === 'zh' ? '显示来源' : 'Show Source'}</span>
|
||||
</label>
|
||||
<div className="output-log-menu-divider" />
|
||||
<button
|
||||
className="output-log-menu-action"
|
||||
onClick={handleClear}
|
||||
@@ -421,7 +422,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
className={`output-log-content ${!showTimestamp ? 'hide-timestamp' : ''} ${!showSource ? 'hide-source' : ''}`}
|
||||
className="output-log-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
@@ -438,7 +439,9 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
onOpenJsonViewer={setJsonViewerData}
|
||||
isExpanded={expandedLogIds.has(String(log.id))}
|
||||
onToggle={() => toggleLogExpanded(String(log.id))}
|
||||
onCopy={() => handleCopyLog(log)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -461,14 +464,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Viewer Modal */}
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory } from '@esengine/editor-core';
|
||||
import { Check, Lock, RefreshCw, Package } from 'lucide-react';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
|
||||
@@ -30,14 +30,14 @@ const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' }
|
||||
content: { zh: '内容', en: 'Content' },
|
||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content'];
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
@@ -55,11 +55,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.loader.descriptor.id === pluginId);
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
@@ -69,11 +69,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
const newEnabled = !plugin.enabled;
|
||||
|
||||
// 检查依赖
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.loader.descriptor.id === dep.id);
|
||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
@@ -81,44 +81,76 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 检查是否有其他插件依赖此插件
|
||||
const dependents = plugins.filter(p => {
|
||||
if (!p.enabled || p.loader.descriptor.id === pluginId) return false;
|
||||
const deps = p.loader.descriptor.dependencies || [];
|
||||
return deps.some(d => d.id === pluginId);
|
||||
});
|
||||
|
||||
if (dependents.length > 0) {
|
||||
showWarning(`以下插件依赖此插件: ${dependents.map(p => p.loader.descriptor.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录待处理的更改
|
||||
const newPendingChanges = new Map(pendingChanges);
|
||||
newPendingChanges.set(pluginId, newEnabled);
|
||||
setPendingChanges(newPendingChanges);
|
||||
// 调用 PluginManager 的动态启用/禁用方法
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enabling' : 'Disabling'} plugin: ${pluginId}`);
|
||||
let success: boolean;
|
||||
if (newEnabled) {
|
||||
success = await pluginManager.enable(pluginId);
|
||||
} else {
|
||||
success = await pluginManager.disable(pluginId);
|
||||
}
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enable' : 'Disable'} result: ${success}`);
|
||||
|
||||
if (!success) {
|
||||
showWarning(newEnabled ? '启用插件失败' : '禁用插件失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.loader.descriptor.id === pluginId) {
|
||||
if (p.plugin.descriptor.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
|
||||
// 调用 PluginManager 的启用/禁用方法
|
||||
if (newEnabled) {
|
||||
pluginManager.enable(pluginId);
|
||||
} else {
|
||||
pluginManager.disable(pluginId);
|
||||
// 保存到项目配置
|
||||
savePluginConfigToProject();
|
||||
|
||||
// 通知用户(如果有编辑器模块变更)
|
||||
const hasEditorModule = !!plugin.plugin.editorModule;
|
||||
if (hasEditorModule) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存插件配置到项目文件
|
||||
*/
|
||||
const savePluginConfigToProject = async () => {
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
if (!projectService || !projectService.isProjectOpen()) {
|
||||
console.warn('[PluginListSetting] Cannot save: project not open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.descriptor.isCore)
|
||||
.map(p => p.plugin.descriptor.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
try {
|
||||
await projectService.setEnabledPlugins(enabledPlugins);
|
||||
console.log('[PluginListSetting] Plugin config saved successfully');
|
||||
} catch (error) {
|
||||
console.error('[PluginListSetting] Failed to save plugin config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.loader.descriptor.category;
|
||||
const category = plugin.plugin.descriptor.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
@@ -131,13 +163,6 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
return (
|
||||
<div className="plugin-list-setting">
|
||||
{pendingChanges.size > 0 && (
|
||||
<div className="plugin-list-notice">
|
||||
<RefreshCw size={14} />
|
||||
<span>部分更改需要重启编辑器后生效</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedCategories.map(category => (
|
||||
<div key={category} className="plugin-category">
|
||||
<div className="plugin-category-header">
|
||||
@@ -145,9 +170,9 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.loader.descriptor;
|
||||
const hasRuntime = !!plugin.loader.runtimeModule;
|
||||
const hasEditor = !!plugin.loader.editorModule;
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -18,12 +18,16 @@ export function PortManager({ onClose }: PortManagerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setServerPort(settings.get('profiler.port', '8080'));
|
||||
const savedPort = settings.get('profiler.port', 8080);
|
||||
console.log('[PortManager] Initial port from settings:', savedPort);
|
||||
setServerPort(String(savedPort));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
console.log('[PortManager] settings:changed event received:', event.detail);
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setServerPort(newPort);
|
||||
if (newPort !== undefined) {
|
||||
console.log('[PortManager] Updating port to:', newPort);
|
||||
setServerPort(String(newPort));
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown,
|
||||
Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag,
|
||||
SquareStack
|
||||
SquareStack, FolderPlus
|
||||
} from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import { CreateEntityCommand, DeleteEntityCommand, ReparentEntityCommand, DropPosition } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode {
|
||||
@@ -61,8 +61,19 @@ interface SceneHierarchyProps {
|
||||
interface EntityNode {
|
||||
entity: Entity;
|
||||
children: EntityNode[];
|
||||
isExpanded: boolean;
|
||||
depth: number;
|
||||
bIsFolder: boolean;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖放指示器位置
|
||||
*/
|
||||
enum DropIndicator {
|
||||
NONE = 'none',
|
||||
BEFORE = 'before',
|
||||
INSIDE = 'inside',
|
||||
AFTER = 'after'
|
||||
}
|
||||
|
||||
export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) {
|
||||
@@ -78,9 +89,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
|
||||
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ entityId: number; indicator: DropIndicator } | null>(null);
|
||||
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set([-1])); // -1 is scene root
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
@@ -89,6 +100,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
|
||||
const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null;
|
||||
|
||||
/**
|
||||
* 构建层级树结构
|
||||
*/
|
||||
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const buildNode = (entity: Entity, depth: number): EntityNode => {
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
const childIds = hierarchy?.childIds ?? [];
|
||||
const bIsEntityFolder = isFolder(entity.tag);
|
||||
|
||||
const children: EntityNode[] = [];
|
||||
for (const childId of childIds) {
|
||||
const childEntity = scene.findEntityById(childId);
|
||||
if (childEntity) {
|
||||
children.push(buildNode(childEntity, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entity,
|
||||
children,
|
||||
depth,
|
||||
bIsFolder: bIsEntityFolder,
|
||||
hasChildren: children.length > 0
|
||||
};
|
||||
};
|
||||
|
||||
return rootEntities.map((entity) => buildNode(entity, 1));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 扁平化树为带深度信息的列表(用于渲染)
|
||||
*/
|
||||
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
|
||||
const result: EntityNode[] = [];
|
||||
|
||||
const traverse = (nodeList: EntityNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
result.push(node);
|
||||
|
||||
const bIsExpanded = expandedSet.has(node.entity.id);
|
||||
if (bIsExpanded && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 层级树和扁平化列表
|
||||
*/
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
|
||||
const flattenedEntities = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
|
||||
[entityTree, expandedIds, flattenTree]
|
||||
);
|
||||
|
||||
// Get entity creation templates from plugins
|
||||
useEffect(() => {
|
||||
const updateTemplates = () => {
|
||||
@@ -171,7 +244,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
|
||||
const unsubSceneLoaded = messageHub.subscribe('scene:loaded', updateEntities);
|
||||
const unsubSceneNew = messageHub.subscribe('scene:new', updateEntities);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', updateEntities);
|
||||
const unsubReordered = messageHub.subscribe('entity:reordered', updateEntities);
|
||||
const unsubReparented = messageHub.subscribe('entity:reparented', updateEntities);
|
||||
|
||||
return () => {
|
||||
unsubAdd();
|
||||
@@ -180,7 +255,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
unsubSelect();
|
||||
unsubSceneLoaded();
|
||||
unsubSceneNew();
|
||||
unsubSceneRestored();
|
||||
unsubReordered();
|
||||
unsubReparented();
|
||||
};
|
||||
}, [entityStore, messageHub]);
|
||||
|
||||
@@ -258,35 +335,110 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, entityId: number) => {
|
||||
const handleDragStart = useCallback((e: React.DragEvent, entityId: number) => {
|
||||
setDraggedEntityId(entityId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', entityId.toString());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
/**
|
||||
* 根据鼠标位置计算拖放指示器位置
|
||||
* 上20%区域 = BEFORE, 中间60% = INSIDE, 下20% = AFTER
|
||||
* 所有实体都支持作为父节点接收子节点
|
||||
*/
|
||||
const calculateDropIndicator = useCallback((e: React.DragEvent, _targetNode: EntityNode): DropIndicator => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height * 0.2) {
|
||||
return DropIndicator.BEFORE;
|
||||
} else if (y > height * 0.8) {
|
||||
return DropIndicator.AFTER;
|
||||
} else {
|
||||
return DropIndicator.INSIDE;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDropTargetIndex(index);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedEntityId !== null) {
|
||||
entityStore.reorderEntity(draggedEntityId, targetIndex);
|
||||
// 不能拖放到自己
|
||||
if (draggedEntityId === targetNode.entity.id) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
setDraggedEntityId(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
// 检查是否拖到自己的子节点
|
||||
const scene = Core.scene;
|
||||
if (scene && draggedEntityId !== null) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
const draggedEntity = scene.findEntityById(draggedEntityId);
|
||||
if (draggedEntity && hierarchySystem?.isAncestorOf(draggedEntity, targetNode.entity)) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const indicator = calculateDropIndicator(e, targetNode);
|
||||
setDropTarget({ entityId: targetNode.entity.id, indicator });
|
||||
}, [draggedEntityId, calculateDropIndicator]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedEntityId === null || !dropTarget) {
|
||||
setDraggedEntityId(null);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const draggedEntity = scene.findEntityById(draggedEntityId);
|
||||
if (!draggedEntity) return;
|
||||
|
||||
// 转换 DropIndicator 到 DropPosition
|
||||
let dropPosition: DropPosition;
|
||||
switch (dropTarget.indicator) {
|
||||
case DropIndicator.BEFORE:
|
||||
dropPosition = DropPosition.BEFORE;
|
||||
break;
|
||||
case DropIndicator.INSIDE:
|
||||
dropPosition = DropPosition.INSIDE;
|
||||
// 自动展开目标节点
|
||||
setExpandedIds(prev => new Set([...prev, targetNode.entity.id]));
|
||||
break;
|
||||
case DropIndicator.AFTER:
|
||||
dropPosition = DropPosition.AFTER;
|
||||
break;
|
||||
default:
|
||||
dropPosition = DropPosition.AFTER;
|
||||
}
|
||||
|
||||
const command = new ReparentEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
draggedEntity,
|
||||
targetNode.entity,
|
||||
dropPosition
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
setDraggedEntityId(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
setDropTarget(null);
|
||||
}, [draggedEntityId, dropTarget, entityStore, messageHub, commandManager]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedEntityId(null);
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleRemoteEntityClick = (entity: RemoteEntity) => {
|
||||
setSelectedIds(new Set([entity.id]));
|
||||
@@ -373,8 +525,8 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isShowingRemote]);
|
||||
|
||||
const toggleFolderExpand = (entityId: number) => {
|
||||
setExpandedFolders(prev => {
|
||||
const toggleExpand = useCallback((entityId: number) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entityId)) {
|
||||
next.delete(entityId);
|
||||
@@ -383,7 +535,29 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 创建文件夹实体
|
||||
*/
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const folderName = locale === 'zh' ? `文件夹 ${entityCount + 1}` : `Folder ${entityCount + 1}`;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entity = scene.createEntity(folderName);
|
||||
entity.tag = EntityTags.FOLDER;
|
||||
|
||||
// 添加 HierarchyComponent 支持层级结构
|
||||
entity.addComponent(new HierarchyComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
entityStore.selectEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
}, [entityStore, messageHub, locale]);
|
||||
|
||||
const handleSortClick = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
@@ -394,20 +568,33 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
};
|
||||
|
||||
// Get entity type for display
|
||||
const getEntityType = (entity: Entity): string => {
|
||||
/**
|
||||
* 获取实体类型显示名称
|
||||
*/
|
||||
const getEntityType = useCallback((entity: Entity): string => {
|
||||
if (isFolder(entity.tag)) {
|
||||
return 'Folder';
|
||||
}
|
||||
|
||||
const components = entity.components || [];
|
||||
if (components.length > 0) {
|
||||
const firstComponent = components[0];
|
||||
return firstComponent?.constructor?.name || 'Entity';
|
||||
}
|
||||
return 'Entity';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get icon for entity type
|
||||
const getEntityIcon = (entityType: string): React.ReactNode => {
|
||||
/**
|
||||
* 获取实体类型图标
|
||||
*/
|
||||
const getEntityIcon = useCallback((entity: Entity): React.ReactNode => {
|
||||
if (isFolder(entity.tag)) {
|
||||
return <Folder size={14} className="entity-type-icon folder" />;
|
||||
}
|
||||
|
||||
const entityType = getEntityType(entity);
|
||||
return entityTypeIcons[entityType] || <Box size={14} className="entity-type-icon default" />;
|
||||
};
|
||||
}, [getEntityType]);
|
||||
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
@@ -443,13 +630,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
});
|
||||
};
|
||||
|
||||
const displayEntities = isShowingRemote
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
|
||||
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
|
||||
|
||||
const totalCount = displayEntities.length;
|
||||
const totalCount = isShowingRemote ? remoteEntities.length : entityStore.getAllEntities().length;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
return (
|
||||
@@ -479,13 +663,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
<div className="outliner-toolbar-right">
|
||||
{!isShowingRemote && (
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '添加' : 'Add'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
onClick={handleCreateFolder}
|
||||
title={locale === 'zh' ? '创建文件夹' : 'Create Folder'}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="outliner-action-btn"
|
||||
@@ -550,94 +743,129 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
{/* Entity List */}
|
||||
<div className="outliner-content" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{isShowingRemote
|
||||
? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game')
|
||||
: (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
|
||||
{isShowingRemote ? (
|
||||
// Remote entities view (flat list)
|
||||
remoteEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isShowingRemote ? (
|
||||
<div className="outliner-list">
|
||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleRemoteEntityClick(entity)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')}
|
||||
<span className="outliner-item-name">{entity.name}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">
|
||||
{entity.componentTypes?.[0] || 'Entity'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{/* World/Scene Root */}
|
||||
<div
|
||||
className={`outliner-item world-item ${expandedFolders.has(-1) ? 'expanded' : ''}`}
|
||||
onClick={() => toggleFolderExpand(-1)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span
|
||||
className="outliner-item-expand"
|
||||
onClick={(e) => { e.stopPropagation(); toggleFolderExpand(-1); }}
|
||||
>
|
||||
{expandedFolders.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<Mountain size={14} className="entity-type-icon world" />
|
||||
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">World</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Items */}
|
||||
{expandedFolders.has(-1) && entities.map((entity, index) => {
|
||||
const entityType = getEntityType(entity);
|
||||
return (
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{filterRemoteEntities(remoteEntities).map((entity) => (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleRemoteEntityClick(entity)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span className="outliner-item-expand" />
|
||||
{getEntityIcon(entityType)}
|
||||
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
{entityTypeIcons[entity.componentTypes?.[0] || 'Entity'] || <Box size={14} className="entity-type-icon default" />}
|
||||
<span className="outliner-item-name">{entity.name}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">
|
||||
{entity.componentTypes?.[0] || 'Entity'}
|
||||
</div>
|
||||
<div className="outliner-item-type">{entityType}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// Local entities view (hierarchical tree)
|
||||
entities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-hint">
|
||||
{locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{/* World/Scene Root */}
|
||||
<div
|
||||
className={`outliner-item world-item ${expandedIds.has(-1) ? 'expanded' : ''}`}
|
||||
onClick={() => toggleExpand(-1)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
<span
|
||||
className="outliner-item-expand"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(-1); }}
|
||||
>
|
||||
{expandedIds.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<Mountain size={14} className="entity-type-icon world" />
|
||||
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">World</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Entity Items */}
|
||||
{flattenedEntities.map((node) => {
|
||||
const { entity, depth, hasChildren, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + depth * 16;
|
||||
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -657,6 +885,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
entityId={contextMenu.entityId}
|
||||
pluginTemplates={pluginTemplates}
|
||||
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
|
||||
onCreateFolder={() => { handleCreateFolder(); closeContextMenu(); }}
|
||||
onCreateFromTemplate={async (template) => {
|
||||
await template.create(contextMenu.entityId ?? undefined);
|
||||
closeContextMenu();
|
||||
@@ -676,6 +905,7 @@ interface ContextMenuWithSubmenuProps {
|
||||
entityId: number | null;
|
||||
pluginTemplates: EntityCreationTemplate[];
|
||||
onCreateEmpty: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
@@ -683,7 +913,7 @@ interface ContextMenuWithSubmenuProps {
|
||||
|
||||
function ContextMenuWithSubmenu({
|
||||
x, y, locale, entityId, pluginTemplates,
|
||||
onCreateEmpty, onCreateFromTemplate, onDelete
|
||||
onCreateEmpty, onCreateFolder, onCreateFromTemplate, onDelete
|
||||
}: ContextMenuWithSubmenuProps) {
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
@@ -738,6 +968,11 @@ function ContextMenuWithSubmenu({
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
|
||||
<button onClick={onCreateFolder}>
|
||||
<Folder size={12} />
|
||||
<span>{locale === 'zh' ? '创建文件夹' : 'Create Folder'}</span>
|
||||
</button>
|
||||
|
||||
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
|
||||
|
||||
{sortedCategories.map(([category, templates]) => (
|
||||
|
||||
@@ -148,9 +148,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
} else {
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
if (key.startsWith('profiler.')) {
|
||||
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Initial values for profiler:',
|
||||
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
@@ -162,10 +167,24 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || '无效值');
|
||||
setErrors(newErrors);
|
||||
return; // 验证失败,不保存
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
setErrors(newErrors);
|
||||
|
||||
// 实时保存设置
|
||||
const settings = SettingsService.getInstance();
|
||||
if (!key.startsWith('project.')) {
|
||||
settings.set(key, value);
|
||||
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
||||
|
||||
// 触发设置变更事件
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: { [key]: value }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -208,6 +227,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,6 @@ interface StartupPageProps {
|
||||
onOpenProject: () => void;
|
||||
onCreateProject: () => void;
|
||||
onOpenRecentProject?: (projectPath: string) => void;
|
||||
onProfilerMode?: () => void;
|
||||
onLocaleChange?: (locale: Locale) => void;
|
||||
recentProjects?: string[];
|
||||
locale: string;
|
||||
@@ -22,7 +21,7 @@ const LANGUAGES = [
|
||||
{ code: 'zh', name: '中文' }
|
||||
];
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
@@ -62,10 +61,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
subtitle: 'Professional Game Development Tool',
|
||||
openProject: 'Open Project',
|
||||
createProject: 'Create Project',
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
comingSoon: 'Coming Soon',
|
||||
updateAvailable: 'New version available',
|
||||
updateNow: 'Update Now',
|
||||
installing: 'Installing...',
|
||||
@@ -76,10 +73,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
subtitle: '专业游戏开发工具',
|
||||
openProject: '打开项目',
|
||||
createProject: '创建新项目',
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
comingSoon: '即将推出',
|
||||
updateAvailable: '发现新版本',
|
||||
updateNow: '立即更新',
|
||||
installing: '正在安装...',
|
||||
@@ -126,13 +121,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
</svg>
|
||||
<span>{t.createProject}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onProfilerMode}>
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>{t.profilerMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="startup-recent">
|
||||
|
||||
@@ -8,8 +8,9 @@ import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
@@ -59,7 +60,8 @@ function generateRuntimeHtml(): string {
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'runtime-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
height: window.innerHeight,
|
||||
projectConfigUrl: '/ecs-editor.config.json'
|
||||
});
|
||||
|
||||
await runtime.initialize(esEngine);
|
||||
@@ -354,11 +356,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
|
||||
if (messageHubRef.current) {
|
||||
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
|
||||
const value = propertyName === 'position' ? transform.position :
|
||||
propertyName === 'rotation' ? transform.rotation : transform.scale;
|
||||
messageHubRef.current.publish('component:property:changed', {
|
||||
entity,
|
||||
component: transform,
|
||||
propertyName,
|
||||
value: transform[propertyName]
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -373,16 +377,29 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
const rotationSpeed = 0.01;
|
||||
uiTransform.rotation += deltaX * rotationSpeed;
|
||||
} else if (mode === 'scale') {
|
||||
const width = uiTransform.width * uiTransform.scaleX;
|
||||
const height = uiTransform.height * uiTransform.scaleY;
|
||||
const centerX = uiTransform.x + width * uiTransform.pivotX;
|
||||
const centerY = uiTransform.y + height * uiTransform.pivotY;
|
||||
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
|
||||
const oldWidth = uiTransform.width * uiTransform.scaleX;
|
||||
const oldHeight = uiTransform.height * uiTransform.scaleY;
|
||||
|
||||
// pivot点的世界坐标(缩放前)
|
||||
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
|
||||
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
|
||||
|
||||
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
|
||||
|
||||
if (startDist > 0) {
|
||||
const scaleFactor = endDist / startDist;
|
||||
uiTransform.scaleX *= scaleFactor;
|
||||
uiTransform.scaleY *= scaleFactor;
|
||||
const newScaleX = uiTransform.scaleX * scaleFactor;
|
||||
const newScaleY = uiTransform.scaleY * scaleFactor;
|
||||
|
||||
const newWidth = uiTransform.width * newScaleX;
|
||||
const newHeight = uiTransform.height * newScaleY;
|
||||
|
||||
// 调整位置使pivot点保持不动
|
||||
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
|
||||
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
|
||||
uiTransform.scaleX = newScaleX;
|
||||
uiTransform.scaleY = newScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,46 +706,118 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
// Write scene data and HTML (always update)
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||
|
||||
// Copy texture assets referenced in the scene
|
||||
// 复制场景中引用的纹理资产
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const texturePathSet = new Set<string>();
|
||||
|
||||
// Find all texture paths in sprite components
|
||||
if (sceneObj.entities) {
|
||||
for (const entity of sceneObj.entities) {
|
||||
if (entity.components) {
|
||||
for (const comp of entity.components) {
|
||||
if (comp.type === 'Sprite' && comp.data?.texture) {
|
||||
texturePathSet.add(comp.data.texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy project config file (for plugin settings)
|
||||
// 复制项目配置文件(用于插件设置)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
if (projectPath) {
|
||||
const configPath = `${projectPath}\\ecs-editor.config.json`;
|
||||
const configExists = await TauriAPI.pathExists(configPath);
|
||||
if (configExists) {
|
||||
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
|
||||
console.log('[Viewport] Copied project config to runtime dir');
|
||||
}
|
||||
}
|
||||
|
||||
// Create assets directory and copy textures
|
||||
// Create assets directory
|
||||
// 创建资产目录
|
||||
const assetsDir = `${runtimeDir}\\assets`;
|
||||
const assetsDirExists = await TauriAPI.pathExists(assetsDir);
|
||||
if (!assetsDirExists) {
|
||||
await TauriAPI.createDirectory(assetsDir);
|
||||
}
|
||||
|
||||
for (const texturePath of texturePathSet) {
|
||||
if (texturePath && (texturePath.includes(':\\') || texturePath.startsWith('/'))) {
|
||||
try {
|
||||
const filename = texturePath.split(/[/\\]/).pop() || '';
|
||||
const destPath = `${assetsDir}\\${filename}`;
|
||||
const exists = await TauriAPI.pathExists(texturePath);
|
||||
if (exists) {
|
||||
await TauriAPI.copyFile(texturePath, destPath);
|
||||
// Collect all asset paths from scene
|
||||
// 从场景中收集所有资产路径
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const assetPaths = new Set<string>();
|
||||
|
||||
// Scan all components for asset references
|
||||
if (sceneObj.entities) {
|
||||
for (const entity of sceneObj.entities) {
|
||||
if (entity.components) {
|
||||
for (const comp of entity.components) {
|
||||
// Sprite textures
|
||||
if (comp.type === 'Sprite' && comp.data?.texture) {
|
||||
assetPaths.add(comp.data.texture);
|
||||
}
|
||||
// Behavior tree assets
|
||||
if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) {
|
||||
assetPaths.add(comp.data.treeAssetId);
|
||||
}
|
||||
// Tilemap assets
|
||||
if (comp.type === 'Tilemap' && comp.data?.tmxPath) {
|
||||
assetPaths.add(comp.data.tmxPath);
|
||||
}
|
||||
// Audio assets
|
||||
if (comp.type === 'AudioSource' && comp.data?.clip) {
|
||||
assetPaths.add(comp.data.clip);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy texture ${texturePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build asset catalog and copy files
|
||||
// 构建资产目录并复制文件
|
||||
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
|
||||
|
||||
try {
|
||||
const exists = await TauriAPI.pathExists(assetPath);
|
||||
if (!exists) {
|
||||
console.warn(`[Viewport] Asset not found: ${assetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get filename and determine relative path
|
||||
const filename = assetPath.split(/[/\\]/).pop() || '';
|
||||
const destPath = `${assetsDir}\\${filename}`;
|
||||
const relativePath = `assets/${filename}`;
|
||||
|
||||
// Copy file
|
||||
await TauriAPI.copyFile(assetPath, destPath);
|
||||
|
||||
// Determine asset type from extension
|
||||
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
const typeMap: Record<string, string> = {
|
||||
'.png': 'texture', '.jpg': 'texture', '.jpeg': 'texture', '.webp': 'texture',
|
||||
'.btree': 'btree',
|
||||
'.tmx': 'tilemap', '.tsx': 'tileset',
|
||||
'.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio',
|
||||
'.json': 'json'
|
||||
};
|
||||
const assetType = typeMap[ext] || 'binary';
|
||||
|
||||
// Generate simple GUID based on path
|
||||
const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
|
||||
|
||||
catalogEntries[guid] = {
|
||||
guid,
|
||||
path: relativePath,
|
||||
type: assetType,
|
||||
size: 0,
|
||||
hash: ''
|
||||
};
|
||||
|
||||
console.log(`[Viewport] Copied asset: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Write asset catalog
|
||||
// 写入资产目录
|
||||
const assetCatalog = {
|
||||
version: '1.0.0',
|
||||
createdAt: Date.now(),
|
||||
entries: catalogEntries
|
||||
};
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
||||
|
||||
const runtimeHtml = generateRuntimeHtml();
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||
|
||||
@@ -781,6 +870,19 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
if (projectService) {
|
||||
const currentProject = projectService.getCurrentProject();
|
||||
if (currentProject?.path) {
|
||||
const configPath = `${currentProject.path}\\ecs-editor.config.json`;
|
||||
const configExists = await TauriAPI.pathExists(configPath);
|
||||
if (configExists) {
|
||||
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write scene data and HTML
|
||||
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
||||
|
||||
@@ -124,7 +124,15 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
setComponentVersion((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSceneRestored = () => {
|
||||
// 场景恢复后,清除当前选中的实体(因为旧引用已无效)
|
||||
// 用户需要重新选择实体
|
||||
setTarget(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
@@ -136,6 +144,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
|
||||
return () => {
|
||||
unsubEntitySelect();
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Component } from '@esengine/ecs-framework';
|
||||
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { ChevronDown, Lock, Unlock } from 'lucide-react';
|
||||
import '../../../styles/TransformInspector.css';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user