refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Trash2, Replace, Plus } from 'lucide-react';
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
onReplaceNode?: () => void;
|
||||
onDeleteNode?: () => void;
|
||||
onCreateNode?: () => void;
|
||||
}
|
||||
|
||||
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
nodeId,
|
||||
onReplaceNode,
|
||||
onDeleteNode,
|
||||
onCreateNode
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
const menuItemStyle = {
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px',
|
||||
transition: 'background-color 0.15s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
backgroundColor: '#2d2d30',
|
||||
border: '1px solid #454545',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 10000,
|
||||
minWidth: '150px',
|
||||
padding: '4px 0'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nodeId ? (
|
||||
<>
|
||||
{onReplaceNode && (
|
||||
<div
|
||||
onClick={onReplaceNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Replace size={14} />
|
||||
替换节点
|
||||
</div>
|
||||
)}
|
||||
{onDeleteNode && (
|
||||
<div
|
||||
onClick={onDeleteNode}
|
||||
style={{ ...menuItemStyle, color: '#f48771' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onCreateNode && (
|
||||
<div
|
||||
onClick={onCreateNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useLocale } from '../../../hooks/useLocale';
|
||||
|
||||
interface QuickCreateMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
onSearchChange: (text: string) => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onNodeSelect: (template: NodeTemplate) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
templates: NodeTemplate[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
searchText,
|
||||
selectedIndex,
|
||||
iconMap,
|
||||
onSearchChange,
|
||||
onIndexChange,
|
||||
onNodeSelect,
|
||||
onClose
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
||||
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
const searchTextLower = searchText.toLowerCase();
|
||||
const filteredTemplates = searchTextLower
|
||||
? allTemplates.filter((t: NodeTemplate) => {
|
||||
const className = t.className || '';
|
||||
return t.displayName.toLowerCase().includes(searchTextLower) ||
|
||||
t.description.toLowerCase().includes(searchTextLower) ||
|
||||
t.category.toLowerCase().includes(searchTextLower) ||
|
||||
className.toLowerCase().includes(searchTextLower);
|
||||
})
|
||||
: allTemplates;
|
||||
|
||||
const uncategorizedLabel = t('quickCreateMenu.uncategorized');
|
||||
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
|
||||
const groups = new Map<string, NodeTemplate[]>();
|
||||
|
||||
filteredTemplates.forEach((template: NodeTemplate) => {
|
||||
const category = template.category || uncategorizedLabel;
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
groups.get(category)!.push(template);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([category, templates]) => ({
|
||||
category,
|
||||
templates,
|
||||
isExpanded: searchTextLower ? true : expandedCategories.has(category)
|
||||
})).sort((a, b) => a.category.localeCompare(b.category));
|
||||
}, [filteredTemplates, expandedCategories, searchTextLower, uncategorizedLabel]);
|
||||
|
||||
const flattenedTemplates = React.useMemo(() => {
|
||||
return categoryGroups.flatMap((group) =>
|
||||
group.isExpanded ? group.templates : []
|
||||
);
|
||||
}, [categoryGroups]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||
const categories = new Set(allTemplates.map((template) => template.category || uncategorizedLabel));
|
||||
setExpandedCategories(categories);
|
||||
}
|
||||
}, [allTemplates, expandedCategories.size, uncategorizedLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll && selectedNodeRef.current) {
|
||||
selectedNodeRef.current.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setShouldAutoScroll(false);
|
||||
}
|
||||
}, [selectedIndex, shouldAutoScroll]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
let globalIndex = -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.quick-create-menu-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
.category-header {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.category-header:hover {
|
||||
background-color: #3c3c3c;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '300px',
|
||||
maxHeight: '500px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderBottom: '1px solid #3c3c3c',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('quickCreateMenu.searchPlaceholder')}
|
||||
autoFocus
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
onIndexChange(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setShouldAutoScroll(true);
|
||||
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setShouldAutoScroll(true);
|
||||
onIndexChange(Math.max(selectedIndex - 1, 0));
|
||||
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
||||
e.preventDefault();
|
||||
const selectedTemplate = flattenedTemplates[selectedIndex];
|
||||
if (selectedTemplate) {
|
||||
onNodeSelect(selectedTemplate);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
padding: '4px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#999',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
<div
|
||||
className="quick-create-menu-list"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
{categoryGroups.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{t('quickCreateMenu.noMatchingNodes')}
|
||||
</div>
|
||||
) : (
|
||||
categoryGroups.map((group) => {
|
||||
return (
|
||||
<div key={group.category} style={{ marginBottom: '4px' }}>
|
||||
<div
|
||||
className="category-header"
|
||||
onClick={() => toggleCategory(group.category)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{group.isExpanded ? (
|
||||
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||
) : (
|
||||
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{
|
||||
color: '#aaa',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
flex: 1
|
||||
}}>
|
||||
{group.category}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
{group.templates.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.isExpanded && (
|
||||
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
|
||||
{group.templates.map((template: NodeTemplate) => {
|
||||
globalIndex++;
|
||||
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||
const className = template.className || '';
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={template.className || template.displayName}
|
||||
ref={isSelected ? selectedNodeRef : null}
|
||||
onClick={() => onNodeSelect(template)}
|
||||
onMouseEnter={() => onIndexChange(globalIndex)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
|
||||
borderLeft: `3px solid ${template.color || '#666'}`,
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{IconComponent && (
|
||||
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#ccc',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '2px'
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
{className && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{className}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo, Box } from 'lucide-react';
|
||||
import { useLocale } from '../../../hooks/useLocale';
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
|
||||
interface EditorToolbarProps {
|
||||
executionMode: ExecutionMode;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
showGizmos: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
onStep: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onResetView: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onToggleGizmos: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
executionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
showGizmos,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
onStep,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onResetView,
|
||||
onClearCanvas,
|
||||
onToggleGizmos
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
backgroundColor: 'rgba(45, 45, 45, 0.95)',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={executionMode === 'running'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'running' ? '#2d2d2d' : '#4caf50',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'running' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.play')}
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
|
||||
{/* 暂停按钮 | Pause button */}
|
||||
<button
|
||||
onClick={onPause}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#ff9800',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={executionMode === 'paused' ? t('editorToolbar.resume') : t('editorToolbar.pause')}
|
||||
>
|
||||
{executionMode === 'paused' ? <Play size={16} /> : <Pause size={16} />}
|
||||
</button>
|
||||
|
||||
{/* 停止按钮 | Stop button */}
|
||||
<button
|
||||
onClick={onStop}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#f44336',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.stop')}
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
|
||||
{/* 单步执行按钮 | Step forward button */}
|
||||
<button
|
||||
onClick={onStep}
|
||||
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2d2d2d' : '#2196f3',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
|
||||
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.stepForward')}
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
|
||||
{/* 重置按钮 | Reset button */}
|
||||
<button
|
||||
onClick={onReset}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: '#9e9e9e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.reset')}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#666',
|
||||
margin: '4px 0'
|
||||
}} />
|
||||
|
||||
{/* 重置视图按钮 | Reset view button */}
|
||||
<button
|
||||
onClick={onResetView}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title={t('editorToolbar.resetView')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
View
|
||||
</button>
|
||||
|
||||
{/* 清空画布按钮 | Clear canvas button */}
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title={t('editorToolbar.clearCanvas')}
|
||||
onClick={onClearCanvas}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('editorToolbar.clear')}
|
||||
</button>
|
||||
|
||||
{/* Gizmo 开关按钮 | Gizmo toggle button */}
|
||||
<button
|
||||
onClick={onToggleGizmos}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: showGizmos ? '#4a9eff' : '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: showGizmos ? '#fff' : '#cccccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title={t('editorToolbar.toggleGizmos')}
|
||||
>
|
||||
<Box size={14} />
|
||||
Gizmos
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '24px',
|
||||
backgroundColor: '#555',
|
||||
margin: '0 4px'
|
||||
}} />
|
||||
|
||||
{/* 撤销按钮 | Undo button */}
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: canUndo ? '#3c3c3c' : '#2d2d2d',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canUndo ? '#cccccc' : '#666',
|
||||
cursor: canUndo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.undo')}
|
||||
>
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
|
||||
{/* 重做按钮 | Redo button */}
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: canRedo ? '#3c3c3c' : '#2d2d2d',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canRedo ? '#cccccc' : '#666',
|
||||
cursor: canRedo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
title={t('editorToolbar.redo')}
|
||||
>
|
||||
<Redo size={16} />
|
||||
</button>
|
||||
|
||||
{/* 状态指示器 | Status indicator */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#ccc',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
executionMode === 'running' ? '#4caf50' :
|
||||
executionMode === 'paused' ? '#ff9800' : '#666'
|
||||
}} />
|
||||
{executionMode === 'idle' ? t('editorToolbar.idle') :
|
||||
executionMode === 'running' ? t('editorToolbar.running') :
|
||||
executionMode === 'paused' ? t('editorToolbar.paused') : t('editorToolbar.step')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user