Feature/render pipeline (#232)
* refactor(engine): 重构2D渲染管线坐标系统 * feat(engine): 完善2D渲染管线和编辑器视口功能 * feat(editor): 实现Viewport变换工具系统 * feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示 * feat(editor): 实现Run on Device移动预览功能 * feat(editor): 添加组件属性控制和依赖关系系统 * feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器 * feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(ci): 迁移项目到pnpm并修复CI构建问题 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 移除 network 相关包 * chore: 移除 network 相关包
This commit is contained in:
@@ -225,7 +225,6 @@ function App() {
|
||||
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
|
||||
|
||||
services.messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
console.log('[App] Received ui:openWindow:', data);
|
||||
const { windowId } = data;
|
||||
|
||||
if (windowId === 'profiler') {
|
||||
@@ -504,18 +503,14 @@ function App() {
|
||||
};
|
||||
|
||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||
console.log('[App] handleOpenSceneByPath called with:', scenePath);
|
||||
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[App] Opening scene:', scenePath);
|
||||
await sceneManager.openScene(scenePath);
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
console.log('[App] Scene opened, state:', sceneState);
|
||||
setStatus(locale === 'zh' ? `已打开场景: ${sceneState.sceneName}` : `Scene opened: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to open scene:', error);
|
||||
@@ -615,38 +610,30 @@ function App() {
|
||||
const handleReloadPlugins = async () => {
|
||||
if (currentProjectPath && pluginManager) {
|
||||
try {
|
||||
console.log('[App] Starting plugin hot reload...');
|
||||
|
||||
// 1. 关闭所有动态面板
|
||||
console.log('[App] Closing all dynamic panels');
|
||||
setActiveDynamicPanels([]);
|
||||
|
||||
// 2. 清空当前面板列表(强制卸载插件面板组件)
|
||||
console.log('[App] Clearing plugin panels');
|
||||
setPanels((prev) => prev.filter((p) =>
|
||||
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
|
||||
));
|
||||
|
||||
// 3. 等待React完成卸载
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall)
|
||||
console.log('[App] Unloading all project plugins');
|
||||
await pluginLoader.unloadProjectPlugins(pluginManager);
|
||||
|
||||
// 5. 等待卸载完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 6. 重新加载插件
|
||||
console.log('[App] Reloading project plugins');
|
||||
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
|
||||
|
||||
// 7. 触发面板重新渲染
|
||||
console.log('[App] Triggering panel re-render');
|
||||
setPluginUpdateTrigger((prev) => prev + 1);
|
||||
|
||||
showToast(locale === 'zh' ? '插件已重新加载' : 'Plugins reloaded', 'success');
|
||||
console.log('[App] Plugin hot reload completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload plugins:', error);
|
||||
showToast(locale === 'zh' ? '重新加载插件失败' : 'Failed to reload plugins', 'error');
|
||||
@@ -690,7 +677,7 @@ function App() {
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
content: <Viewport locale={locale} />,
|
||||
content: <Viewport locale={locale} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
@@ -765,8 +752,6 @@ function App() {
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[App] Loading plugin panels:', pluginPanels);
|
||||
console.log('[App] Loading dynamic panels:', dynamicPanels);
|
||||
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
|
||||
|
||||
@@ -231,6 +231,73 @@ export class TauriAPI {
|
||||
static async readFileAsBase64(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param src 源文件路径
|
||||
* @param dst 目标文件路径
|
||||
*/
|
||||
static async copyFile(src: string, dst: string): Promise<void> {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录路径
|
||||
* @returns 临时目录路径
|
||||
*/
|
||||
static async getTempDir(): Promise<string> {
|
||||
return await invoke<string>('get_temp_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用资源目录
|
||||
* @returns 资源目录路径
|
||||
*/
|
||||
static async getAppResourceDir(): Promise<string> {
|
||||
return await invoke<string>('get_app_resource_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前工作目录
|
||||
* @returns 当前工作目录路径
|
||||
*/
|
||||
static async getCurrentDir(): Promise<string> {
|
||||
return await invoke<string>('get_current_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本地HTTP服务器
|
||||
* @param rootPath 服务器根目录
|
||||
* @param port 端口号
|
||||
* @returns 服务器URL
|
||||
*/
|
||||
static async startLocalServer(rootPath: string, port: number): Promise<string> {
|
||||
return await invoke<string>('start_local_server', { rootPath, port });
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本地HTTP服务器
|
||||
*/
|
||||
static async stopLocalServer(): Promise<void> {
|
||||
return await invoke<void>('stop_local_server');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机局域网IP地址
|
||||
* @returns 局域网IP地址
|
||||
*/
|
||||
static async getLocalIp(): Promise<string> {
|
||||
return await invoke<string>('get_local_ip');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
* @param text 要编码的文本
|
||||
* @returns base64编码的PNG图片
|
||||
*/
|
||||
static async generateQRCode(text: string): Promise<string> {
|
||||
return await invoke<string>('generate_qrcode', { text });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
@@ -17,6 +17,17 @@ import {
|
||||
PropertyRendererRegistry,
|
||||
FieldEditorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
TransformComponent,
|
||||
SpriteComponent,
|
||||
SpriteAnimatorComponent,
|
||||
TextComponent,
|
||||
CameraComponent,
|
||||
RigidBodyComponent,
|
||||
BoxColliderComponent,
|
||||
CircleColliderComponent,
|
||||
AudioSourceComponent
|
||||
} from '@esengine/ecs-components';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
@@ -43,7 +54,8 @@ import {
|
||||
Vector2FieldEditor,
|
||||
Vector3FieldEditor,
|
||||
Vector4FieldEditor,
|
||||
ColorFieldEditor
|
||||
ColorFieldEditor,
|
||||
AnimationClipsFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
|
||||
export interface EditorServices {
|
||||
@@ -81,6 +93,34 @@ export class ServiceRegistry {
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new ComponentRegistry();
|
||||
|
||||
// 注册标准组件到编辑器和核心注册表
|
||||
// Register to both editor registry (for UI) and core registry (for serialization)
|
||||
const standardComponents = [
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description' },
|
||||
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description' },
|
||||
{ name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description' },
|
||||
{ name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description' },
|
||||
{ name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description' }
|
||||
];
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description
|
||||
});
|
||||
|
||||
// Register to core registry for serialization/deserialization
|
||||
CoreComponentRegistry.register(comp.type as any);
|
||||
}
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
@@ -114,6 +154,8 @@ export class ServiceRegistry {
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
const dialog = new TauriDialogService();
|
||||
const notification = new NotificationService();
|
||||
Core.services.registerInstance(NotificationService, notification);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
@@ -140,6 +182,7 @@ export class ServiceRegistry {
|
||||
fieldEditorRegistry.register(new Vector3FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class UpdateComponentCommand extends BaseCommand {
|
||||
execute(): void {
|
||||
(this.component as any)[this.propertyName] = this.newValue;
|
||||
|
||||
this.messageHub.publish('component:updated', {
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
@@ -34,7 +34,7 @@ export class UpdateComponentCommand extends BaseCommand {
|
||||
undo(): void {
|
||||
(this.component as any)[this.propertyName] = this.oldValue;
|
||||
|
||||
this.messageHub.publish('component:updated', {
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带动画组件的Sprite实体命令
|
||||
*/
|
||||
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform、Sprite和Animator组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new SpriteAnimatorComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建动画Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Camera组件的实体命令
|
||||
*/
|
||||
export class CreateCameraEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Camera组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new CameraComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Camera实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
@@ -27,6 +28,9 @@ export class CreateEntityCommand extends BaseCommand {
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 自动添加Transform组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent } from '@esengine/ecs-components';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Sprite组件的实体命令
|
||||
*/
|
||||
export class CreateSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加Transform和Sprite组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
this.parentEntity.addChild(this.entity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export { CreateEntityCommand } from './CreateEntityCommand';
|
||||
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
|
||||
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
|
||||
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
|
||||
export { DeleteEntityCommand } from './DeleteEntityCommand';
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||||
@@ -83,7 +84,7 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
} else {
|
||||
setAssets([]);
|
||||
setCurrentPath(null);
|
||||
setSelectedPath(null);
|
||||
setSelectedPaths(new Set());
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
@@ -92,21 +93,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
setCurrentPath(dirPath);
|
||||
loadAssets(dirPath);
|
||||
// Load assets first, then set selection after list is populated
|
||||
await loadAssets(dirPath);
|
||||
setSelectedPaths(new Set([filePath]));
|
||||
|
||||
// Expand tree to reveal the file
|
||||
if (showDetailView) {
|
||||
detailViewFileTreeRef.current?.revealPath(filePath);
|
||||
} else {
|
||||
treeOnlyViewFileTreeRef.current?.revealPath(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
}, [showDetailView]);
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
@@ -211,8 +220,57 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => {
|
||||
if (paths.length === 0) return;
|
||||
const path = paths[0];
|
||||
if (!path) return;
|
||||
|
||||
if (modifiers.shiftKey && paths.length > 1) {
|
||||
// Range select - paths already contains the range from FileTree
|
||||
setSelectedPaths(new Set(paths));
|
||||
} else if (modifiers.ctrlKey) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(path)) {
|
||||
newSelected.delete(path);
|
||||
} else {
|
||||
newSelected.add(path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(path);
|
||||
} else {
|
||||
setSelectedPaths(new Set([path]));
|
||||
setLastSelectedPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => {
|
||||
const filteredAssets = searchQuery.trim() ? searchResults : assets;
|
||||
|
||||
if (e.shiftKey && lastSelectedPath) {
|
||||
// Range select with Shift
|
||||
const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath);
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path);
|
||||
setSelectedPaths(new Set(rangePaths));
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Multi-select with Ctrl/Cmd
|
||||
const newSelected = new Set(selectedPaths);
|
||||
if (newSelected.has(asset.path)) {
|
||||
newSelected.delete(asset.path);
|
||||
} else {
|
||||
newSelected.add(asset.path);
|
||||
}
|
||||
setSelectedPaths(newSelected);
|
||||
setLastSelectedPath(asset.path);
|
||||
} else {
|
||||
// Single select
|
||||
setSelectedPaths(new Set([asset.path]));
|
||||
setLastSelectedPath(asset.path);
|
||||
}
|
||||
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
@@ -275,8 +333,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
|
||||
// 更新选中路径
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(newPath);
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
newSelected.add(newPath);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setRenameDialog(null);
|
||||
@@ -300,8 +361,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
|
||||
// 清除选中状态
|
||||
if (selectedPath === asset.path) {
|
||||
setSelectedPath(null);
|
||||
if (selectedPaths.has(asset.path)) {
|
||||
const newSelected = new Set(selectedPaths);
|
||||
newSelected.delete(asset.path);
|
||||
setSelectedPaths(newSelected);
|
||||
}
|
||||
|
||||
setDeleteConfirmDialog(null);
|
||||
@@ -637,100 +700,120 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(loading || isSearching) ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => {
|
||||
const relativePath = getRelativePath(asset.path);
|
||||
const showPath = searchQuery.trim() && relativePath;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||
onClick={() => handleAssetClick(asset)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
// 设置拖拽的数据
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('asset-name', asset.name);
|
||||
e.dataTransfer.setData('asset-extension', asset.extension || '');
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
|
||||
// 设置拖拽时的视觉效果
|
||||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-9999px';
|
||||
dragImage.style.opacity = '0.8';
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: asset.type === 'file' ? 'grab' : 'pointer'
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.path}>
|
||||
{asset.name}
|
||||
</div>
|
||||
{showPath && (
|
||||
<div className="asset-path" style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{relativePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{(loading || isSearching) ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => {
|
||||
const relativePath = getRelativePath(asset.path);
|
||||
const showPath = searchQuery.trim() && relativePath;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
draggable={asset.type === 'file'}
|
||||
onDragStart={(e) => {
|
||||
if (asset.type === 'file') {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Get all selected file assets
|
||||
const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1
|
||||
? Array.from(selectedPaths)
|
||||
.filter((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return a && a.type === 'file';
|
||||
})
|
||||
.map((p) => {
|
||||
const a = assets?.find((item) => item.path === p);
|
||||
return { type: 'file', path: p, name: a?.name, extension: a?.extension };
|
||||
})
|
||||
: [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }];
|
||||
|
||||
// Set drag data as JSON array for multi-file support
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('asset-name', asset.name);
|
||||
e.dataTransfer.setData('asset-extension', asset.extension || '');
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
|
||||
// 设置拖拽时的视觉效果
|
||||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-9999px';
|
||||
dragImage.style.opacity = '0.8';
|
||||
if (selectedFiles.length > 1) {
|
||||
dragImage.textContent = `${selectedFiles.length} files`;
|
||||
}
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: asset.type === 'file' ? 'grab' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{getFileIcon(asset)}
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.path}>
|
||||
{asset.name}
|
||||
</div>
|
||||
{showPath && (
|
||||
<div className="asset-path" style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{relativePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="asset-browser-tree-only">
|
||||
<FileTree
|
||||
ref={treeOnlyViewFileTreeRef}
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
onSelectFiles={handleTreeMultiSelect}
|
||||
selectedPath={Array.from(selectedPaths)[0] || currentPath}
|
||||
selectedPaths={selectedPaths}
|
||||
messageHub={messageHub}
|
||||
searchQuery={searchQuery}
|
||||
showFiles={true}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
try {
|
||||
const registry = Core.services.resolve(CompilerRegistry);
|
||||
console.log('[CompilerConfigDialog] Registry resolved:', registry);
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
|
||||
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
|
||||
const comp = registry.get(compilerId);
|
||||
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
|
||||
setCompiler(comp || null);
|
||||
@@ -74,7 +74,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
},
|
||||
listDirectory: async (path: string): Promise<FileEntry[]> => {
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
return entries.map(e => ({
|
||||
return entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
isDirectory: e.is_dir
|
||||
@@ -96,8 +96,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
|
||||
const ext = pattern.replace(/\*/g, '');
|
||||
return entries
|
||||
.filter(e => !e.is_dir && e.name.endsWith(ext))
|
||||
.map(e => e.name.replace(ext, ''));
|
||||
.filter((e) => !e.is_dir && e.name.endsWith(ext))
|
||||
.map((e) => e.name.replace(ext, ''));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,10 +19,20 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedEntity(data.entity);
|
||||
setSelectedEntity((prev) => {
|
||||
// Only reset version when selecting a different entity
|
||||
// 只在选择不同实体时重置版本
|
||||
if (prev?.id !== data.entity?.id) {
|
||||
setComponentVersion(0);
|
||||
} else {
|
||||
// Same entity re-selected, trigger refresh
|
||||
// 同一实体重新选择,触发刷新
|
||||
setComponentVersion((v) => v + 1);
|
||||
}
|
||||
return data.entity;
|
||||
});
|
||||
setRemoteEntity(null);
|
||||
setRemoteEntityDetails(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const handleRemoteSelection = (data: { entity: any }) => {
|
||||
@@ -45,6 +55,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
@@ -53,6 +64,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
unsubRemoteSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
@@ -80,6 +92,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
|
||||
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
|
||||
if (!selectedEntity) return;
|
||||
|
||||
// Actually update the component property
|
||||
// 实际更新组件属性
|
||||
component[propertyName] = value;
|
||||
|
||||
messageHub.publish('component:property:changed', {
|
||||
entity: selectedEntity,
|
||||
component,
|
||||
@@ -500,6 +517,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
|
||||
{isExpanded && (
|
||||
<div className="component-properties animate-slideDown">
|
||||
<PropertyInspector
|
||||
key={`${index}-${componentVersion}`}
|
||||
component={component}
|
||||
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,9 @@ interface TreeNode {
|
||||
interface FileTreeProps {
|
||||
rootPath: string | null;
|
||||
onSelectFile?: (path: string) => void;
|
||||
onSelectFiles?: (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => void;
|
||||
selectedPath?: string | null;
|
||||
selectedPaths?: Set<string>;
|
||||
messageHub?: MessageHub;
|
||||
searchQuery?: string;
|
||||
showFiles?: boolean;
|
||||
@@ -31,12 +33,30 @@ interface FileTreeProps {
|
||||
export interface FileTreeHandle {
|
||||
collapseAll: () => void;
|
||||
refresh: () => void;
|
||||
revealPath: (targetPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
|
||||
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, onSelectFiles, selectedPath, selectedPaths, messageHub, searchQuery, showFiles = true }, ref) => {
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
|
||||
const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null);
|
||||
|
||||
// Flatten visible file nodes for range selection
|
||||
const getVisibleFilePaths = (nodes: TreeNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: TreeNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
if (node.type === 'file') {
|
||||
paths.push(node.path);
|
||||
} else if (node.type === 'folder' && node.expanded && node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
node: TreeNode | null;
|
||||
@@ -49,7 +69,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
parentPath: string;
|
||||
templateExtension?: string;
|
||||
templateContent?: (fileName: string) => Promise<string>;
|
||||
} | null>(null);
|
||||
} | null>(null);
|
||||
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
|
||||
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||
|
||||
@@ -65,13 +85,84 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return node;
|
||||
};
|
||||
|
||||
const collapsedTree = tree.map(node => collapseNode(node));
|
||||
const collapsedTree = tree.map((node) => collapseNode(node));
|
||||
setTree(collapsedTree);
|
||||
};
|
||||
|
||||
// Expand tree to reveal a specific file path
|
||||
const revealPath = async (targetPath: string) => {
|
||||
if (!rootPath || !targetPath.startsWith(rootPath)) return;
|
||||
|
||||
// Get path segments between root and target
|
||||
const relativePath = targetPath.substring(rootPath.length).replace(/^[/\\]/, '');
|
||||
const segments = relativePath.split(/[/\\]/);
|
||||
|
||||
// Build list of folder paths to expand
|
||||
const pathsToExpand: string[] = [];
|
||||
let currentPath = rootPath;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${segments[i]}`;
|
||||
pathsToExpand.push(currentPath.replace(/\//g, '\\'));
|
||||
}
|
||||
|
||||
// Recursively expand nodes and load children
|
||||
const expandToPath = async (nodes: TreeNode[], pathSet: Set<string>): Promise<TreeNode[]> => {
|
||||
const result: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const normalizedPath = node.path.replace(/\//g, '\\');
|
||||
if (node.type === 'folder' && pathSet.has(normalizedPath)) {
|
||||
// Load children if not loaded
|
||||
let children = node.children;
|
||||
if (!node.loaded || !children) {
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(node.path);
|
||||
children = entries.map((entry: DirectoryEntry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
size: entry.size,
|
||||
modified: entry.modified,
|
||||
expanded: false,
|
||||
loaded: false
|
||||
})).sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
} catch (error) {
|
||||
children = [];
|
||||
}
|
||||
}
|
||||
// Recursively expand children
|
||||
const expandedChildren = await expandToPath(children, pathSet);
|
||||
result.push({
|
||||
...node,
|
||||
expanded: true,
|
||||
loaded: true,
|
||||
children: expandedChildren
|
||||
});
|
||||
} else if (node.type === 'folder' && node.children) {
|
||||
// Keep existing state for non-target folders
|
||||
result.push({
|
||||
...node,
|
||||
children: await expandToPath(node.children, pathSet)
|
||||
});
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const pathSet = new Set(pathsToExpand);
|
||||
const expandedTree = await expandToPath(tree, pathSet);
|
||||
setTree(expandedTree);
|
||||
setInternalSelectedPath(targetPath);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
collapseAll,
|
||||
refresh: refreshTree
|
||||
refresh: refreshTree,
|
||||
revealPath
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,8 +183,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
const performSearch = async () => {
|
||||
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes
|
||||
.filter(node => showFiles || node.type === 'folder')
|
||||
.map(node => ({
|
||||
.filter((node) => showFiles || node.type === 'folder')
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? filterByFileType(node.children) : node.children
|
||||
}));
|
||||
@@ -280,7 +371,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
children = await loadChildren(node);
|
||||
}
|
||||
const restoredChildren = await Promise.all(
|
||||
children.map(child => restoreExpandedState(child))
|
||||
children.map((child) => restoreExpandedState(child))
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
@@ -290,7 +381,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
};
|
||||
} else if (node.type === 'folder' && node.children) {
|
||||
const restoredChildren = await Promise.all(
|
||||
node.children.map(child => restoreExpandedState(child))
|
||||
node.children.map((child) => restoreExpandedState(child))
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
@@ -325,7 +416,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
}
|
||||
|
||||
const expandedChildren = await Promise.all(
|
||||
children.map(child => expandNode(child))
|
||||
children.map((child) => expandNode(child))
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -338,7 +429,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return node;
|
||||
};
|
||||
|
||||
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
|
||||
const expandedTree = await Promise.all(tree.map((node) => expandNode(node)));
|
||||
setTree(expandedTree);
|
||||
};
|
||||
|
||||
@@ -574,13 +665,39 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
return items;
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: TreeNode) => {
|
||||
const handleNodeClick = (node: TreeNode, e: React.MouseEvent) => {
|
||||
if (node.type === 'folder') {
|
||||
setInternalSelectedPath(node.path);
|
||||
onSelectFile?.(node.path);
|
||||
toggleNode(node.path);
|
||||
} else {
|
||||
setInternalSelectedPath(node.path);
|
||||
|
||||
// Support multi-select with Ctrl/Cmd or Shift
|
||||
if (onSelectFiles) {
|
||||
if (e.shiftKey && lastSelectedFilePath) {
|
||||
// Range select with Shift
|
||||
const treeToUse = searchQuery ? filteredTree : tree;
|
||||
const visiblePaths = getVisibleFilePaths(treeToUse);
|
||||
const lastIndex = visiblePaths.indexOf(lastSelectedFilePath);
|
||||
const currentIndex = visiblePaths.indexOf(node.path);
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const rangePaths = visiblePaths.slice(start, end + 1);
|
||||
onSelectFiles(rangePaths, { ctrlKey: false, shiftKey: true });
|
||||
} else {
|
||||
onSelectFiles([node.path], { ctrlKey: false, shiftKey: false });
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
} else {
|
||||
onSelectFiles([node.path], { ctrlKey: e.ctrlKey || e.metaKey, shiftKey: false });
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
} else {
|
||||
setLastSelectedFilePath(node.path);
|
||||
}
|
||||
|
||||
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
|
||||
messageHub?.publish('asset-file:selected', {
|
||||
fileInfo: {
|
||||
@@ -622,7 +739,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
};
|
||||
|
||||
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||
const isSelected = (internalSelectedPath || selectedPath) === node.path;
|
||||
const isSelected = selectedPaths
|
||||
? selectedPaths.has(node.path)
|
||||
: (internalSelectedPath || selectedPath) === node.path;
|
||||
const isRenaming = renamingNode === node.path;
|
||||
const indent = level * 16;
|
||||
|
||||
@@ -631,14 +750,30 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
<div
|
||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
|
||||
onClick={() => !isRenaming && handleNodeClick(node)}
|
||||
onClick={(e) => !isRenaming && handleNodeClick(node, e)}
|
||||
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
||||
onContextMenu={(e) => handleContextMenu(e, node)}
|
||||
draggable={node.type === 'file' && !isRenaming}
|
||||
onDragStart={(e) => {
|
||||
if (node.type === 'file' && !isRenaming) {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
// 设置拖拽的数据
|
||||
|
||||
// Get all selected files for multi-file drag
|
||||
const selectedFiles = selectedPaths && selectedPaths.has(node.path) && selectedPaths.size > 1
|
||||
? Array.from(selectedPaths).map((p) => {
|
||||
const name = p.split(/[/\\]/).pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop() : '';
|
||||
return { type: 'file', path: p, name, extension: ext };
|
||||
})
|
||||
: [{
|
||||
type: 'file',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
extension: node.name.includes('.') ? node.name.split('.').pop() : ''
|
||||
}];
|
||||
|
||||
// Set drag data as JSON array for multi-file support
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
|
||||
e.dataTransfer.setData('asset-path', node.path);
|
||||
e.dataTransfer.setData('asset-name', node.name);
|
||||
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
|
||||
@@ -748,18 +883,18 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
||||
<PromptDialog
|
||||
title={
|
||||
promptDialog.type === 'create-file' ? '新建文件' :
|
||||
promptDialog.type === 'create-folder' ? '新建文件夹' :
|
||||
'新建文件'
|
||||
promptDialog.type === 'create-folder' ? '新建文件夹' :
|
||||
'新建文件'
|
||||
}
|
||||
message={
|
||||
promptDialog.type === 'create-file' ? '请输入文件名:' :
|
||||
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
|
||||
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
|
||||
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
|
||||
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
|
||||
}
|
||||
placeholder={
|
||||
promptDialog.type === 'create-file' ? '例如: config.json' :
|
||||
promptDialog.type === 'create-folder' ? '例如: assets' :
|
||||
'例如: MyFile'
|
||||
promptDialog.type === 'create-folder' ? '例如: assets' :
|
||||
'例如: MyFile'
|
||||
}
|
||||
confirmText="创建"
|
||||
cancelText="取消"
|
||||
|
||||
@@ -33,11 +33,11 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
|
||||
const currentPanelIds = panels.map(p => p.id).sort().join(',');
|
||||
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
|
||||
const previousIds = previousPanelIdsRef.current;
|
||||
|
||||
// 检查标题是否变化
|
||||
const currentTitles = new Map(panels.map(p => [p.id, p.title]));
|
||||
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
|
||||
const titleChanges: Array<{ id: string; newTitle: string }> = [];
|
||||
|
||||
for (const panel of panels) {
|
||||
@@ -66,17 +66,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
}
|
||||
|
||||
// 计算新增和移除的面板
|
||||
const prevSet = new Set(previousIds.split(',').filter(id => id));
|
||||
const currSet = new Set(currentPanelIds.split(',').filter(id => id));
|
||||
const newPanelIds = Array.from(currSet).filter(id => !prevSet.has(id));
|
||||
const removedPanelIds = Array.from(prevSet).filter(id => !currSet.has(id));
|
||||
const prevSet = new Set(previousIds.split(',').filter((id) => id));
|
||||
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
|
||||
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
|
||||
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
|
||||
|
||||
previousPanelIdsRef.current = currentPanelIds;
|
||||
|
||||
// 如果已经有布局且只是添加新面板,使用Action动态添加
|
||||
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
|
||||
// 找到要添加的面板
|
||||
const newPanels = panels.filter(p => newPanelIds.includes(p.id));
|
||||
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
|
||||
// 找到中心区域的tabset ID
|
||||
let centerTabsetId: string | null = null;
|
||||
@@ -101,7 +101,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
|
||||
|
||||
if (centerTabsetId) {
|
||||
// 动态添加tab到中心tabset
|
||||
newPanels.forEach(panel => {
|
||||
newPanels.forEach((panel) => {
|
||||
model.doAction(Actions.addNode(
|
||||
{
|
||||
type: 'tab',
|
||||
|
||||
@@ -868,7 +868,7 @@ function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: Proper
|
||||
const keys = Object.keys(val);
|
||||
if (keys.length === 0) return '{}';
|
||||
if (keys.length <= 2) {
|
||||
const preview = keys.map(k => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
|
||||
const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
|
||||
return `{${preview}}`;
|
||||
}
|
||||
return `{${keys.slice(0, 2).join(', ')}...}`;
|
||||
@@ -996,7 +996,7 @@ function ImagePreview({ src, alt }: ImagePreviewProps) {
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setScale(prev => Math.min(Math.max(prev * delta, 0.1), 10));
|
||||
setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -83,12 +83,10 @@ export function MenuBar({
|
||||
});
|
||||
|
||||
setPluginMenuItems(filteredItems);
|
||||
console.log('[MenuBar] Updated menu items:', filteredItems);
|
||||
} else if (uiRegistry) {
|
||||
// 如果没有 pluginManager,显示所有菜单项
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
console.log('[MenuBar] Updated menu items (no filter):', items);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,17 +97,14 @@ export function MenuBar({
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
console.log('[MenuBar] Plugin installed, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
console.log('[MenuBar] Plugin enabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
console.log('[MenuBar] Plugin disabled, updating menu items');
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
|
||||
};
|
||||
|
||||
const wizardContent = (
|
||||
<div className={inline ? "plugin-publish-wizard inline" : "plugin-publish-wizard"} onClick={(e) => inline ? undefined : e.stopPropagation()}>
|
||||
<div className={inline ? 'plugin-publish-wizard inline' : 'plugin-publish-wizard'} onClick={(e) => inline ? undefined : e.stopPropagation()}>
|
||||
<div className="plugin-publish-header">
|
||||
<h2>{t('title')}</h2>
|
||||
{!inline && (
|
||||
@@ -566,67 +566,67 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
|
||||
</div>
|
||||
|
||||
<div className="plugin-publish-content">
|
||||
{step === 'auth' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepAuth')}</h3>
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={handleAuthSuccess}
|
||||
locale={locale}
|
||||
/>
|
||||
{step === 'auth' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepAuth')}</h3>
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={handleAuthSuccess}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'selectSource' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepSelectSource')}</h3>
|
||||
<p>{t('selectSourceDesc')}</p>
|
||||
|
||||
<div className="source-type-selection">
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('folder')}
|
||||
>
|
||||
<FolderOpen size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeFolder')}</strong>
|
||||
<p>{t('selectFolderDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('zip')}
|
||||
>
|
||||
<FileArchive size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeZip')}</strong>
|
||||
<p>{t('selectZipDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'selectSource' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepSelectSource')}</h3>
|
||||
<p>{t('selectSourceDesc')}</p>
|
||||
{/* ZIP 文件要求说明 */}
|
||||
<details className="zip-requirements-details">
|
||||
<summary>
|
||||
<AlertCircle size={16} />
|
||||
{t('zipRequirements')}
|
||||
</summary>
|
||||
<div className="zip-requirements-content">
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipStructure')}</h4>
|
||||
<p>{t('zipStructureDetails')}</p>
|
||||
<ul>
|
||||
<li><code>package.json</code> - {t('zipFile1')}</li>
|
||||
<li><code>dist/</code> - {t('zipFile2')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="source-type-selection">
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('folder')}
|
||||
>
|
||||
<FolderOpen size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeFolder')}</strong>
|
||||
<p>{t('selectFolderDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSource('zip')}
|
||||
>
|
||||
<FileArchive size={24} />
|
||||
<div className="source-type-info">
|
||||
<strong>{t('sourceTypeZip')}</strong>
|
||||
<p>{t('selectZipDesc')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ZIP 文件要求说明 */}
|
||||
<details className="zip-requirements-details">
|
||||
<summary>
|
||||
<AlertCircle size={16} />
|
||||
{t('zipRequirements')}
|
||||
</summary>
|
||||
<div className="zip-requirements-content">
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipStructure')}</h4>
|
||||
<p>{t('zipStructureDetails')}</p>
|
||||
<ul>
|
||||
<li><code>package.json</code> - {t('zipFile1')}</li>
|
||||
<li><code>dist/</code> - {t('zipFile2')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipBuildScript')}</h4>
|
||||
<p>{t('zipBuildScriptDesc')}</p>
|
||||
<pre className="build-script-example">
|
||||
{`npm install
|
||||
<div className="requirement-section">
|
||||
<h4>{t('zipBuildScript')}</h4>
|
||||
<p>{t('zipBuildScriptDesc')}</p>
|
||||
<pre className="build-script-example">
|
||||
{`npm install
|
||||
npm run build
|
||||
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
|
||||
# ZIP 结构:
|
||||
@@ -634,309 +634,309 @@ npm run build
|
||||
# ├── package.json
|
||||
# └── dist/
|
||||
# └── index.esm.js`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="recommendation-notice">
|
||||
{t('recommendFolder')}
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="selected-source">
|
||||
{parsedPluginInfo.sourceType === 'folder' ? (
|
||||
<FolderOpen size={20} />
|
||||
) : (
|
||||
<FileArchive size={20} />
|
||||
)}
|
||||
<div className="source-details">
|
||||
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
|
||||
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
|
||||
</div>
|
||||
<div className="recommendation-notice">
|
||||
{t('recommendFolder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
{parsedPluginInfo && (
|
||||
<div className="selected-source">
|
||||
{parsedPluginInfo.sourceType === 'folder' ? (
|
||||
<FolderOpen size={20} />
|
||||
) : (
|
||||
<FileArchive size={20} />
|
||||
)}
|
||||
<div className="source-details">
|
||||
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
|
||||
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('auth')}>
|
||||
{t('back')}
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedPluginInfo && (
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('auth')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'info' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepInfo')}</h3>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'info' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepInfo')}</h3>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<div className="form-group">
|
||||
<label>{t('version')} *</label>
|
||||
{isUpdate && (
|
||||
<div className="version-info">
|
||||
<div className="version-notice">
|
||||
<CheckCircle size={16} />
|
||||
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
|
||||
</div>
|
||||
{suggestedVersion && (
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
type="button"
|
||||
className="btn-version-suggest"
|
||||
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
{t('suggestedVersion')}: {suggestedVersion}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('version')} *</label>
|
||||
{isUpdate && (
|
||||
<div className="version-info">
|
||||
<div className="version-notice">
|
||||
<CheckCircle size={16} />
|
||||
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
|
||||
</div>
|
||||
{suggestedVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-version-suggest"
|
||||
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
|
||||
>
|
||||
{t('suggestedVersion')}: {suggestedVersion}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.version || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
{isUpdate && (
|
||||
<details className="version-history">
|
||||
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
|
||||
<ul>
|
||||
{existingVersions.map((v) => (
|
||||
<li key={v}>v{v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={publishInfo.releaseNotes || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isUpdate && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>{t('category')} *</label>
|
||||
<select
|
||||
value={publishInfo.category}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
|
||||
}
|
||||
>
|
||||
<option value="community">{t('community')}</option>
|
||||
<option value="official">{t('official')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('repositoryUrl')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.repositoryUrl || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
|
||||
placeholder={t('repositoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('tags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.tags?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({
|
||||
...publishInfo,
|
||||
tags: e.target.value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
placeholder={t('tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.version || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
{isUpdate && (
|
||||
<details className="version-history">
|
||||
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
|
||||
<ul>
|
||||
{existingVersions.map((v) => (
|
||||
<li key={v}>v{v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleNext}>
|
||||
{sourceType === 'zip' ? t('next') : t('build')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'building' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('building')}</h3>
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, i) => (
|
||||
<div key={i} className="log-line">
|
||||
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
|
||||
<span>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('releaseNotes')} *</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={publishInfo.releaseNotes || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
|
||||
placeholder={t('releaseNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepConfirm')}</h3>
|
||||
|
||||
<p>{t('confirmMessage')}</p>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
{!isUpdate && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>{t('category')} *</label>
|
||||
<select
|
||||
value={publishInfo.category}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
|
||||
}
|
||||
>
|
||||
<option value="community">{t('community')}</option>
|
||||
<option value="official">{t('official')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm-details">
|
||||
<div className="form-group">
|
||||
<label>{t('repositoryUrl')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.repositoryUrl || ''}
|
||||
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
|
||||
placeholder={t('repositoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('tags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publishInfo.tags?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setPublishInfo({
|
||||
...publishInfo,
|
||||
tags: e.target.value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
placeholder={t('tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleNext}>
|
||||
{sourceType === 'zip' ? t('next') : t('build')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'building' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('building')}</h3>
|
||||
<div className="build-log">
|
||||
{buildLog.map((log, i) => (
|
||||
<div key={i} className="log-line">
|
||||
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
|
||||
<span>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="publish-step">
|
||||
<h3>{t('stepConfirm')}</h3>
|
||||
|
||||
<p>{t('confirmMessage')}</p>
|
||||
|
||||
{existingPR && (
|
||||
<div className="existing-pr-notice">
|
||||
<AlertCircle size={20} />
|
||||
<div className="notice-content">
|
||||
<strong>{t('existingPRDetected')}</strong>
|
||||
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(existingPR.url)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('viewExistingPR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="confirm-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('selectSource')}:</span>
|
||||
<span className="detail-value">
|
||||
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('version')}:</span>
|
||||
<span className="detail-value">{publishInfo.version}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('category')}:</span>
|
||||
<span className="detail-value">{t(publishInfo.category!)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('repositoryUrl')}:</span>
|
||||
<span className="detail-value">{publishInfo.repositoryUrl}</span>
|
||||
</div>
|
||||
{builtZipPath && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('selectSource')}:</span>
|
||||
<span className="detail-value">
|
||||
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
|
||||
<span className="detail-label">Package Path:</span>
|
||||
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{builtZipPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('version')}:</span>
|
||||
<span className="detail-value">{publishInfo.version}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('category')}:</span>
|
||||
<span className="detail-value">{t(publishInfo.category!)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{t('repositoryUrl')}:</span>
|
||||
<span className="detail-value">{publishInfo.repositoryUrl}</span>
|
||||
</div>
|
||||
{builtZipPath && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Package Path:</span>
|
||||
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{builtZipPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handlePublish}>
|
||||
{t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'publishing' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="publish-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${publishProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
<p className="progress-percent">{publishProgress.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="publish-step success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h3>{t('publishSuccess')}</h3>
|
||||
<p>{t('prCreated')}</p>
|
||||
<p className="review-message">{t('reviewMessage')}</p>
|
||||
|
||||
<button className="btn-link" onClick={openPR}>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewPR')}
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handlePublish}>
|
||||
{t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'publishing' && (
|
||||
<div className="publish-step publishing">
|
||||
<Loader size={48} className="spinning" />
|
||||
<h3>{t('publishing')}</h3>
|
||||
{publishProgress && (
|
||||
<div className="publish-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${publishProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-message">{publishProgress.message}</p>
|
||||
<p className="progress-percent">{publishProgress.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="publish-step success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h3>{t('publishSuccess')}</h3>
|
||||
<p>{t('prCreated')}</p>
|
||||
<p className="review-message">{t('reviewMessage')}</p>
|
||||
|
||||
<button className="btn-link" onClick={openPR}>
|
||||
<ExternalLink size={14} />
|
||||
{t('viewPR')}
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="publish-step error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h3>{t('publishError')}</h3>
|
||||
<p>{error}</p>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'error' && (
|
||||
<div className="publish-step error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h3>{t('publishError')}</h3>
|
||||
<p>{error}</p>
|
||||
|
||||
<div className="button-group">
|
||||
<button className="btn-secondary" onClick={() => setStep('info')}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const animationRef = useRef<number>();
|
||||
const frameTimesRef = useRef<number[]>([]);
|
||||
const lastFpsRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
@@ -298,7 +300,29 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
||||
return result;
|
||||
};
|
||||
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
// Calculate FPS using rolling average for stability
|
||||
// 使用滑动平均计算 FPS 以保持稳定
|
||||
const calculateFps = () => {
|
||||
// Add any positive frame time
|
||||
// 添加任何正数的帧时间
|
||||
if (totalFrameTime > 0) {
|
||||
frameTimesRef.current.push(totalFrameTime);
|
||||
// Keep last 60 samples
|
||||
if (frameTimesRef.current.length > 60) {
|
||||
frameTimesRef.current.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (frameTimesRef.current.length > 0) {
|
||||
const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length;
|
||||
// Cap FPS between 0-999, and ensure avgFrameTime is reasonable
|
||||
if (avgFrameTime > 0.01) {
|
||||
lastFpsRef.current = Math.min(999, Math.round(1000 / avgFrameTime));
|
||||
}
|
||||
}
|
||||
return lastFpsRef.current;
|
||||
};
|
||||
const fps = calculateFps();
|
||||
const targetFrameTime = 16.67;
|
||||
const isOverBudget = totalFrameTime > targetFrameTime;
|
||||
|
||||
|
||||
@@ -1,17 +1,69 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Component, Core } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, PropertyMetadata } from '@esengine/editor-core';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
|
||||
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
|
||||
import '../styles/PropertyInspector.css';
|
||||
|
||||
const animationClipsEditor = new AnimationClipsFieldEditor();
|
||||
|
||||
interface PropertyInspectorProps {
|
||||
component: Component;
|
||||
entity?: any;
|
||||
version?: number;
|
||||
onChange?: (propertyName: string, value: any) => void;
|
||||
onAction?: (actionId: string, propertyName: string, component: Component) => void;
|
||||
}
|
||||
|
||||
export function PropertyInspector({ component, onChange }: PropertyInspectorProps) {
|
||||
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
|
||||
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
|
||||
void version;
|
||||
|
||||
// Scan entity for components that control this component's properties
|
||||
useEffect(() => {
|
||||
if (!entity) return;
|
||||
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const componentName = component.constructor.name;
|
||||
const controlled = new Map<string, string>();
|
||||
|
||||
// Check all components on this entity
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent);
|
||||
const otherComponentName = otherComponent.constructor.name;
|
||||
|
||||
// Check if any property has controls declaration
|
||||
for (const [, propMeta] of Object.entries(otherMetadata)) {
|
||||
if (propMeta.controls) {
|
||||
for (const control of propMeta.controls) {
|
||||
if (control.component === componentName ||
|
||||
control.component === componentName.replace('Component', '')) {
|
||||
controlled.set(control.property, otherComponentName.replace('Component', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setControlledFields(controlled);
|
||||
}, [component, entity, version]);
|
||||
|
||||
const getControlledBy = (propertyName: string): string | undefined => {
|
||||
return controlledFields.get(propertyName);
|
||||
};
|
||||
|
||||
const handleAction = (actionId: string, propertyName: string) => {
|
||||
if (onAction) {
|
||||
onAction(actionId, propertyName, component);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
@@ -19,35 +71,29 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
|
||||
|
||||
const metadata = propertyMetadataService.getEditableProperties(component);
|
||||
setProperties(metadata);
|
||||
|
||||
const componentAsAny = component as any;
|
||||
const currentValues: Record<string, any> = {};
|
||||
for (const key in metadata) {
|
||||
currentValues[key] = componentAsAny[key];
|
||||
}
|
||||
setValues(currentValues);
|
||||
}, [component]);
|
||||
|
||||
const handleChange = (propertyName: string, value: any) => {
|
||||
const componentAsAny = component as any;
|
||||
componentAsAny[propertyName] = value;
|
||||
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[propertyName]: value
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(propertyName, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Read value directly from component to avoid state sync issues
|
||||
const getValue = (propertyName: string) => {
|
||||
return (component as any)[propertyName];
|
||||
};
|
||||
|
||||
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
|
||||
const value = values[propertyName];
|
||||
const value = getValue(propertyName);
|
||||
const label = metadata.label || propertyName;
|
||||
|
||||
switch (metadata.type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return (
|
||||
<NumberField
|
||||
key={propertyName}
|
||||
@@ -55,9 +101,12 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
|
||||
value={value ?? 0}
|
||||
min={metadata.min}
|
||||
max={metadata.max}
|
||||
step={metadata.step}
|
||||
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
|
||||
isInteger={metadata.type === 'integer'}
|
||||
readOnly={metadata.readOnly}
|
||||
actions={metadata.actions}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
onAction={(actionId) => handleAction(actionId, propertyName)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -128,6 +177,39 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
|
||||
/>
|
||||
);
|
||||
|
||||
case 'asset': {
|
||||
const controlledBy = getControlledBy(propertyName);
|
||||
return (
|
||||
<AssetDropField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? ''}
|
||||
fileExtension={metadata.fileExtension}
|
||||
readOnly={metadata.readOnly || !!controlledBy}
|
||||
controlledBy={controlledBy}
|
||||
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'animationClips':
|
||||
return (
|
||||
<div key={propertyName}>
|
||||
{animationClipsEditor.render({
|
||||
label,
|
||||
value: value ?? [],
|
||||
onChange: (newValue) => handleChange(propertyName, newValue),
|
||||
context: {
|
||||
readonly: metadata.readOnly ?? false,
|
||||
metadata: {
|
||||
component,
|
||||
onDefaultAnimationChange: (val: string) => handleChange('defaultAnimation', val)
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -148,16 +230,33 @@ interface NumberFieldProps {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
isInteger?: boolean;
|
||||
readOnly?: boolean;
|
||||
actions?: PropertyAction[];
|
||||
onChange: (value: number) => void;
|
||||
onAction?: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }: NumberFieldProps) {
|
||||
function NumberField({ label, value, min, max, step = 0.1, isInteger = false, readOnly, actions, onChange, onAction }: NumberFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const renderActionButton = (action: PropertyAction) => {
|
||||
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
className="property-action-btn"
|
||||
title={action.tooltip || action.label}
|
||||
onClick={() => onAction?.(action.id)}
|
||||
>
|
||||
{IconComponent ? <IconComponent size={12} /> : action.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setIsDragging(true);
|
||||
@@ -177,7 +276,14 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(parseFloat(newValue.toFixed(3)));
|
||||
// 整数类型取整
|
||||
if (isInteger) {
|
||||
newValue = Math.round(newValue);
|
||||
} else {
|
||||
newValue = parseFloat(newValue.toFixed(3));
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -211,9 +317,17 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value) || 0;
|
||||
onChange(isInteger ? Math.round(val) : val);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="property-actions">
|
||||
{actions.map(renderActionButton)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -271,27 +385,277 @@ interface ColorFieldProps {
|
||||
}
|
||||
|
||||
function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [tempColor, setTempColor] = useState(value);
|
||||
const [pickerPos, setPickerPos] = useState({ top: 0, left: 0 });
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 解析十六进制颜色为 HSV
|
||||
const hexToHsv = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
|
||||
let h = 0;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, v: v * 100 };
|
||||
};
|
||||
|
||||
// HSV 转十六进制
|
||||
const hsvToHex = (h: number, s: number, v: number) => {
|
||||
h = h / 360;
|
||||
s = s / 100;
|
||||
v = v / 100;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
|
||||
switch (i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
case 5: r = v; g = p; b = q; break;
|
||||
}
|
||||
|
||||
const toHex = (n: number) => Math.round(n * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
const hsv = hexToHsv(tempColor);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!showPicker) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowPicker(false);
|
||||
onChange(tempColor);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPicker, tempColor, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempColor(value);
|
||||
}, [value]);
|
||||
|
||||
const handleSaturationValueChange = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
const v = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
|
||||
const newColor = hsvToHex(hsv.h, s, v);
|
||||
setTempColor(newColor);
|
||||
};
|
||||
|
||||
const handleHueChange = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
|
||||
const newColor = hsvToHex(h, hsv.s, hsv.v);
|
||||
setTempColor(newColor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<div className="property-field" style={{ position: 'relative' }}>
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-color-wrapper">
|
||||
<div className="property-color-preview" style={{ backgroundColor: value }} />
|
||||
<input
|
||||
type="color"
|
||||
className="property-input property-input-color"
|
||||
value={value}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="property-color-preview"
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
if (!showPicker && previewRef.current) {
|
||||
const rect = previewRef.current.getBoundingClientRect();
|
||||
const pickerWidth = 200;
|
||||
const pickerHeight = 220;
|
||||
let top = rect.bottom + 4;
|
||||
let left = rect.right - pickerWidth;
|
||||
|
||||
// Ensure picker stays within viewport
|
||||
if (left < 8) left = 8;
|
||||
if (top + pickerHeight > window.innerHeight - 8) {
|
||||
top = rect.top - pickerHeight - 4;
|
||||
}
|
||||
setPickerPos({ top, left });
|
||||
}
|
||||
setShowPicker(!showPicker);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||||
onChange(val);
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="color-picker-popup"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: pickerPos.top,
|
||||
left: pickerPos.left,
|
||||
right: 'auto'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="color-picker-saturation"
|
||||
style={{ backgroundColor: hsvToHex(hsv.h, 100, 100) }}
|
||||
onMouseDown={(e) => {
|
||||
handleSaturationValueChange(e);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const s = Math.max(0, Math.min(100, ((ev.clientX - rect.left) / rect.width) * 100));
|
||||
const v = Math.max(0, Math.min(100, 100 - ((ev.clientY - rect.top) / rect.height) * 100));
|
||||
setTempColor(hsvToHex(hsv.h, s, v));
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className="color-picker-saturation-white" />
|
||||
<div className="color-picker-saturation-black" />
|
||||
<div
|
||||
className="color-picker-cursor"
|
||||
style={{
|
||||
left: `${hsv.s}%`,
|
||||
top: `${100 - hsv.v}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="color-picker-hue"
|
||||
onMouseDown={(e) => {
|
||||
handleHueChange(e);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const h = Math.max(0, Math.min(360, ((ev.clientX - rect.left) / rect.width) * 360));
|
||||
setTempColor(hsvToHex(h, hsv.s, hsv.v));
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="color-picker-hue-cursor"
|
||||
style={{ left: `${(hsv.h / 360) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="color-picker-preview-row">
|
||||
<div className="color-picker-preview-box" style={{ backgroundColor: tempColor }} />
|
||||
<span className="color-picker-hex">{tempColor.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Draggable axis input component
|
||||
interface DraggableAxisInputProps {
|
||||
axis: 'x' | 'y' | 'z';
|
||||
value: number;
|
||||
readOnly?: boolean;
|
||||
compact?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
const sensitivity = e.shiftKey ? 0.01 : 0.1;
|
||||
const newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
onChange(Math.round(newValue * 1000) / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange]);
|
||||
|
||||
const axisClass = `property-vector-axis-${axis}`;
|
||||
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
|
||||
|
||||
return (
|
||||
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
|
||||
<span
|
||||
className={`property-vector-axis-label ${axisClass}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{axis.toUpperCase()}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={value ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -319,57 +683,35 @@ function Vector2Field({ label, value, readOnly, onChange }: Vector2FieldProps) {
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<DraggableAxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
readOnly={readOnly}
|
||||
onChange={(x) => onChange({ ...value, x })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
readOnly={readOnly}
|
||||
onChange={(y) => onChange({ ...value, y })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<DraggableAxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
readOnly={readOnly}
|
||||
compact
|
||||
onChange={(x) => onChange({ ...value, x })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
readOnly={readOnly}
|
||||
compact
|
||||
onChange={(y) => onChange({ ...value, y })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -399,81 +741,48 @@ function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="property-vector-expanded">
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<DraggableAxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
readOnly={readOnly}
|
||||
onChange={(x) => onChange({ ...value, x })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
readOnly={readOnly}
|
||||
onChange={(y) => onChange({ ...value, y })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
readOnly={readOnly}
|
||||
onChange={(z) => onChange({ ...value, z })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.x ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.y ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value?.z ?? 0}
|
||||
disabled={readOnly}
|
||||
step={0.1}
|
||||
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<DraggableAxisInput
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
readOnly={readOnly}
|
||||
compact
|
||||
onChange={(x) => onChange({ ...value, x })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
readOnly={readOnly}
|
||||
compact
|
||||
onChange={(y) => onChange({ ...value, y })}
|
||||
/>
|
||||
<DraggableAxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
readOnly={readOnly}
|
||||
compact
|
||||
onChange={(z) => onChange({ ...value, z })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -489,29 +798,165 @@ interface EnumFieldProps {
|
||||
}
|
||||
|
||||
function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const displayLabel = selectedOption?.label || (options.length === 0 ? 'No options' : '');
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<select
|
||||
className="property-input property-input-select"
|
||||
value={value ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const selectedOption = options.find((opt) => String(opt.value) === e.target.value);
|
||||
if (selectedOption) {
|
||||
onChange(selectedOption.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<option value="">No options</option>
|
||||
<div className="property-dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
className={`property-dropdown-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={() => !readOnly && setIsOpen(!isOpen)}
|
||||
disabled={readOnly}
|
||||
type="button"
|
||||
>
|
||||
<span className="property-dropdown-value">{displayLabel}</span>
|
||||
<span className="property-dropdown-arrow">▾</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="property-dropdown-menu">
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`property-dropdown-item ${option.value === value ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<option key={index} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssetDropFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
fileExtension?: string;
|
||||
readOnly?: boolean;
|
||||
controlledBy?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, onChange }: AssetDropFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!readOnly) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (readOnly) return;
|
||||
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath) {
|
||||
if (fileExtension) {
|
||||
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
|
||||
const fileExt = assetPath.toLowerCase().split('.').pop();
|
||||
if (fileExt && extensions.some((ext) => ext === `.${fileExt}` || ext === fileExt)) {
|
||||
onChange(assetPath);
|
||||
}
|
||||
} else {
|
||||
onChange(assetPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (path: string) => {
|
||||
if (!path) return '';
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readOnly) onChange('');
|
||||
};
|
||||
|
||||
const handleNavigate = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (value) {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path: value });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
{label}
|
||||
{controlledBy && (
|
||||
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
|
||||
<Lock size={10} />
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className={`property-asset-drop ${isDragging ? 'dragging' : ''} ${value ? 'has-value' : ''} ${controlledBy ? 'controlled' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
title={controlledBy ? `Controlled by ${controlledBy}` : (value || 'Drop asset here')}
|
||||
>
|
||||
<span className="property-asset-text">
|
||||
{value ? getFileName(value) : 'None'}
|
||||
</span>
|
||||
<div className="property-asset-actions">
|
||||
{value && (
|
||||
<button
|
||||
className="property-asset-btn"
|
||||
onClick={handleNavigate}
|
||||
title="在资产浏览器中显示"
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
{value && !readOnly && (
|
||||
<button className="property-asset-clear" onClick={handleClear}>×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
89
packages/editor-app/src/components/QRCodeDialog.tsx
Normal file
89
packages/editor-app/src/components/QRCodeDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Copy, Check } from 'lucide-react';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import '../styles/QRCodeDialog.css';
|
||||
|
||||
interface QRCodeDialogProps {
|
||||
url: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QRCodeDialog: React.FC<QRCodeDialogProps> = ({ url, isOpen, onClose }) => {
|
||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
setLoading(true);
|
||||
TauriAPI.generateQRCode(url)
|
||||
.then((base64) => {
|
||||
setQrCodeData(`data:image/png;base64,${base64}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="qrcode-dialog-overlay">
|
||||
<div className="qrcode-dialog">
|
||||
<div className="qrcode-dialog-header">
|
||||
<h3>扫码在移动设备上预览</h3>
|
||||
<button className="qrcode-dialog-close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qrcode-dialog-content">
|
||||
{loading ? (
|
||||
<div className="qrcode-loading">生成二维码中...</div>
|
||||
) : qrCodeData ? (
|
||||
<img src={qrCodeData} alt="QR Code" width={200} height={200} />
|
||||
) : (
|
||||
<div className="qrcode-error">生成失败</div>
|
||||
)}
|
||||
|
||||
<div className="qrcode-url-container">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
readOnly
|
||||
className="qrcode-url-input"
|
||||
/>
|
||||
<button
|
||||
className="qrcode-copy-button"
|
||||
onClick={handleCopy}
|
||||
title={copied ? '已复制' : '复制链接'}
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="qrcode-hint">
|
||||
确保手机和电脑在同一局域网内
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCodeDialog;
|
||||
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe } from 'lucide-react';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } 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, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
type ViewMode = 'local' | 'remote';
|
||||
@@ -201,6 +201,43 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateSpriteEntity = () => {
|
||||
// Count only Sprite entities for naming
|
||||
const spriteCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('Sprite ')).length;
|
||||
const entityName = `Sprite ${spriteCount + 1}`;
|
||||
|
||||
const command = new CreateSpriteEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateAnimatedSpriteEntity = () => {
|
||||
const animCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('AnimatedSprite ')).length;
|
||||
const entityName = `AnimatedSprite ${animCount + 1}`;
|
||||
|
||||
const command = new CreateAnimatedSpriteEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleCreateCameraEntity = () => {
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Camera ${entityCount + 1}`;
|
||||
|
||||
const command = new CreateCameraEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async () => {
|
||||
if (!selectedId) return;
|
||||
|
||||
@@ -431,7 +468,19 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
|
||||
>
|
||||
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
|
||||
<Plus size={12} />
|
||||
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateSpriteEntity(); closeContextMenu(); }}>
|
||||
<Image size={12} />
|
||||
<span>{locale === 'zh' ? '创建 Sprite' : 'Create Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}>
|
||||
<Film size={12} />
|
||||
<span>{locale === 'zh' ? '创建动画 Sprite' : 'Create Animated Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateCameraEntity(); closeContextMenu(); }}>
|
||||
<Camera size={12} />
|
||||
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
|
||||
</button>
|
||||
{contextMenu.entityId && (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import '../styles/StartupPage.css';
|
||||
|
||||
interface StartupPageProps {
|
||||
@@ -12,6 +13,11 @@ interface StartupPageProps {
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
|
||||
}, []);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
@@ -22,7 +28,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
profilerMode: 'Profiler Mode',
|
||||
recentProjects: 'Recent Projects',
|
||||
noRecentProjects: 'No recent projects',
|
||||
version: 'Version 1.0.0',
|
||||
comingSoon: 'Coming Soon'
|
||||
},
|
||||
zh: {
|
||||
@@ -33,12 +38,12 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
profilerMode: '性能分析模式',
|
||||
recentProjects: '最近的项目',
|
||||
noRecentProjects: '没有最近的项目',
|
||||
version: '版本 1.0.0',
|
||||
comingSoon: '即将推出'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`;
|
||||
|
||||
return (
|
||||
<div className="startup-page">
|
||||
@@ -101,7 +106,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
</div>
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{t.version}</span>
|
||||
<span className="startup-version">{versionText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -293,7 +293,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
deleteReason
|
||||
);
|
||||
|
||||
console.log(`[UserDashboard] Delete PR created:`, prUrl);
|
||||
console.log('[UserDashboard] Delete PR created:', prUrl);
|
||||
|
||||
setConfirmDeletePlugin(null);
|
||||
setDeleteReason('');
|
||||
@@ -407,7 +407,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
|
||||
const pluginName = removeMatch[0];
|
||||
|
||||
const plugin = publishedPlugins.find(p => p.name === pluginName);
|
||||
const plugin = publishedPlugins.find((p) => p.name === pluginName);
|
||||
if (!plugin) {
|
||||
alert(t('recreatePRFailed') + ': Plugin not found in published list');
|
||||
return;
|
||||
@@ -443,7 +443,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
true
|
||||
);
|
||||
|
||||
console.log(`[UserDashboard] Recreated delete PR:`, prUrl);
|
||||
console.log('[UserDashboard] Recreated delete PR:', prUrl);
|
||||
alert(t('recreatePRSuccess'));
|
||||
await loadData();
|
||||
|
||||
@@ -482,12 +482,12 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
if (prFilter === 'all') {
|
||||
return pendingReviews;
|
||||
}
|
||||
return pendingReviews.filter(review => review.status === prFilter);
|
||||
return pendingReviews.filter((review) => review.status === prFilter);
|
||||
};
|
||||
|
||||
const handleLinkClick = (href: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
open(href).catch(err => {
|
||||
open(href).catch((err) => {
|
||||
console.error('[UserDashboard] Failed to open link:', err);
|
||||
});
|
||||
};
|
||||
@@ -568,7 +568,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
{publishedPlugins.map((plugin) => {
|
||||
const isExpanded = expandedVersions.has(plugin.id);
|
||||
const hasMultipleVersions = plugin.versions.length > 1;
|
||||
const pendingPR = pendingReviews.find(pr => pr.pluginName === plugin.name && pr.status === 'open');
|
||||
const pendingPR = pendingReviews.find((pr) => pr.pluginName === plugin.name && pr.status === 'open');
|
||||
|
||||
return (
|
||||
<div key={plugin.id} className="plugin-card">
|
||||
@@ -633,55 +633,55 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="plugin-actions">
|
||||
{pendingPR && (
|
||||
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
|
||||
<AlertCircle size={14} />
|
||||
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn-update"
|
||||
onClick={() => handleUpdatePlugin(plugin)}
|
||||
disabled={!!pendingPR}
|
||||
title={pendingPR
|
||||
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
|
||||
: t('updatePlugin')
|
||||
}
|
||||
>
|
||||
<Upload size={14} />
|
||||
{t('updatePlugin')}
|
||||
</button>
|
||||
{plugin.repositoryUrl && (
|
||||
<a
|
||||
href={plugin.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-link"
|
||||
<div className="plugin-actions">
|
||||
{pendingPR && (
|
||||
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
|
||||
<AlertCircle size={14} />
|
||||
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn-update"
|
||||
onClick={() => handleUpdatePlugin(plugin)}
|
||||
disabled={!!pendingPR}
|
||||
title={pendingPR
|
||||
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
|
||||
: t('updatePlugin')
|
||||
}
|
||||
>
|
||||
{t('viewRepo')} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
{plugin.versions[0]?.prUrl && (
|
||||
<a
|
||||
href={plugin.versions[0].prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-link"
|
||||
<Upload size={14} />
|
||||
{t('updatePlugin')}
|
||||
</button>
|
||||
{plugin.repositoryUrl && (
|
||||
<a
|
||||
href={plugin.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-link"
|
||||
>
|
||||
{t('viewRepo')} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
{plugin.versions[0]?.prUrl && (
|
||||
<a
|
||||
href={plugin.versions[0].prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-link"
|
||||
>
|
||||
{t('viewPR')} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={() => setConfirmDeletePlugin(plugin)}
|
||||
title={t('deletePlugin')}
|
||||
>
|
||||
{t('viewPR')} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={() => setConfirmDeletePlugin(plugin)}
|
||||
title={t('deletePlugin')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('deletePlugin')}
|
||||
</button>
|
||||
<Trash2 size={14} />
|
||||
{t('deletePlugin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -729,19 +729,19 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
|
||||
className={`filter-btn ${prFilter === 'open' ? 'active' : ''}`}
|
||||
onClick={() => setPRFilter('open')}
|
||||
>
|
||||
{t('filterOpen')} ({pendingReviews.filter(r => r.status === 'open').length})
|
||||
{t('filterOpen')} ({pendingReviews.filter((r) => r.status === 'open').length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${prFilter === 'merged' ? 'active' : ''}`}
|
||||
onClick={() => setPRFilter('merged')}
|
||||
>
|
||||
{t('filterMerged')} ({pendingReviews.filter(r => r.status === 'merged').length})
|
||||
{t('filterMerged')} ({pendingReviews.filter((r) => r.status === 'merged').length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${prFilter === 'closed' ? 'active' : ''}`}
|
||||
onClick={() => setPRFilter('closed')}
|
||||
>
|
||||
{t('filterClosed')} ({pendingReviews.filter(r => r.status === 'closed').length})
|
||||
{t('filterClosed')} ({pendingReviews.filter((r) => r.status === 'closed').length})
|
||||
</button>
|
||||
</div>
|
||||
<div className="review-list">
|
||||
|
||||
@@ -52,7 +52,6 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
|
||||
useEffect(() => {
|
||||
// 监听加载状态变化
|
||||
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
|
||||
console.log('[UserProfile] User load state changed:', isLoading);
|
||||
setIsLoadingUser(isLoading);
|
||||
});
|
||||
|
||||
@@ -65,10 +64,8 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
|
||||
const currentUser = githubService.getUser();
|
||||
setUser((prevUser) => {
|
||||
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
|
||||
console.log('[UserProfile] User state changed:', currentUser.login);
|
||||
return currentUser;
|
||||
} else if (!currentUser && prevUser) {
|
||||
console.log('[UserProfile] User logged out');
|
||||
return null;
|
||||
}
|
||||
return prevUser;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
199
packages/editor-app/src/components/dialogs/AssetPickerDialog.css
Normal file
199
packages/editor-app/src/components/dialogs/AssetPickerDialog.css
Normal file
@@ -0,0 +1,199 @@
|
||||
.asset-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.asset-picker-dialog {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asset-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.asset-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.asset-picker-close:hover {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.asset-picker-search svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-picker-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-picker-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.asset-picker-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.asset-picker-loading,
|
||||
.asset-picker-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-picker-tree {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.asset-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-picker-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected {
|
||||
background: #0d47a1;
|
||||
}
|
||||
|
||||
.asset-picker-item__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected .asset-picker-item__icon {
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
.asset-picker-item__name {
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #333;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.asset-picker-selected {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-picker-selected .placeholder {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.asset-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.asset-picker-actions button {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
282
packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx
Normal file
282
packages/editor-app/src/components/dialogs/AssetPickerDialog.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ProjectService } from '@esengine/editor-core';
|
||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||
import './AssetPickerDialog.css';
|
||||
|
||||
interface AssetPickerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
title?: string;
|
||||
fileExtensions?: string[]; // e.g., ['.png', '.jpg']
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
export function AssetPickerDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
title = 'Select Asset',
|
||||
fileExtensions = [],
|
||||
placeholder = 'Search assets...'
|
||||
}: AssetPickerDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load project assets
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const loadAssets = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
|
||||
const currentProject = projectService?.getCurrentProject();
|
||||
if (projectService && currentProject) {
|
||||
const projectPath = currentProject.path;
|
||||
const assetsPath = `${projectPath}/assets`;
|
||||
|
||||
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
|
||||
const entries = await fileSystem.listDirectory(dirPath);
|
||||
const nodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const node: FileNode = {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory
|
||||
};
|
||||
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
node.children = await buildTree(entry.path);
|
||||
} catch {
|
||||
node.children = [];
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// Sort: folders first, then files, alphabetically
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
const tree = await buildTree(assetsPath);
|
||||
setAssets(tree);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAssets();
|
||||
setSelectedPath(null);
|
||||
setSearchTerm('');
|
||||
}, [isOpen]);
|
||||
|
||||
// Filter assets based on search and file extensions
|
||||
const filteredAssets = useMemo(() => {
|
||||
const filterNode = (node: FileNode): FileNode | null => {
|
||||
// Check file extension filter
|
||||
if (!node.isDirectory && fileExtensions.length > 0) {
|
||||
const hasValidExtension = fileExtensions.some((ext) =>
|
||||
node.name.toLowerCase().endsWith(ext.toLowerCase())
|
||||
);
|
||||
if (!hasValidExtension) return null;
|
||||
}
|
||||
|
||||
// Check search term
|
||||
const matchesSearch = !searchTerm ||
|
||||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (node.isDirectory && node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
|
||||
if (filteredChildren.length > 0 || matchesSearch) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return matchesSearch ? node : null;
|
||||
};
|
||||
|
||||
return assets
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
}, [assets, searchTerm, fileExtensions]);
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((node: FileNode) => {
|
||||
if (node.isDirectory) {
|
||||
toggleFolder(node.path);
|
||||
} else {
|
||||
setSelectedPath(node.path);
|
||||
}
|
||||
}, [toggleFolder]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedPath) {
|
||||
onSelect(selectedPath);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedPath, onSelect, onClose]);
|
||||
|
||||
const handleDoubleClick = useCallback((node: FileNode) => {
|
||||
if (!node.isDirectory) {
|
||||
onSelect(node.path);
|
||||
onClose();
|
||||
}
|
||||
}, [onSelect, onClose]);
|
||||
|
||||
const getFileIcon = (name: string) => {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return <Image size={14} />;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'ogg':
|
||||
return <Music size={14} />;
|
||||
case 'mp4':
|
||||
case 'webm':
|
||||
return <Video size={14} />;
|
||||
case 'json':
|
||||
case 'txt':
|
||||
case 'md':
|
||||
return <FileText size={14} />;
|
||||
default:
|
||||
return <File size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (node: FileNode, depth: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = selectedPath === node.path;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => handleSelect(node)}
|
||||
onDoubleClick={() => handleDoubleClick(node)}
|
||||
>
|
||||
<span className="asset-picker-item__icon">
|
||||
{node.isDirectory ? (
|
||||
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
|
||||
) : (
|
||||
getFileIcon(node.name)
|
||||
)}
|
||||
</span>
|
||||
<span className="asset-picker-item__name">{node.name}</span>
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div className="asset-picker-children">
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="asset-picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="asset-picker-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="asset-picker-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-content">
|
||||
{loading ? (
|
||||
<div className="asset-picker-loading">Loading assets...</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-picker-empty">No assets found</div>
|
||||
) : (
|
||||
<div className="asset-picker-tree">
|
||||
{filteredAssets.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-footer">
|
||||
<div className="asset-picker-selected">
|
||||
{selectedPath ? (
|
||||
<span title={selectedPath}>
|
||||
{selectedPath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="placeholder">No asset selected</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-picker-actions">
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedPath}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
@@ -140,6 +141,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemPro
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
|
||||
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
|
||||
@@ -51,4 +51,4 @@ export function PropertyField({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
|
||||
import './AssetField.css';
|
||||
|
||||
interface AssetFieldProps {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
fileExtension?: string; // 例如: '.btree'
|
||||
@@ -24,6 +24,7 @@ export function AssetField({
|
||||
}: AssetFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
@@ -54,7 +55,7 @@ export function AssetField({
|
||||
|
||||
// 处理从文件系统拖入的文件
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find(f =>
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
@@ -78,25 +79,15 @@ export function AssetField({
|
||||
}
|
||||
}, [onChange, fileExtension, readonly]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const handleBrowse = useCallback(() => {
|
||||
if (readonly) return;
|
||||
setShowPicker(true);
|
||||
}, [readonly]);
|
||||
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: fileExtension ? [{
|
||||
name: `${fileExtension} Files`,
|
||||
extensions: [fileExtension.replace('.', '')]
|
||||
}] : []
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
onChange(selected as string);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open file dialog:', error);
|
||||
}
|
||||
}, [onChange, fileExtension, readonly]);
|
||||
const handlePickerSelect = useCallback((path: string) => {
|
||||
onChange(path);
|
||||
setShowPicker(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!readonly) {
|
||||
@@ -111,7 +102,7 @@ export function AssetField({
|
||||
|
||||
return (
|
||||
<div className="asset-field">
|
||||
<label className="asset-field__label">{label}</label>
|
||||
{label && <label className="asset-field__label">{label}</label>}
|
||||
<div
|
||||
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -160,17 +151,21 @@ export function AssetField({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
{value && onNavigate && (
|
||||
{/* 导航/定位按钮 */}
|
||||
{onNavigate && (
|
||||
<button
|
||||
className="asset-field__button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(value);
|
||||
if (value) {
|
||||
onNavigate(value);
|
||||
} else {
|
||||
handleBrowse();
|
||||
}
|
||||
}}
|
||||
title="在资产浏览器中显示"
|
||||
title={value ? '在资产浏览器中显示' : '选择资产'}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -189,6 +184,14 @@ export function AssetField({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerDialog
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handlePickerSelect}
|
||||
title="Select Asset"
|
||||
fileExtensions={fileExtension ? [fileExtension] : []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
{fileInfo.isDirectory
|
||||
? '文件夹'
|
||||
: fileInfo.extension
|
||||
? `.${fileInfo.extension}`
|
||||
: '文件'}
|
||||
? `.${fileInfo.extension}`
|
||||
: '文件'}
|
||||
</span>
|
||||
</div>
|
||||
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
|
||||
import { Entity, Component, Core } from '@esengine/ecs-framework';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { NotificationService } from '../../../services/NotificationService';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface EntityInspectorProps {
|
||||
entity: Entity;
|
||||
@@ -16,6 +18,7 @@ interface EntityInspectorProps {
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const availableComponents = componentRegistry?.getAllComponents() || [];
|
||||
@@ -40,14 +43,42 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
const component = entity.components[index];
|
||||
if (component) {
|
||||
const command = new RemoveComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component
|
||||
);
|
||||
commandManager.execute(command);
|
||||
if (!component) return;
|
||||
|
||||
const componentName = getComponentTypeName(component.constructor as any);
|
||||
console.log('Removing component:', componentName);
|
||||
|
||||
// Check if any other component depends on this one
|
||||
const dependentComponents: string[] = [];
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const dependencies = getComponentDependencies(otherComponent.constructor as any);
|
||||
const otherName = getComponentTypeName(otherComponent.constructor as any);
|
||||
console.log('Checking', otherName, 'dependencies:', dependencies);
|
||||
if (dependencies && dependencies.includes(componentName)) {
|
||||
dependentComponents.push(otherName);
|
||||
}
|
||||
}
|
||||
console.log('Dependent components:', dependentComponents);
|
||||
|
||||
if (dependentComponents.length > 0) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.warning(
|
||||
'无法删除组件',
|
||||
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new RemoveComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
|
||||
@@ -61,6 +92,34 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handlePropertyAction = async (actionId: string, _propertyName: string, component: Component) => {
|
||||
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
|
||||
const sprite = component as unknown as { texture: string; width: number; height: number };
|
||||
if (!sprite.texture) {
|
||||
console.warn('No texture set for sprite');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||
const assetUrl = convertFileSrc(sprite.texture);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
handlePropertyChange(component, 'width', img.naturalWidth);
|
||||
handlePropertyChange(component, 'height', img.naturalHeight);
|
||||
setLocalVersion((v) => v + 1);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load texture for native size:', sprite.texture);
|
||||
};
|
||||
img.src = assetUrl;
|
||||
} catch (error) {
|
||||
console.error('Error getting texture size:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
@@ -82,149 +141,123 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="section-title section-title-with-action">
|
||||
<span>组件</span>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="component-menu-container">
|
||||
<button
|
||||
className="add-component-trigger"
|
||||
onClick={() => setShowComponentMenu(!showComponentMenu)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #4a4a4a',
|
||||
borderRadius: '4px',
|
||||
color: '#e0e0e0',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加
|
||||
</button>
|
||||
{showComponentMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #4a4a4a',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{availableComponents.length === 0 ? (
|
||||
<div style={{ padding: '8px 12px', color: '#888', fontSize: '11px' }}>
|
||||
没有可用组件
|
||||
</div>
|
||||
) : (
|
||||
availableComponents.map((info) => (
|
||||
<button
|
||||
key={info.name}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#3a3a3a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
>
|
||||
{info.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
|
||||
<div className="component-dropdown">
|
||||
<div className="component-dropdown-header">选择组件</div>
|
||||
{availableComponents.length === 0 ? (
|
||||
<div className="component-dropdown-empty">
|
||||
没有可用组件
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{/* 按分类分组显示 */}
|
||||
{(() => {
|
||||
const categories = new Map<string, typeof availableComponents>();
|
||||
availableComponents.forEach((info) => {
|
||||
const cat = info.category || 'components.category.other';
|
||||
if (!categories.has(cat)) {
|
||||
categories.set(cat, []);
|
||||
}
|
||||
categories.get(cat)!.push(info);
|
||||
});
|
||||
|
||||
return Array.from(categories.entries()).map(([category, components]) => (
|
||||
<div key={category} className="component-category-group">
|
||||
<div className="component-category-label">{category}</div>
|
||||
{components.map((info) => (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{entity.components.map((component: Component, index: number) => {
|
||||
{entity.components.length === 0 ? (
|
||||
<div className="empty-state-small">暂无组件</div>
|
||||
) : (
|
||||
entity.components.map((component: Component, index: number) => {
|
||||
const isExpanded = expandedComponents.has(index);
|
||||
const componentName = component.constructor?.name || 'Component';
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${componentName}-${index}-${componentVersion}`}
|
||||
style={{
|
||||
marginBottom: '2px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
key={`${componentName}-${index}`}
|
||||
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#3a3a3a',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#e0e0e0',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
{IconComponent ? (
|
||||
<span className="component-icon">
|
||||
<IconComponent size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="component-icon">
|
||||
<Box size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="component-item-name">
|
||||
{componentName}
|
||||
</span>
|
||||
<button
|
||||
className="component-remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(index);
|
||||
}}
|
||||
title="移除组件"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
borderRadius: '3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = '#dc2626')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '6px 8px' }}>
|
||||
<div className="component-item-content">
|
||||
<PropertyInspector
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value)
|
||||
}
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,33 +242,33 @@ export function RemoteEntityInspector({
|
||||
details.components &&
|
||||
Array.isArray(details.components) &&
|
||||
details.components.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件 ({details.components.length})</div>
|
||||
{details.components.map((comp, index) => {
|
||||
const registry = Core.services.resolve(PropertyRendererRegistry);
|
||||
const context: PropertyContext = {
|
||||
name: comp.typeName || `Component ${index}`,
|
||||
decimalPlaces,
|
||||
readonly: true,
|
||||
expandByDefault: true,
|
||||
depth: 0
|
||||
};
|
||||
const rendered = registry.render(comp, context);
|
||||
return rendered ? <div key={index}>{rendered}</div> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件 ({details.components.length})</div>
|
||||
{details.components.map((comp, index) => {
|
||||
const registry = Core.services.resolve(PropertyRendererRegistry);
|
||||
const context: PropertyContext = {
|
||||
name: comp.typeName || `Component ${index}`,
|
||||
decimalPlaces,
|
||||
readonly: true,
|
||||
expandByDefault: true,
|
||||
depth: 0
|
||||
};
|
||||
const rendered = registry.render(comp, context);
|
||||
return rendered ? <div key={index}>{rendered}</div> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details &&
|
||||
Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">其他信息</div>
|
||||
{Object.entries(details)
|
||||
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.map(([key, value]) => renderRemoteProperty(key, value))}
|
||||
</div>
|
||||
)}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">其他信息</div>
|
||||
{Object.entries(details)
|
||||
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.map(([key, value]) => renderRemoteProperty(key, value))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export class PanelRegistry implements IPanelRegistry {
|
||||
}
|
||||
|
||||
return allPanels
|
||||
.filter(panel => panel.category === category)
|
||||
.filter((panel) => panel.category === category)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ export class TypedEventBus<TEvents = Record<string, unknown>> implements IEventB
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
this.subjects.forEach(subject => subject.complete());
|
||||
this.subjects.forEach((subject) => subject.complete());
|
||||
this.subjects.clear();
|
||||
}
|
||||
|
||||
|
||||
207
packages/editor-app/src/hooks/useAssetSystem.ts
Normal file
207
packages/editor-app/src/hooks/useAssetSystem.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Asset system integration hook
|
||||
* 资产系统集成Hook
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AssetManager,
|
||||
AssetGUID,
|
||||
IAssetLoadProgress,
|
||||
AssetReference,
|
||||
EngineIntegration
|
||||
} from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* Asset system hook
|
||||
* 资产系统Hook
|
||||
*/
|
||||
export function useAssetSystem() {
|
||||
const [assetManager, setAssetManager] = useState<AssetManager | null>(null);
|
||||
const [engineIntegration, setEngineIntegration] = useState<EngineIntegration | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadProgress, setLoadProgress] = useState<IAssetLoadProgress | null>(null);
|
||||
const loadingCountRef = useRef(0);
|
||||
|
||||
/**
|
||||
* Initialize asset system
|
||||
* 初始化资产系统
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 创建资产管理器 / Create asset manager
|
||||
const manager = new AssetManager();
|
||||
|
||||
setAssetManager(manager);
|
||||
|
||||
// 创建引擎集成 / Create engine integration
|
||||
const integration = new EngineIntegration(manager);
|
||||
setEngineIntegration(integration);
|
||||
|
||||
return () => {
|
||||
if (assetManager) {
|
||||
assetManager.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load asset by path
|
||||
* 通过路径加载资产
|
||||
*/
|
||||
const loadAssetByPath = useCallback(async <T = unknown>(path: string): Promise<T | null> => {
|
||||
if (!assetManager) return null;
|
||||
|
||||
try {
|
||||
loadingCountRef.current++;
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await assetManager.loadAssetByPath<T>(path, {
|
||||
onProgress: (progress) => {
|
||||
setLoadProgress({
|
||||
currentAsset: path,
|
||||
loadedCount: Math.floor(progress * 100),
|
||||
totalCount: 100,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progress
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result.asset;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load asset at ${path}:`, error);
|
||||
return null;
|
||||
} finally {
|
||||
loadingCountRef.current--;
|
||||
if (loadingCountRef.current === 0) {
|
||||
setIsLoading(false);
|
||||
setLoadProgress(null);
|
||||
}
|
||||
}
|
||||
}, [assetManager]);
|
||||
|
||||
/**
|
||||
* Load texture for sprite component
|
||||
* 为精灵组件加载纹理
|
||||
*/
|
||||
const loadTextureForSprite = useCallback(async (path: string): Promise<number> => {
|
||||
if (!engineIntegration) return 0;
|
||||
|
||||
try {
|
||||
return await engineIntegration.loadTextureForComponent(path);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load texture ${path}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}, [engineIntegration]);
|
||||
|
||||
/**
|
||||
* Create asset reference
|
||||
* 创建资产引用
|
||||
*/
|
||||
const createAssetReference = useCallback((guid: AssetGUID): AssetReference | null => {
|
||||
if (!assetManager) return null;
|
||||
return new AssetReference(guid, assetManager);
|
||||
}, [assetManager]);
|
||||
|
||||
/**
|
||||
* Unload unused assets
|
||||
* 卸载未使用的资产
|
||||
*/
|
||||
const unloadUnusedAssets = useCallback(() => {
|
||||
if (!assetManager) return;
|
||||
assetManager.unloadUnusedAssets();
|
||||
}, [assetManager]);
|
||||
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* 获取统计信息
|
||||
*/
|
||||
const getStatistics = useCallback(() => {
|
||||
if (!assetManager) {
|
||||
return { loadedCount: 0, loadQueue: 0, failedCount: 0 };
|
||||
}
|
||||
return assetManager.getStatistics();
|
||||
}, [assetManager]);
|
||||
|
||||
return {
|
||||
assetManager,
|
||||
engineIntegration,
|
||||
isLoading,
|
||||
loadProgress,
|
||||
loadAssetByPath,
|
||||
loadTextureForSprite,
|
||||
createAssetReference,
|
||||
unloadUnusedAssets,
|
||||
getStatistics
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reference hook
|
||||
* 资产引用Hook
|
||||
*/
|
||||
export function useAssetReference<T = unknown>(
|
||||
reference: AssetReference<T> | null,
|
||||
autoLoad = false
|
||||
) {
|
||||
const [asset, setAsset] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// 自动加载 / Auto load
|
||||
useEffect(() => {
|
||||
if (autoLoad && reference) {
|
||||
loadAsset();
|
||||
}
|
||||
}, [reference, autoLoad]);
|
||||
|
||||
/**
|
||||
* Load asset
|
||||
* 加载资产
|
||||
*/
|
||||
const loadAsset = useCallback(async () => {
|
||||
if (!reference) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const loadedAsset = await reference.loadAsync();
|
||||
setAsset(loadedAsset);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to load asset reference:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [reference]);
|
||||
|
||||
/**
|
||||
* Release asset
|
||||
* 释放资产
|
||||
*/
|
||||
const release = useCallback(() => {
|
||||
if (!reference) return;
|
||||
reference.release();
|
||||
setAsset(null);
|
||||
}, [reference]);
|
||||
|
||||
// 清理 / Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reference && reference.isLoaded) {
|
||||
reference.release();
|
||||
}
|
||||
};
|
||||
}, [reference]);
|
||||
|
||||
return {
|
||||
asset,
|
||||
isLoading,
|
||||
error,
|
||||
load: loadAsset,
|
||||
release
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,17 @@
|
||||
* 使用Rust游戏引擎的React钩子。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
|
||||
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { EditorEngineSync } from '../services/EditorEngineSync';
|
||||
|
||||
// Module-level initialization tracking (outside React lifecycle)
|
||||
// 模块级别的初始化追踪(在React生命周期外部)
|
||||
let engineInitialized = false;
|
||||
let engineInitializing = false;
|
||||
|
||||
export interface EngineState {
|
||||
initialized: boolean;
|
||||
@@ -27,6 +36,66 @@ export interface UseEngineReturn {
|
||||
height?: number;
|
||||
}) => void;
|
||||
loadTexture: (id: number, url: string) => void;
|
||||
viewportId: string;
|
||||
}
|
||||
|
||||
export interface UseEngineOptions {
|
||||
viewportId: string;
|
||||
canvasId: string;
|
||||
showGrid?: boolean;
|
||||
showGizmos?: boolean;
|
||||
autoInit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize engine once at module level
|
||||
* 在模块级别初始化引擎一次
|
||||
*/
|
||||
async function initializeEngine(canvasId: string): Promise<void> {
|
||||
if (engineInitialized || engineInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
engineInitializing = true;
|
||||
|
||||
try {
|
||||
const engine = EngineService.getInstance();
|
||||
await engine.initialize(canvasId);
|
||||
|
||||
// Initialize sync service
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
if (messageHub && entityStore) {
|
||||
EditorEngineSync.getInstance().initialize(messageHub, entityStore);
|
||||
|
||||
// Create default camera if none exists
|
||||
// 如果不存在相机则创建默认相机
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
|
||||
if (existingCameras.length === 0) {
|
||||
const cameraEntity = scene.createEntity('Main Camera');
|
||||
cameraEntity.addComponent(new TransformComponent());
|
||||
const camera = new CameraComponent();
|
||||
camera.orthographicSize = 1;
|
||||
cameraEntity.addComponent(camera);
|
||||
|
||||
// Register with EntityStore so it appears in hierarchy
|
||||
// 注册到 EntityStore 以便在层级视图中显示
|
||||
entityStore.addEntity(cameraEntity);
|
||||
messageHub.publish('entity:added', { entity: cameraEntity });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.warn('Failed to initialize sync service | 同步服务初始化失败:', syncError);
|
||||
}
|
||||
|
||||
engineInitialized = true;
|
||||
} finally {
|
||||
engineInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,12 +105,34 @@ export interface UseEngineReturn {
|
||||
* @param canvasId - Canvas element ID | Canvas元素ID
|
||||
* @param autoInit - Whether to auto-initialize | 是否自动初始化
|
||||
*/
|
||||
export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
|
||||
export function useEngine(canvasId: string, autoInit?: boolean): UseEngineReturn;
|
||||
export function useEngine(options: UseEngineOptions): UseEngineReturn;
|
||||
export function useEngine(
|
||||
canvasIdOrOptions: string | UseEngineOptions,
|
||||
autoInit?: boolean
|
||||
): UseEngineReturn {
|
||||
// Parse options
|
||||
const options: UseEngineOptions = typeof canvasIdOrOptions === 'string'
|
||||
? {
|
||||
viewportId: canvasIdOrOptions, // Use canvasId as viewportId for backward compatibility
|
||||
canvasId: canvasIdOrOptions,
|
||||
showGrid: true,
|
||||
showGizmos: true,
|
||||
autoInit
|
||||
}
|
||||
: {
|
||||
showGrid: true,
|
||||
showGizmos: true,
|
||||
autoInit: true,
|
||||
...canvasIdOrOptions
|
||||
};
|
||||
|
||||
const engineRef = useRef<EngineService>(EngineService.getInstance());
|
||||
const statsIntervalRef = useRef<number | null>(null);
|
||||
const viewportRegisteredRef = useRef(false);
|
||||
|
||||
const [state, setState] = useState<EngineState>({
|
||||
initialized: false,
|
||||
initialized: engineInitialized,
|
||||
running: false,
|
||||
fps: 0,
|
||||
drawCalls: 0,
|
||||
@@ -49,28 +140,42 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
|
||||
error: null
|
||||
});
|
||||
|
||||
// Initialize engine | 初始化引擎
|
||||
// Initialize engine and register viewport
|
||||
useEffect(() => {
|
||||
if (!autoInit) return;
|
||||
if (!options.autoInit) return;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await engineRef.current.initialize(canvasId);
|
||||
setState(prev => ({ ...prev, initialized: true, error: null }));
|
||||
// Initialize engine with primary canvas (first viewport)
|
||||
await initializeEngine(options.canvasId);
|
||||
setState((prev) => ({ ...prev, initialized: true, error: null }));
|
||||
|
||||
// Start stats update interval | 启动统计更新间隔
|
||||
statsIntervalRef.current = window.setInterval(() => {
|
||||
const stats = engineRef.current.getStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
fps: stats.fps,
|
||||
drawCalls: stats.drawCalls,
|
||||
spriteCount: stats.spriteCount
|
||||
}));
|
||||
}, 100);
|
||||
// Register this viewport
|
||||
if (!viewportRegisteredRef.current) {
|
||||
engineRef.current.registerViewport(options.viewportId, options.canvasId);
|
||||
engineRef.current.setViewportConfig(
|
||||
options.viewportId,
|
||||
options.showGrid ?? true,
|
||||
options.showGizmos ?? true
|
||||
);
|
||||
viewportRegisteredRef.current = true;
|
||||
}
|
||||
|
||||
// Start stats update interval
|
||||
if (!statsIntervalRef.current) {
|
||||
statsIntervalRef.current = window.setInterval(() => {
|
||||
const stats = engineRef.current.getStats();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fps: stats.fps,
|
||||
drawCalls: stats.drawCalls,
|
||||
spriteCount: stats.spriteCount
|
||||
}));
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize engine | 引擎初始化失败:', error);
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
@@ -82,24 +187,29 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
|
||||
return () => {
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
}
|
||||
// Unregister viewport on cleanup
|
||||
if (viewportRegisteredRef.current) {
|
||||
engineRef.current.unregisterViewport(options.viewportId);
|
||||
viewportRegisteredRef.current = false;
|
||||
}
|
||||
engineRef.current.dispose();
|
||||
};
|
||||
}, [canvasId, autoInit]);
|
||||
}, [options.canvasId, options.viewportId, options.autoInit, options.showGrid, options.showGizmos]);
|
||||
|
||||
// Start engine | 启动引擎
|
||||
// Start engine
|
||||
const start = useCallback(() => {
|
||||
engineRef.current.start();
|
||||
setState(prev => ({ ...prev, running: true }));
|
||||
setState((prev) => ({ ...prev, running: true }));
|
||||
}, []);
|
||||
|
||||
// Stop engine | 停止引擎
|
||||
// Stop engine
|
||||
const stop = useCallback(() => {
|
||||
engineRef.current.stop();
|
||||
setState(prev => ({ ...prev, running: false }));
|
||||
setState((prev) => ({ ...prev, running: false }));
|
||||
}, []);
|
||||
|
||||
// Create sprite entity | 创建精灵实体
|
||||
// Create sprite entity
|
||||
const createSprite = useCallback((name: string, options?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
@@ -110,7 +220,7 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
|
||||
engineRef.current.createSpriteEntity(name, options);
|
||||
}, []);
|
||||
|
||||
// Load texture | 加载纹理
|
||||
// Load texture
|
||||
const loadTexture = useCallback((id: number, url: string) => {
|
||||
engineRef.current.loadTexture(id, url);
|
||||
}, []);
|
||||
@@ -120,7 +230,8 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
|
||||
start,
|
||||
stop,
|
||||
createSprite,
|
||||
loadTexture
|
||||
loadTexture,
|
||||
viewportId: options.viewportId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,5 +44,37 @@
|
||||
"executionResumed": "Execution resumed",
|
||||
"resetToInitial": "Reset to initial state",
|
||||
"currentValue": "Current Value"
|
||||
},
|
||||
"components": {
|
||||
"category": {
|
||||
"core": "Core",
|
||||
"rendering": "Rendering",
|
||||
"physics": "Physics",
|
||||
"audio": "Audio"
|
||||
},
|
||||
"transform": {
|
||||
"description": "Transform - Position, Rotation, Scale"
|
||||
},
|
||||
"sprite": {
|
||||
"description": "Sprite - 2D Image Rendering"
|
||||
},
|
||||
"text": {
|
||||
"description": "Text - Text Rendering"
|
||||
},
|
||||
"camera": {
|
||||
"description": "Camera - View Control"
|
||||
},
|
||||
"rigidBody": {
|
||||
"description": "RigidBody - Physics Simulation"
|
||||
},
|
||||
"boxCollider": {
|
||||
"description": "Box Collider"
|
||||
},
|
||||
"circleCollider": {
|
||||
"description": "Circle Collider"
|
||||
},
|
||||
"audioSource": {
|
||||
"description": "Audio Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,37 @@
|
||||
"executionResumed": "执行已恢复",
|
||||
"resetToInitial": "重置到初始状态",
|
||||
"currentValue": "当前值"
|
||||
},
|
||||
"components": {
|
||||
"category": {
|
||||
"core": "基础",
|
||||
"rendering": "渲染",
|
||||
"physics": "物理",
|
||||
"audio": "音频"
|
||||
},
|
||||
"transform": {
|
||||
"description": "变换组件 - 位置、旋转、缩放"
|
||||
},
|
||||
"sprite": {
|
||||
"description": "精灵组件 - 2D图像渲染"
|
||||
},
|
||||
"text": {
|
||||
"description": "文本组件 - 文本渲染"
|
||||
},
|
||||
"camera": {
|
||||
"description": "相机组件 - 视图控制"
|
||||
},
|
||||
"rigidBody": {
|
||||
"description": "刚体组件 - 物理模拟"
|
||||
},
|
||||
"boxCollider": {
|
||||
"description": "盒型碰撞器"
|
||||
},
|
||||
"circleCollider": {
|
||||
"description": "圆形碰撞器"
|
||||
},
|
||||
"audioSource": {
|
||||
"description": "音频源组件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
|
||||
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
import { EngineService } from '../../services/EngineService';
|
||||
|
||||
interface DraggableNumberProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function DraggableNumber({ value, min = 0, max = 10, step = 0.1, onChange, disabled, label }: DraggableNumberProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.1 : 1;
|
||||
let newValue = dragStartValue + delta * step * sensitivity;
|
||||
|
||||
newValue = Math.max(min, Math.min(max, newValue));
|
||||
newValue = parseFloat(newValue.toFixed(2));
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<label className="clip-draggable-number">
|
||||
<span
|
||||
className="clip-draggable-label"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: disabled ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export class AnimationClipsFieldEditor implements IFieldEditor<AnimationClip[]> {
|
||||
readonly type = 'animationClips';
|
||||
readonly name = 'Animation Clips Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'animationClips';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<AnimationClip[]>): React.ReactElement {
|
||||
return (
|
||||
<AnimationClipsEditor
|
||||
label={label}
|
||||
clips={value || []}
|
||||
onChange={onChange}
|
||||
readonly={context.readonly}
|
||||
component={context.metadata?.component as SpriteAnimatorComponent}
|
||||
onDefaultAnimationChange={context.metadata?.onDefaultAnimationChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AnimationClipsEditorProps {
|
||||
label: string;
|
||||
clips: AnimationClip[];
|
||||
onChange: (clips: AnimationClip[]) => void;
|
||||
readonly?: boolean;
|
||||
component?: SpriteAnimatorComponent;
|
||||
onDefaultAnimationChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function AnimationClipsEditor({ label, clips, onChange, readonly, component, onDefaultAnimationChange }: AnimationClipsEditorProps) {
|
||||
const [expandedClips, setExpandedClips] = useState<Set<number>>(new Set());
|
||||
const [playingClip, setPlayingClip] = useState<string | null>(null);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleClip = (index: number) => {
|
||||
const newExpanded = new Set(expandedClips);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedClips(newExpanded);
|
||||
};
|
||||
|
||||
const addClip = () => {
|
||||
const newName = `Animation ${clips.length + 1}`;
|
||||
const newClip: AnimationClip = {
|
||||
name: newName,
|
||||
frames: [],
|
||||
loop: true,
|
||||
speed: 1
|
||||
};
|
||||
onChange([...clips, newClip]);
|
||||
setExpandedClips(new Set([...expandedClips, clips.length]));
|
||||
|
||||
// Auto-set first clip as default animation
|
||||
if (clips.length === 0 && component && !component.defaultAnimation) {
|
||||
component.defaultAnimation = newName;
|
||||
setDefaultAnimation(newName);
|
||||
if (onDefaultAnimationChange) {
|
||||
onDefaultAnimationChange(newName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeClip = (index: number) => {
|
||||
const newClips = clips.filter((_, i) => i !== index);
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const updateClip = (index: number, updates: Partial<AnimationClip>) => {
|
||||
const newClips = [...clips];
|
||||
const existingClip = newClips[index];
|
||||
if (!existingClip) return;
|
||||
newClips[index] = { ...existingClip, ...updates } as AnimationClip;
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const addFrame = (clipIndex: number) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = [...clip.frames, { texture: '', duration: 0.1 }];
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const removeFrame = (clipIndex: number, frameIndex: number) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = clip.frames.filter((_, i) => i !== frameIndex);
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const updateFrame = (clipIndex: number, frameIndex: number, updates: Partial<AnimationFrame>) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = [...clip.frames];
|
||||
const existingFrame = clip.frames[frameIndex];
|
||||
if (!existingFrame) return;
|
||||
clip.frames[frameIndex] = { ...existingFrame, ...updates } as AnimationFrame;
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const addFramesBatch = (clipIndex: number, texturePaths: string[]) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
const newFrames = texturePaths.map((texture) => ({
|
||||
texture,
|
||||
duration: 0.1
|
||||
}));
|
||||
clip.frames = [...clip.frames, ...newFrames];
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const handleFramesDrop = useCallback((clipIndex: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const data = e.dataTransfer.getData('application/json');
|
||||
if (data) {
|
||||
try {
|
||||
const items = JSON.parse(data);
|
||||
if (Array.isArray(items)) {
|
||||
const textures = items
|
||||
.filter((item: { type: string; path: string }) =>
|
||||
item.type === 'file' && /\.(png|jpg|jpeg|webp|gif)$/i.test(item.path))
|
||||
.map((item: { path: string }) => item.path)
|
||||
.sort();
|
||||
if (textures.length > 0) {
|
||||
addFramesBatch(clipIndex, textures);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try text data for single file
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && /\.(png|jpg|jpeg|webp|gif)$/i.test(text)) {
|
||||
addFramesBatch(clipIndex, [text]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [clips, onChange]);
|
||||
|
||||
const handleFramesDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const [defaultAnimation, setDefaultAnimation] = useState(component?.defaultAnimation || '');
|
||||
|
||||
// Sync with component changes
|
||||
useEffect(() => {
|
||||
if (component) {
|
||||
setDefaultAnimation(component.defaultAnimation || '');
|
||||
}
|
||||
}, [component?.defaultAnimation]);
|
||||
|
||||
const setAsDefaultAnimationHandler = (clipName: string) => {
|
||||
if (component) {
|
||||
component.defaultAnimation = clipName;
|
||||
setDefaultAnimation(clipName);
|
||||
|
||||
// Notify parent to update the defaultAnimation field
|
||||
if (onDefaultAnimationChange) {
|
||||
onDefaultAnimationChange(clipName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultAnimation = (clipName: string) => {
|
||||
return defaultAnimation === clipName;
|
||||
};
|
||||
|
||||
const handlePlayPreview = (clipName: string) => {
|
||||
if (component) {
|
||||
const engineService = EngineService.getInstance();
|
||||
|
||||
// Get the actual component from scene entity (not the one passed as prop)
|
||||
const scene = engineService.getScene();
|
||||
const entityId = component.entityId;
|
||||
let actualComponent = component;
|
||||
|
||||
if (scene && entityId !== undefined && entityId !== null) {
|
||||
const sceneEntity = scene.findEntityById(entityId);
|
||||
if (sceneEntity) {
|
||||
const sceneAnimator = sceneEntity.getComponent(component.constructor as any);
|
||||
if (sceneAnimator) {
|
||||
actualComponent = sceneAnimator as SpriteAnimatorComponent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playingClip === clipName) {
|
||||
// Stop playing
|
||||
actualComponent.stop();
|
||||
setPlayingClip(null);
|
||||
engineService.disableAnimationPreview();
|
||||
} else {
|
||||
// Stop previous animation if any
|
||||
actualComponent.stop();
|
||||
|
||||
// Sync clips data to component before playing
|
||||
actualComponent.clips = clips;
|
||||
|
||||
// Enable animation preview if not already enabled
|
||||
if (!engineService.isAnimationPreviewEnabled()) {
|
||||
engineService.enableAnimationPreview();
|
||||
}
|
||||
|
||||
// Play this clip
|
||||
actualComponent.play(clipName);
|
||||
setPlayingClip(clipName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync playingClip state with actual component state
|
||||
useEffect(() => {
|
||||
if (component && playingClip) {
|
||||
// Check if component is still playing
|
||||
if (!component.isPlaying()) {
|
||||
setPlayingClip(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stop preview when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (component) {
|
||||
component.stop();
|
||||
const engineService = EngineService.getInstance();
|
||||
engineService.disableAnimationPreview();
|
||||
}
|
||||
};
|
||||
}, [component]);
|
||||
|
||||
return (
|
||||
<div className="animation-clips-editor">
|
||||
<div className="clips-header">
|
||||
<span className="clips-label">{label}</span>
|
||||
{!readonly && (
|
||||
<button className="add-clip-btn" onClick={addClip} title="Add Animation Clip">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<div className="clips-empty">
|
||||
<Film size={24} strokeWidth={1} />
|
||||
<span>No animation clips</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="clips-list">
|
||||
{clips.map((clip, clipIndex) => (
|
||||
<div key={clipIndex} className="clip-item">
|
||||
<div className="clip-header" onClick={() => toggleClip(clipIndex)}>
|
||||
{expandedClips.has(clipIndex) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<Film size={14} />
|
||||
<input
|
||||
className="clip-name-input"
|
||||
value={clip.name}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
updateClip(clipIndex, { name: e.target.value });
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<span className="frame-count">{clip.frames.length} frames</span>
|
||||
{component && clip.frames.length > 0 && (
|
||||
<button
|
||||
className={`preview-clip-btn ${playingClip === clip.name ? 'is-playing' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePlayPreview(clip.name);
|
||||
}}
|
||||
title={playingClip === clip.name ? 'Stop Preview' : 'Preview Animation'}
|
||||
>
|
||||
{playingClip === clip.name ? <Square size={10} /> : <Play size={10} />}
|
||||
</button>
|
||||
)}
|
||||
{component && !readonly && (
|
||||
<button
|
||||
className={`set-default-btn ${isDefaultAnimation(clip.name) ? 'is-default' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAsDefaultAnimationHandler(clip.name);
|
||||
}}
|
||||
title={isDefaultAnimation(clip.name) ? 'Current Default Animation' : 'Set as Default Animation'}
|
||||
>
|
||||
<Star size={12} fill={isDefaultAnimation(clip.name) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{!readonly && (
|
||||
<button
|
||||
className="remove-clip-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clipIndex);
|
||||
}}
|
||||
title="Remove Clip"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedClips.has(clipIndex) && (
|
||||
<div className="clip-content">
|
||||
<div className="clip-settings">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clip.loop}
|
||||
onChange={(e) => updateClip(clipIndex, { loop: e.target.checked })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
<DraggableNumber
|
||||
label="Speed:"
|
||||
value={clip.speed}
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={(val) => updateClip(clipIndex, { speed: val })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="frames-section"
|
||||
onDrop={(e) => handleFramesDrop(clipIndex, e)}
|
||||
onDragOver={handleFramesDragOver}
|
||||
>
|
||||
<div className="frames-header">
|
||||
<span>Frames</span>
|
||||
{!readonly && (
|
||||
<button onClick={() => addFrame(clipIndex)} title="Add Frame">
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clip.frames.length === 0 ? (
|
||||
<div className="frames-empty frames-drop-zone">
|
||||
<Upload size={16} />
|
||||
<span>Drop images here or click + to add</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="frames-list">
|
||||
{clip.frames.map((frame, frameIndex) => (
|
||||
<div key={frameIndex} className="frame-item">
|
||||
<span className="frame-index">{frameIndex + 1}</span>
|
||||
<div className="frame-texture-field">
|
||||
<AssetField
|
||||
value={frame.texture}
|
||||
onChange={(val) => updateFrame(clipIndex, frameIndex, { texture: val || '' })}
|
||||
fileExtension=".png"
|
||||
placeholder="Texture..."
|
||||
readonly={readonly}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="frame-duration"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={frame.duration}
|
||||
onChange={(e) => updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })}
|
||||
disabled={readonly}
|
||||
title="Duration (seconds)"
|
||||
/>
|
||||
{!readonly && (
|
||||
<button
|
||||
onClick={() => removeFrame(clipIndex, frameIndex)}
|
||||
title="Remove Frame"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
|
||||
export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
@@ -15,6 +16,13 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
const fileExtension = context.metadata?.fileExtension || '';
|
||||
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AssetField
|
||||
label={label}
|
||||
@@ -23,7 +31,8 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
fileExtension={fileExtension}
|
||||
placeholder={placeholder}
|
||||
readonly={context.readonly}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,4 +196,4 @@ export class ColorFieldEditor implements IFieldEditor<Color> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './AssetFieldEditor';
|
||||
export * from './VectorFieldEditors';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './AnimationClipsFieldEditor';
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
|
||||
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
@@ -86,4 +86,4 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
|
||||
);
|
||||
}
|
||||
|
||||
const isStringArray = value.every(item => typeof item === 'string');
|
||||
const isStringArray = value.every((item) => typeof item === 'string');
|
||||
if (isStringArray && value.length <= 5) {
|
||||
return (
|
||||
<div className="property-field">
|
||||
@@ -136,4 +136,4 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,4 @@ export class NullRenderer implements IPropertyRenderer<null> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './PrimitiveRenderers';
|
||||
export * from './VectorRenderers';
|
||||
export * from './ComponentRenderer';
|
||||
export * from './FallbackRenderer';
|
||||
export * from './FallbackRenderer';
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { setGlobalLogLevel, LogLevel } from '@esengine/ecs-framework';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
import './styles/index.css';
|
||||
import './i18n/config';
|
||||
|
||||
// Set log level to Warn in production to reduce console noise
|
||||
setGlobalLogLevel(LogLevel.Warn);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -91,8 +91,6 @@ export class ProfilerPlugin implements IEditorPlugin {
|
||||
|
||||
// 将服务实例存储到全局,供组件访问
|
||||
(window as any).__PROFILER_SERVICE__ = this.profilerService;
|
||||
|
||||
console.log('[ProfilerPlugin] Installed and ProfilerService started');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
@@ -103,12 +101,9 @@ export class ProfilerPlugin implements IEditorPlugin {
|
||||
}
|
||||
|
||||
delete (window as any).__PROFILER_SERVICE__;
|
||||
|
||||
console.log('[ProfilerPlugin] Uninstalled and ProfilerService stopped');
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[ProfilerPlugin] Editor is ready');
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
@@ -119,12 +114,10 @@ export class ProfilerPlugin implements IEditorPlugin {
|
||||
parentId: 'window',
|
||||
order: 100,
|
||||
onClick: () => {
|
||||
console.log('[ProfilerPlugin] Menu item clicked!');
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
console.log('[ProfilerPlugin] Registering menu items:', items);
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
{onDeleteNode && (
|
||||
<div
|
||||
onClick={onDeleteNode}
|
||||
style={{...menuItemStyle, color: '#f48771'}}
|
||||
style={{ ...menuItemStyle, color: '#f48771' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
|
||||
@@ -67,13 +67,13 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
||||
|
||||
const flattenedTemplates = React.useMemo(() => {
|
||||
return categoryGroups.flatMap(group =>
|
||||
return categoryGroups.flatMap((group) =>
|
||||
group.isExpanded ? group.templates : []
|
||||
);
|
||||
}, [categoryGroups]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
setExpandedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
@@ -86,7 +86,7 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
|
||||
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
|
||||
setExpandedCategories(categories);
|
||||
}
|
||||
}, [allTemplates, expandedCategories.size]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo } from 'lucide-react';
|
||||
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo, Box } from 'lucide-react';
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
|
||||
@@ -7,6 +7,7 @@ interface EditorToolbarProps {
|
||||
executionMode: ExecutionMode;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
showGizmos: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
@@ -16,12 +17,14 @@ interface EditorToolbarProps {
|
||||
onRedo: () => void;
|
||||
onResetView: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onToggleGizmos: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
executionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
showGizmos,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
@@ -30,7 +33,8 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onResetView,
|
||||
onClearCanvas
|
||||
onClearCanvas,
|
||||
onToggleGizmos
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -200,6 +204,27 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
清空
|
||||
</button>
|
||||
|
||||
{/* Gizmo 开关按钮 */}
|
||||
<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="显示/隐藏选择边框 (Gizmos)"
|
||||
>
|
||||
<Box size={14} />
|
||||
Gizmos
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
|
||||
331
packages/editor-app/src/services/EditorEngineSync.ts
Normal file
331
packages/editor-app/src/services/EditorEngineSync.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Editor-Engine Sync Service
|
||||
* 编辑器-引擎同步服务
|
||||
*
|
||||
* Synchronizes editor entities to Rust engine for rendering.
|
||||
* 将编辑器实体同步到Rust引擎进行渲染。
|
||||
*/
|
||||
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { EngineService } from './EngineService';
|
||||
|
||||
export class EditorEngineSync {
|
||||
private static instance: EditorEngineSync | null = null;
|
||||
|
||||
private engineService: EngineService;
|
||||
private messageHub: MessageHub | null = null;
|
||||
private entityStore: EntityStoreService | null = null;
|
||||
|
||||
// Track synced entities: editor entity id -> engine entity id
|
||||
private syncedEntities: Map<number, Entity> = new Map();
|
||||
|
||||
// Subscription IDs
|
||||
private subscriptions: Array<() => void> = [];
|
||||
|
||||
private initialized = false;
|
||||
|
||||
private constructor() {
|
||||
this.engineService = EngineService.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
* 获取单例实例。
|
||||
*/
|
||||
static getInstance(): EditorEngineSync {
|
||||
if (!EditorEngineSync.instance) {
|
||||
EditorEngineSync.instance = new EditorEngineSync();
|
||||
}
|
||||
return EditorEngineSync.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sync service.
|
||||
* 初始化同步服务。
|
||||
*/
|
||||
initialize(messageHub: MessageHub, entityStore: EntityStoreService): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageHub = messageHub;
|
||||
this.entityStore = entityStore;
|
||||
|
||||
// Subscribe to entity events
|
||||
this.subscribeToEvents();
|
||||
|
||||
// Sync existing entities
|
||||
this.syncAllEntities();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to MessageHub events.
|
||||
* 订阅MessageHub事件。
|
||||
*/
|
||||
private subscribeToEvents(): void {
|
||||
if (!this.messageHub) return;
|
||||
|
||||
// Entity added
|
||||
const unsubAdd = this.messageHub.subscribe('entity:added', (data: { entity: Entity }) => {
|
||||
this.syncEntity(data.entity);
|
||||
});
|
||||
this.subscriptions.push(unsubAdd);
|
||||
|
||||
// Entity removed
|
||||
const unsubRemove = this.messageHub.subscribe('entity:removed', (data: { entity: Entity }) => {
|
||||
this.removeEntityFromEngine(data.entity);
|
||||
});
|
||||
this.subscriptions.push(unsubRemove);
|
||||
|
||||
// Component property changed - need to re-sync entity
|
||||
const unsubComponent = this.messageHub.subscribe('component:property:changed', (data: { entity: Entity; component: Component; propertyName: string; value: any }) => {
|
||||
this.updateEntityInEngine(data.entity, data.component, data.propertyName, data.value);
|
||||
});
|
||||
this.subscriptions.push(unsubComponent);
|
||||
|
||||
// Component added - sync entity if it has sprite
|
||||
const unsubComponentAdded = this.messageHub.subscribe('component:added', (data: { entity: Entity; component: Component }) => {
|
||||
this.syncEntity(data.entity);
|
||||
});
|
||||
this.subscriptions.push(unsubComponentAdded);
|
||||
|
||||
// Entities cleared
|
||||
const unsubClear = this.messageHub.subscribe('entities:cleared', () => {
|
||||
this.clearAllFromEngine();
|
||||
});
|
||||
this.subscriptions.push(unsubClear);
|
||||
|
||||
// Entity selected - update gizmo display
|
||||
const unsubSelected = this.messageHub.subscribe('entity:selected', (data: { entity: Entity | null }) => {
|
||||
this.updateSelectedEntity(data.entity);
|
||||
});
|
||||
this.subscriptions.push(unsubSelected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selected entity for gizmo display.
|
||||
* 更新选中的实体用于Gizmo显示。
|
||||
*/
|
||||
private updateSelectedEntity(entity: Entity | null): void {
|
||||
if (entity) {
|
||||
this.engineService.setSelectedEntityIds([entity.id]);
|
||||
} else {
|
||||
this.engineService.setSelectedEntityIds([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all existing entities.
|
||||
* 同步所有现有实体。
|
||||
*/
|
||||
private syncAllEntities(): void {
|
||||
if (!this.entityStore) return;
|
||||
|
||||
const entities = this.entityStore.getAllEntities();
|
||||
for (const entity of entities) {
|
||||
this.syncEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single entity to engine.
|
||||
* 将单个实体同步到引擎。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem
|
||||
* via Rust engine's path-based texture loading.
|
||||
* 注意:纹理加载现在由EngineRenderSystem通过Rust引擎的路径加载自动处理。
|
||||
*/
|
||||
private syncEntity(entity: Entity): void {
|
||||
// Check if entity has sprite component
|
||||
const spriteComponent = entity.getComponent(SpriteComponent);
|
||||
if (!spriteComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload animator textures and set first frame
|
||||
// 预加载动画纹理并设置第一帧
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent);
|
||||
if (animator && animator.clips) {
|
||||
const bridge = this.engineService.getBridge();
|
||||
if (bridge) {
|
||||
for (const clip of animator.clips) {
|
||||
for (const frame of clip.frames) {
|
||||
if (frame.texture) {
|
||||
// Trigger texture loading
|
||||
bridge.getOrLoadTextureByPath(frame.texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set sprite texture to first frame (static preview in editor)
|
||||
// 设置精灵纹理为第一帧(编辑器中的静态预览)
|
||||
if (animator.clips && animator.clips.length > 0) {
|
||||
const firstClip = animator.clips[0];
|
||||
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
|
||||
const firstFrame = firstClip.frames[0];
|
||||
if (firstFrame && firstFrame.texture && spriteComponent) {
|
||||
spriteComponent.texture = firstFrame.texture;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track synced entity
|
||||
this.syncedEntities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entity from tracking.
|
||||
* 从跟踪中移除实体。
|
||||
*/
|
||||
private removeEntityFromEngine(entity: Entity): void {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
// Just remove from tracking, entity destruction is handled by the command
|
||||
this.syncedEntities.delete(entity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity in engine when component changes.
|
||||
* 当组件变化时更新引擎中的实体。
|
||||
*/
|
||||
private updateEntityInEngine(entity: Entity, component: Component, propertyName: string, value: any): void {
|
||||
const engineEntity = this.syncedEntities.get(entity.id);
|
||||
if (!engineEntity) {
|
||||
// Entity not synced yet, try to sync it
|
||||
this.syncEntity(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update based on component type
|
||||
if (component instanceof TransformComponent) {
|
||||
this.updateTransform(engineEntity, component);
|
||||
} else if (component instanceof SpriteComponent) {
|
||||
this.updateSprite(engineEntity, component, propertyName, value);
|
||||
} else if (component instanceof SpriteAnimatorComponent) {
|
||||
this.updateAnimator(engineEntity, component, propertyName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animator - preload textures and set initial frame.
|
||||
* 更新动画器 - 预加载纹理并设置初始帧。
|
||||
*/
|
||||
private updateAnimator(entity: Entity, animator: SpriteAnimatorComponent, propertyName: string): void {
|
||||
// In editor mode, only preload textures and show first frame (no animation playback)
|
||||
// 编辑模式下只预加载纹理并显示第一帧(不播放动画)
|
||||
const bridge = this.engineService.getBridge();
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
|
||||
if (bridge && animator.clips) {
|
||||
// Preload all frame textures
|
||||
for (const clip of animator.clips) {
|
||||
for (const frame of clip.frames) {
|
||||
if (frame.texture) {
|
||||
bridge.getOrLoadTextureByPath(frame.texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set sprite texture to first frame if available (static preview in editor)
|
||||
// 设置精灵纹理为第一帧(编辑器中的静态预览)
|
||||
if (sprite && animator.clips && animator.clips.length > 0) {
|
||||
const firstClip = animator.clips[0];
|
||||
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
|
||||
const firstFrame = firstClip.frames[0];
|
||||
if (firstFrame && firstFrame.texture) {
|
||||
sprite.texture = firstFrame.texture;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transform in engine entity.
|
||||
* 更新引擎实体的变换。
|
||||
*/
|
||||
private updateTransform(engineEntity: Entity, transform: TransformComponent): void {
|
||||
// Get engine transform component (same type as editor)
|
||||
const engineTransform = engineEntity.getComponent(TransformComponent);
|
||||
if (engineTransform) {
|
||||
engineTransform.position = {
|
||||
x: transform.position?.x ?? 0,
|
||||
y: transform.position?.y ?? 0,
|
||||
z: transform.position?.z ?? 0
|
||||
};
|
||||
engineTransform.rotation = {
|
||||
x: transform.rotation?.x ?? 0,
|
||||
y: transform.rotation?.y ?? 0,
|
||||
z: transform.rotation?.z ?? 0
|
||||
};
|
||||
engineTransform.scale = {
|
||||
x: transform.scale?.x ?? 1,
|
||||
y: transform.scale?.y ?? 1,
|
||||
z: transform.scale?.z ?? 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sprite in engine entity.
|
||||
* 更新引擎实体的精灵。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
||||
*/
|
||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||
// No manual texture loading needed - EngineRenderSystem handles it
|
||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all synced entities from tracking.
|
||||
* 清除所有已同步实体的跟踪。
|
||||
*/
|
||||
private clearAllFromEngine(): void {
|
||||
// Just clear tracking, entity destruction is handled elsewhere
|
||||
this.syncedEntities.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized.
|
||||
* 检查是否已初始化。
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get synced entity count.
|
||||
* 获取已同步实体数量。
|
||||
*/
|
||||
getSyncedCount(): number {
|
||||
return this.syncedEntities.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose sync service.
|
||||
* 释放同步服务。
|
||||
*/
|
||||
dispose(): void {
|
||||
// Unsubscribe from all events
|
||||
for (const unsub of this.subscriptions) {
|
||||
unsub();
|
||||
}
|
||||
this.subscriptions = [];
|
||||
|
||||
// Clear synced entities
|
||||
this.syncedEntities.clear();
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default EditorEngineSync;
|
||||
@@ -3,20 +3,14 @@
|
||||
* 管理Rust引擎生命周期的服务。
|
||||
*/
|
||||
|
||||
import { EngineBridge, SpriteComponent, EngineRenderSystem, ITransformComponent } from '@esengine/ecs-engine-bindgen';
|
||||
import { Core, Scene, Entity, Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import { EngineBridge, EngineRenderSystem, CameraConfig } from '@esengine/ecs-engine-bindgen';
|
||||
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
|
||||
/**
|
||||
* Transform component for editor entities.
|
||||
* 编辑器实体的变换组件。
|
||||
*/
|
||||
@ECSComponent('Transform')
|
||||
export class TransformComponent extends Component implements ITransformComponent {
|
||||
position = { x: 0, y: 0 };
|
||||
rotation = 0;
|
||||
scale = { x: 1, y: 1 };
|
||||
}
|
||||
import { AssetManager, EngineIntegration, AssetPathResolver, AssetPlatform } from '@esengine/asset-system';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { IdGenerator } from '../utils/idGenerator';
|
||||
|
||||
/**
|
||||
* Engine service singleton for editor integration.
|
||||
@@ -28,10 +22,17 @@ export class EngineService {
|
||||
private bridge: EngineBridge | null = null;
|
||||
private scene: Scene | null = null;
|
||||
private renderSystem: EngineRenderSystem | null = null;
|
||||
private animatorSystem: SpriteAnimatorSystem | null = null;
|
||||
private initialized = false;
|
||||
private running = false;
|
||||
private animationFrameId: number | null = null;
|
||||
private lastTime = 0;
|
||||
private sceneSnapshot: string | null = null;
|
||||
private assetManager: AssetManager | null = null;
|
||||
private engineIntegration: EngineIntegration | null = null;
|
||||
private assetPathResolver: AssetPathResolver | null = null;
|
||||
private assetSystemInitialized = false;
|
||||
private initializationError: Error | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -64,29 +65,119 @@ export class EngineService {
|
||||
// Initialize WASM with pre-imported module | 使用预导入模块初始化WASM
|
||||
await this.bridge.initializeWithModule(esEngine);
|
||||
|
||||
// Set path resolver for Tauri asset URLs | 设置Tauri资产URL的路径解析器
|
||||
this.bridge.setPathResolver((path: string) => {
|
||||
// If already a URL, return as-is
|
||||
if (path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://')) {
|
||||
return path;
|
||||
}
|
||||
// Convert file path to Tauri asset URL
|
||||
return convertFileSrc(path);
|
||||
});
|
||||
|
||||
// Initialize Core if not already | 初始化Core(如果尚未初始化)
|
||||
if (!Core.scene) {
|
||||
Core.create({ debug: false });
|
||||
}
|
||||
|
||||
// Create ECS scene and set it via Core | 通过Core创建并设置ECS场景
|
||||
this.scene = new Scene({ name: 'EditorScene' });
|
||||
// Use existing Core scene or create new one | 使用现有Core场景或创建新的
|
||||
if (Core.scene) {
|
||||
this.scene = Core.scene as Scene;
|
||||
} else {
|
||||
this.scene = new Scene({ name: 'EditorScene' });
|
||||
Core.setScene(this.scene);
|
||||
}
|
||||
|
||||
// Add render system | 添加渲染系统
|
||||
// Add sprite animator system (disabled by default in editor mode)
|
||||
// 添加精灵动画系统(编辑器模式下默认禁用)
|
||||
this.animatorSystem = new SpriteAnimatorSystem();
|
||||
this.animatorSystem.enabled = false;
|
||||
this.scene!.addSystem(this.animatorSystem);
|
||||
|
||||
// Add render system to the scene | 将渲染系统添加到场景
|
||||
this.renderSystem = new EngineRenderSystem(this.bridge, TransformComponent);
|
||||
this.scene.addSystem(this.renderSystem);
|
||||
this.scene!.addSystem(this.renderSystem);
|
||||
|
||||
// Set scene via Core | 通过Core设置场景
|
||||
Core.setScene(this.scene);
|
||||
// Initialize asset system | 初始化资产系统
|
||||
await this.initializeAssetSystem();
|
||||
|
||||
// Start the default world to enable system updates
|
||||
// 启动默认world以启用系统更新
|
||||
const defaultWorld = Core.worldManager.getWorld('__default__');
|
||||
if (defaultWorld && !defaultWorld.isActive) {
|
||||
defaultWorld.start();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log('EngineService initialized | 引擎服务初始化完成');
|
||||
|
||||
// Sync viewport size immediately after initialization
|
||||
// 初始化后立即同步视口尺寸
|
||||
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||||
if (canvas && canvas.parentElement) {
|
||||
// Get container size in CSS pixels
|
||||
// 获取容器尺寸(CSS像素)
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Canvas internal size uses DPR for sharpness
|
||||
// Canvas内部尺寸使用DPR以保持清晰
|
||||
canvas.width = Math.floor(rect.width * dpr);
|
||||
canvas.height = Math.floor(rect.height * dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
// Camera uses actual canvas pixels for correct rendering
|
||||
// 相机使用实际canvas像素以保证正确渲染
|
||||
this.bridge.resize(canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Auto-start render loop for editor preview | 自动启动渲染循环用于编辑器预览
|
||||
this.startRenderLoop();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize engine | 引擎初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start render loop (editor preview mode).
|
||||
* 启动渲染循环(编辑器预览模式)。
|
||||
*/
|
||||
private startRenderLoop(): void {
|
||||
if (this.animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
this.lastTime = performance.now();
|
||||
this.renderLoop();
|
||||
}
|
||||
|
||||
private frameCount = 0;
|
||||
|
||||
/**
|
||||
* Render loop for editor preview (always runs).
|
||||
* 编辑器预览的渲染循环(始终运行)。
|
||||
*/
|
||||
private renderLoop = (): void => {
|
||||
const currentTime = performance.now();
|
||||
const deltaTime = (currentTime - this.lastTime) / 1000;
|
||||
this.lastTime = currentTime;
|
||||
|
||||
this.frameCount++;
|
||||
|
||||
// Update via Core (handles deltaTime internally) | 通过Core更新
|
||||
Core.update(deltaTime);
|
||||
|
||||
// Note: Rendering is handled by EngineRenderSystem.process()
|
||||
// Texture loading is handled automatically via Rust engine's path-based loading
|
||||
// 注意:渲染由 EngineRenderSystem.process() 处理
|
||||
// 纹理加载由Rust引擎的路径加载自动处理
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.renderLoop);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if engine is initialized.
|
||||
* 检查引擎是否已初始化。
|
||||
@@ -114,19 +205,82 @@ export class EngineService {
|
||||
|
||||
this.running = true;
|
||||
this.lastTime = performance.now();
|
||||
|
||||
// Enable animator system and start auto-play animations
|
||||
// 启用动画系统并启动自动播放的动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = true;
|
||||
}
|
||||
this.startAutoPlayAnimations();
|
||||
|
||||
this.gameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all auto-play animations.
|
||||
* 启动所有自动播放的动画。
|
||||
*/
|
||||
private startAutoPlayAnimations(): void {
|
||||
if (!this.scene) return;
|
||||
|
||||
const entities = this.scene.entities.findEntitiesWithComponent(SpriteAnimatorComponent);
|
||||
for (const entity of entities) {
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent);
|
||||
if (animator && animator.autoPlay && animator.defaultAnimation) {
|
||||
animator.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all animations and reset to first frame.
|
||||
* 停止所有动画并重置到第一帧。
|
||||
*/
|
||||
private stopAllAnimations(): void {
|
||||
if (!this.scene) return;
|
||||
|
||||
const entities = this.scene.entities.findEntitiesWithComponent(SpriteAnimatorComponent);
|
||||
for (const entity of entities) {
|
||||
const animator = entity.getComponent(SpriteAnimatorComponent);
|
||||
if (animator) {
|
||||
animator.stop();
|
||||
|
||||
// Reset sprite texture to first frame
|
||||
// 重置精灵纹理到第一帧
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
if (sprite && animator.clips && animator.clips.length > 0) {
|
||||
const firstClip = animator.clips[0];
|
||||
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
|
||||
const firstFrame = firstClip.frames[0];
|
||||
if (firstFrame && firstFrame.texture) {
|
||||
sprite.texture = firstFrame.texture;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the game loop.
|
||||
* 停止游戏循环。
|
||||
*/
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
|
||||
// Disable animator system and stop all animations
|
||||
// 禁用动画系统并停止所有动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
this.stopAllAnimations();
|
||||
|
||||
// Note: Don't cancel animationFrameId here, as renderLoop should keep running
|
||||
// for editor preview. The renderLoop will continue but gameLoop will stop
|
||||
// because this.running is false.
|
||||
// 注意:这里不要取消 animationFrameId,因为 renderLoop 应该继续运行
|
||||
// 用于编辑器预览。renderLoop 会继续运行,但 gameLoop 会停止
|
||||
// 因为 this.running 是 false。
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,6 +339,53 @@ export class EngineService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize asset system
|
||||
* 初始化资产系统
|
||||
*/
|
||||
private async initializeAssetSystem(): Promise<void> {
|
||||
try {
|
||||
// 创建资产管理器 / Create asset manager
|
||||
this.assetManager = new AssetManager();
|
||||
|
||||
// 创建路径解析器 / Create path resolver
|
||||
this.assetPathResolver = new AssetPathResolver({
|
||||
platform: AssetPlatform.Editor,
|
||||
pathTransformer: (path: string) => {
|
||||
// 编辑器平台使用Tauri的convertFileSrc
|
||||
// Use Tauri's convertFileSrc for editor platform
|
||||
if (!path.startsWith('http://') && !path.startsWith('https://') && !path.startsWith('data:')) {
|
||||
return convertFileSrc(path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
});
|
||||
|
||||
// 创建引擎集成 / Create engine integration
|
||||
if (this.bridge) {
|
||||
this.engineIntegration = new EngineIntegration(this.assetManager, this.bridge);
|
||||
}
|
||||
|
||||
this.assetSystemInitialized = true;
|
||||
this.initializationError = null;
|
||||
} catch (error) {
|
||||
this.assetSystemInitialized = false;
|
||||
this.initializationError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('Failed to initialize asset system:', error);
|
||||
|
||||
// Notify user of failure
|
||||
const messageHub = Core.services.tryResolve<MessageHub>(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('notification:error', {
|
||||
title: 'Asset System Error',
|
||||
message: 'Failed to initialize asset system. Some features may not work properly.'
|
||||
});
|
||||
}
|
||||
|
||||
throw this.initializationError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture.
|
||||
* 加载纹理。
|
||||
@@ -195,6 +396,70 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture through asset system
|
||||
* 通过资产系统加载纹理
|
||||
*/
|
||||
async loadTextureAsset(path: string): Promise<number> {
|
||||
// Check if asset system is properly initialized
|
||||
if (!this.assetSystemInitialized || this.initializationError) {
|
||||
console.warn('Asset system not initialized, using fallback texture loading');
|
||||
const textureId = IdGenerator.nextId('texture-fallback');
|
||||
this.loadTexture(textureId, path);
|
||||
return textureId;
|
||||
}
|
||||
|
||||
if (!this.engineIntegration) {
|
||||
// 回退到直接加载 / Fallback to direct loading
|
||||
const textureId = IdGenerator.nextId('texture');
|
||||
this.loadTexture(textureId, path);
|
||||
return textureId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.engineIntegration.loadTextureForComponent(path);
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture asset:', error);
|
||||
// Return a valid fallback ID instead of 0
|
||||
const fallbackId = IdGenerator.nextId('texture-fallback');
|
||||
|
||||
// Notify about texture loading failure
|
||||
const messageHub = Core.services.tryResolve<MessageHub>(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('notification:warning', {
|
||||
title: 'Texture Loading Failed',
|
||||
message: `Could not load texture: ${path}`
|
||||
});
|
||||
}
|
||||
|
||||
return fallbackId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset manager
|
||||
* 获取资产管理器
|
||||
*/
|
||||
getAssetManager(): AssetManager | null {
|
||||
return this.assetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine integration
|
||||
* 获取引擎集成
|
||||
*/
|
||||
getEngineIntegration(): EngineIntegration | null {
|
||||
return this.engineIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset path resolver
|
||||
* 获取资产路径解析器
|
||||
*/
|
||||
getAssetPathResolver(): AssetPathResolver | null {
|
||||
return this.assetPathResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine statistics.
|
||||
* 获取引擎统计信息。
|
||||
@@ -220,6 +485,45 @@ export class EngineService {
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable animation preview in editor mode.
|
||||
* 在编辑器模式下启用动画预览。
|
||||
*/
|
||||
enableAnimationPreview(): void {
|
||||
if (this.animatorSystem && !this.running) {
|
||||
// Clear entity cache to force re-query when enabled
|
||||
// 清除实体缓存以便启用时强制重新查询
|
||||
this.animatorSystem.clearEntityCache();
|
||||
this.animatorSystem.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable animation preview in editor mode.
|
||||
* 在编辑器模式下禁用动画预览。
|
||||
*/
|
||||
disableAnimationPreview(): void {
|
||||
if (this.animatorSystem && !this.running) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if animation preview is enabled.
|
||||
* 检查动画预览是否启用。
|
||||
*/
|
||||
isAnimationPreviewEnabled(): boolean {
|
||||
return this.animatorSystem?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the engine bridge.
|
||||
* 获取引擎桥接。
|
||||
*/
|
||||
getBridge(): EngineBridge | null {
|
||||
return this.bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the engine viewport.
|
||||
* 调整引擎视口大小。
|
||||
@@ -230,6 +534,278 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera position, zoom, and rotation.
|
||||
* 设置相机位置、缩放和旋转。
|
||||
*/
|
||||
setCamera(config: CameraConfig): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.setCamera(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera state.
|
||||
* 获取相机状态。
|
||||
*/
|
||||
getCamera(): CameraConfig {
|
||||
if (this.bridge) {
|
||||
return this.bridge.getCamera();
|
||||
}
|
||||
return { x: 0, y: 0, zoom: 1, rotation: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set grid visibility.
|
||||
* 设置网格可见性。
|
||||
*/
|
||||
setShowGrid(show: boolean): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.setShowGrid(show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set clear color (background color).
|
||||
* 设置清除颜色(背景颜色)。
|
||||
*/
|
||||
setClearColor(r: number, g: number, b: number, a: number = 1.0): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.setClearColor(r, g, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gizmo visibility.
|
||||
* 设置Gizmo可见性。
|
||||
*/
|
||||
setShowGizmos(show: boolean): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setShowGizmos(show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gizmo visibility.
|
||||
* 获取Gizmo可见性。
|
||||
*/
|
||||
getShowGizmos(): boolean {
|
||||
return this.renderSystem?.getShowGizmos() ?? true;
|
||||
}
|
||||
|
||||
// ===== Scene Snapshot API =====
|
||||
// ===== 场景快照 API =====
|
||||
|
||||
/**
|
||||
* Save a snapshot of the current scene state.
|
||||
* 保存当前场景状态的快照。
|
||||
*/
|
||||
saveSceneSnapshot(): boolean {
|
||||
if (!this.scene) {
|
||||
console.warn('Cannot save snapshot: no scene available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SceneSerializer from core library
|
||||
this.sceneSnapshot = SceneSerializer.serialize(this.scene, {
|
||||
format: 'json',
|
||||
pretty: false,
|
||||
includeMetadata: false
|
||||
}) as string;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene snapshot:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore scene state from saved snapshot.
|
||||
* 从保存的快照恢复场景状态。
|
||||
*/
|
||||
restoreSceneSnapshot(): boolean {
|
||||
if (!this.scene || !this.sceneSnapshot) {
|
||||
console.warn('Cannot restore snapshot: no scene or snapshot available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SceneSerializer from core library
|
||||
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {
|
||||
strategy: 'replace',
|
||||
preserveIds: true
|
||||
});
|
||||
|
||||
// Sync EntityStore with restored scene entities
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (entityStore && messageHub) {
|
||||
// Remember selected entity ID before clearing
|
||||
const selectedEntity = entityStore.getSelectedEntity();
|
||||
const selectedId = selectedEntity?.id;
|
||||
|
||||
// Clear old entities from store
|
||||
entityStore.clear();
|
||||
|
||||
// Add restored entities to store
|
||||
for (const entity of this.scene.entities.buffer) {
|
||||
entityStore.addEntity(entity);
|
||||
}
|
||||
|
||||
// Re-select the same entity (now with new reference)
|
||||
if (selectedId !== undefined) {
|
||||
const newEntity = entityStore.getEntity(selectedId);
|
||||
if (newEntity) {
|
||||
entityStore.selectEntity(newEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI to refresh
|
||||
messageHub.publish('scene:restored', {});
|
||||
}
|
||||
|
||||
this.sceneSnapshot = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to restore scene snapshot:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists.
|
||||
* 检查是否存在快照。
|
||||
*/
|
||||
hasSnapshot(): boolean {
|
||||
return this.sceneSnapshot !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected entity IDs for gizmo display.
|
||||
* 设置选中的实体ID用于Gizmo显示。
|
||||
*/
|
||||
setSelectedEntityIds(ids: number[]): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setSelectedEntityIds(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
* 设置变换工具模式。
|
||||
*/
|
||||
setTransformMode(mode: 'select' | 'move' | 'rotate' | 'scale'): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setTransformMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transform tool mode.
|
||||
* 获取变换工具模式。
|
||||
*/
|
||||
getTransformMode(): 'select' | 'move' | 'rotate' | 'scale' {
|
||||
return this.renderSystem?.getTransformMode() ?? 'select';
|
||||
}
|
||||
|
||||
// ===== Multi-viewport API =====
|
||||
// ===== 多视口 API =====
|
||||
|
||||
/**
|
||||
* Register a new viewport.
|
||||
* 注册新视口。
|
||||
*/
|
||||
registerViewport(id: string, canvasId: string): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.registerViewport(id, canvasId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a viewport.
|
||||
* 注销视口。
|
||||
*/
|
||||
unregisterViewport(id: string): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.unregisterViewport(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active viewport.
|
||||
* 设置活动视口。
|
||||
*/
|
||||
setActiveViewport(id: string): boolean {
|
||||
if (this.bridge) {
|
||||
return this.bridge.setActiveViewport(id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera for a specific viewport.
|
||||
* 为特定视口设置相机。
|
||||
*/
|
||||
setViewportCamera(viewportId: string, config: CameraConfig): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.setViewportCamera(viewportId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera for a specific viewport.
|
||||
* 获取特定视口的相机。
|
||||
*/
|
||||
getViewportCamera(viewportId: string): CameraConfig | null {
|
||||
if (this.bridge) {
|
||||
return this.bridge.getViewportCamera(viewportId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set viewport configuration.
|
||||
* 设置视口配置。
|
||||
*/
|
||||
setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a specific viewport.
|
||||
* 调整特定视口大小。
|
||||
*/
|
||||
resizeViewport(viewportId: string, width: number, height: number): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.resizeViewport(viewportId, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render to a specific viewport.
|
||||
* 渲染到特定视口。
|
||||
*/
|
||||
renderToViewport(viewportId: string): void {
|
||||
if (this.bridge) {
|
||||
this.bridge.renderToViewport(viewportId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered viewport IDs.
|
||||
* 获取所有已注册的视口ID。
|
||||
*/
|
||||
getViewportIds(): string[] {
|
||||
if (this.bridge) {
|
||||
return this.bridge.getViewportIds();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose engine resources.
|
||||
* 释放引擎资源。
|
||||
@@ -237,6 +813,19 @@ export class EngineService {
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
|
||||
// Stop render loop | 停止渲染循环
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
// Dispose asset system | 释放资产系统
|
||||
if (this.assetManager) {
|
||||
this.assetManager.dispose();
|
||||
this.assetManager = null;
|
||||
}
|
||||
this.engineIntegration = null;
|
||||
|
||||
// Scene doesn't have a destroy method, just clear reference
|
||||
// 场景没有destroy方法,只需清除引用
|
||||
this.scene = null;
|
||||
|
||||
@@ -667,7 +667,7 @@ export class GitHubService {
|
||||
}
|
||||
|
||||
// 转换为最终结果,并对版本排序
|
||||
const plugins: PublishedPlugin[] = Array.from(pluginVersionsMap.values()).map(plugin => {
|
||||
const plugins: PublishedPlugin[] = Array.from(pluginVersionsMap.values()).map((plugin) => {
|
||||
// 按版本号降序排序(最新版本在前)
|
||||
const sortedVersions = plugin.versions.sort((a, b) => {
|
||||
const parseVersion = (v: string) => {
|
||||
@@ -714,9 +714,9 @@ export class GitHubService {
|
||||
detailsUrl: run.html_url,
|
||||
output: run.output
|
||||
? {
|
||||
title: run.output.title || '',
|
||||
summary: run.output.summary || ''
|
||||
}
|
||||
title: run.output.title || '',
|
||||
summary: run.output.summary || ''
|
||||
}
|
||||
: undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -782,8 +782,8 @@ export class GitHubService {
|
||||
|
||||
const files = await this.request<any[]>(`GET /repos/${owner}/${repo}/pulls/${prNumber}/files`);
|
||||
const conflictFiles = files
|
||||
.filter(file => file.status === 'modified' || file.status === 'added' || file.status === 'deleted')
|
||||
.map(file => file.filename);
|
||||
.filter((file) => file.status === 'modified' || file.status === 'added' || file.status === 'deleted')
|
||||
.map((file) => file.filename);
|
||||
|
||||
return {
|
||||
hasConflicts: true,
|
||||
@@ -932,7 +932,7 @@ export class GitHubService {
|
||||
this.scheduleRetryLoadUser();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GitHubService] Failed to load token:', error);
|
||||
this.notifyUserLoadStateChange(false);
|
||||
|
||||
@@ -22,6 +22,22 @@ export class NotificationService implements INotification {
|
||||
}
|
||||
}
|
||||
|
||||
warning(title: string, message: string, duration: number = 5000): void {
|
||||
this.show(`${title}: ${message}`, 'warning', duration);
|
||||
}
|
||||
|
||||
error(title: string, message: string, duration: number = 5000): void {
|
||||
this.show(`${title}: ${message}`, 'error', duration);
|
||||
}
|
||||
|
||||
info(title: string, message: string, duration: number = 3000): void {
|
||||
this.show(`${title}: ${message}`, 'info', duration);
|
||||
}
|
||||
|
||||
success(title: string, message: string, duration: number = 3000): void {
|
||||
this.show(`${title}: ${message}`, 'success', duration);
|
||||
}
|
||||
|
||||
hide(id: string): void {
|
||||
if (this.hideCallback) {
|
||||
this.hideCallback(id);
|
||||
@@ -29,4 +45,9 @@ export class NotificationService implements INotification {
|
||||
console.warn('[NotificationService] hideCallback not set');
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.showCallback = undefined;
|
||||
this.hideCallback = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export class PluginMarketService {
|
||||
|
||||
try {
|
||||
// 获取指定版本信息
|
||||
const versionInfo = plugin.versions.find(v => v.version === targetVersion);
|
||||
const versionInfo = plugin.versions.find((v) => v.version === targetVersion);
|
||||
if (!versionInfo) {
|
||||
throw new Error(`Version ${targetVersion} not found for plugin ${plugin.name}`);
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export class PluginMarketService {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
const plugins: InstalledPluginInfo[] = JSON.parse(stored);
|
||||
this.installedPlugins = new Map(plugins.map(p => [p.id, p]));
|
||||
this.installedPlugins = new Map(plugins.map((p) => [p.id, p]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to load installed plugins:', error);
|
||||
|
||||
@@ -221,7 +221,7 @@ export class PluginPublishService {
|
||||
manifest: Record<string, unknown>,
|
||||
publishInfo: PluginPublishInfo
|
||||
): Promise<void> {
|
||||
this.notifyProgress('uploading-files', `Checking for existing manifest...`, 65);
|
||||
this.notifyProgress('uploading-files', 'Checking for existing manifest...', 65);
|
||||
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
const existingManifest = await this.getExistingManifest(pluginId, publishInfo.category);
|
||||
@@ -229,7 +229,7 @@ export class PluginPublishService {
|
||||
let finalManifest = manifest;
|
||||
|
||||
if (existingManifest) {
|
||||
this.notifyProgress('uploading-files', `Merging with existing manifest...`, 68);
|
||||
this.notifyProgress('uploading-files', 'Merging with existing manifest...', 68);
|
||||
finalManifest = this.mergeManifestVersions(existingManifest, manifest, publishInfo.version);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export class PluginSourceParser {
|
||||
const packageJson = JSON.parse(packageJsonContent) as PluginPackageJson;
|
||||
|
||||
// 验证 ZIP 中必须包含 dist 目录
|
||||
const distFiles = Object.keys(zip.files).filter(f => f.startsWith('dist/'));
|
||||
const distFiles = Object.keys(zip.files).filter((f) => f.startsWith('dist/'));
|
||||
if (distFiles.length === 0) {
|
||||
throw new Error('dist/ directory not found in ZIP file. Please ensure the plugin is properly built.');
|
||||
}
|
||||
|
||||
219
packages/editor-app/src/services/RuntimeResolver.ts
Normal file
219
packages/editor-app/src/services/RuntimeResolver.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Runtime Module Resolver
|
||||
* 运行时模块解析器
|
||||
*
|
||||
* Resolves runtime module paths based on environment and configuration
|
||||
* 根据环境和配置解析运行时模块路径
|
||||
*/
|
||||
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
// Sanitize path by removing path traversal sequences and normalizing
|
||||
const sanitizePath = (path: string): string => {
|
||||
// Split by path separators, filter out '..' and empty segments, rejoin
|
||||
const segments = path.split(/[/\\]/).filter((segment) =>
|
||||
segment !== '..' && segment !== '.' && segment !== ''
|
||||
);
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = (): boolean => {
|
||||
try {
|
||||
// Vite environment variable
|
||||
return (import.meta as any).env?.DEV === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RuntimeModule {
|
||||
type: 'javascript' | 'wasm' | 'binary';
|
||||
files: string[];
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
runtime: {
|
||||
version: string;
|
||||
modules: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeResolver {
|
||||
private static instance: RuntimeResolver;
|
||||
private config: RuntimeConfig | null = null;
|
||||
private baseDir: string = '';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): RuntimeResolver {
|
||||
if (!RuntimeResolver.instance) {
|
||||
RuntimeResolver.instance = new RuntimeResolver();
|
||||
}
|
||||
return RuntimeResolver.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the runtime resolver
|
||||
* 初始化运行时解析器
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Load runtime configuration
|
||||
const response = await fetch('/runtime.config.json');
|
||||
this.config = await response.json();
|
||||
|
||||
// Determine base directory based on environment
|
||||
if (isDevelopment()) {
|
||||
// In development, use the project root
|
||||
// We need to go up from src-tauri to get the actual project root
|
||||
const currentDir = await TauriAPI.getCurrentDir();
|
||||
// currentDir might be src-tauri, so we need to find the actual workspace root
|
||||
this.baseDir = await this.findWorkspaceRoot(currentDir);
|
||||
} else {
|
||||
// In production, use the resource directory
|
||||
this.baseDir = await TauriAPI.getAppResourceDir();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find workspace root by looking for package.json or specific markers
|
||||
* 通过查找 package.json 或特定标记来找到工作区根目录
|
||||
*/
|
||||
private async findWorkspaceRoot(startPath: string): Promise<string> {
|
||||
let currentPath = startPath;
|
||||
|
||||
// Try to find the workspace root by looking for key files
|
||||
// We'll check up to 3 levels up from current directory
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Check if we're in src-tauri
|
||||
if (currentPath.endsWith('src-tauri')) {
|
||||
// Go up two levels to get to workspace root
|
||||
const parts = currentPath.split(/[/\\]/);
|
||||
parts.pop(); // Remove src-tauri
|
||||
parts.pop(); // Remove editor-app
|
||||
parts.pop(); // Remove packages
|
||||
return parts.join('\\');
|
||||
}
|
||||
|
||||
// Check for workspace markers
|
||||
const workspaceMarkers = [
|
||||
`${currentPath}\\pnpm-workspace.yaml`,
|
||||
`${currentPath}\\packages\\editor-app`,
|
||||
`${currentPath}\\packages\\platform-web`
|
||||
];
|
||||
|
||||
for (const marker of workspaceMarkers) {
|
||||
if (await TauriAPI.pathExists(marker)) {
|
||||
return currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Go up one level
|
||||
const parts = currentPath.split(/[/\\]/);
|
||||
parts.pop();
|
||||
currentPath = parts.join('\\');
|
||||
}
|
||||
|
||||
// Fallback to current directory
|
||||
return startPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime module files
|
||||
* 获取运行时模块文件
|
||||
*/
|
||||
async getModuleFiles(moduleName: string): Promise<RuntimeModule> {
|
||||
if (!this.config) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const moduleConfig = this.config!.runtime.modules[moduleName];
|
||||
if (!moduleConfig) {
|
||||
throw new Error(`Runtime module ${moduleName} not found in configuration`);
|
||||
}
|
||||
|
||||
const isDev = isDevelopment();
|
||||
const files: string[] = [];
|
||||
let sourcePath: string;
|
||||
|
||||
if (isDev) {
|
||||
// Development mode - use relative paths from workspace root
|
||||
const devPath = moduleConfig.development.path;
|
||||
sourcePath = `${this.baseDir}\\packages\\${sanitizePath(devPath)}`;
|
||||
|
||||
if (moduleConfig.main) {
|
||||
files.push(`${sourcePath}\\${moduleConfig.main}`);
|
||||
}
|
||||
if (moduleConfig.files) {
|
||||
for (const file of moduleConfig.files) {
|
||||
files.push(`${sourcePath}\\${file}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Production mode - files are bundled with the app
|
||||
sourcePath = this.baseDir;
|
||||
|
||||
if (moduleConfig.main) {
|
||||
files.push(`${sourcePath}\\${moduleConfig.main}`);
|
||||
}
|
||||
if (moduleConfig.files) {
|
||||
for (const file of moduleConfig.files) {
|
||||
files.push(`${sourcePath}\\${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: moduleConfig.type,
|
||||
files,
|
||||
sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare runtime files for browser preview
|
||||
* 为浏览器预览准备运行时文件
|
||||
*/
|
||||
async prepareRuntimeFiles(targetDir: string): Promise<void> {
|
||||
// Ensure target directory exists
|
||||
const dirExists = await TauriAPI.pathExists(targetDir);
|
||||
if (!dirExists) {
|
||||
await TauriAPI.createDirectory(targetDir);
|
||||
}
|
||||
|
||||
// Copy platform-web runtime
|
||||
const platformWeb = await this.getModuleFiles('platform-web');
|
||||
for (const srcFile of platformWeb.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
} else {
|
||||
throw new Error(`Runtime file not found: ${srcFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy engine WASM files
|
||||
const engine = await this.getModuleFiles('engine');
|
||||
for (const srcFile of engine.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
} else {
|
||||
throw new Error(`Engine file not found: ${srcFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace root directory
|
||||
* 获取工作区根目录
|
||||
*/
|
||||
getBaseDir(): string {
|
||||
return this.baseDir;
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ export class TauriFileSystemService implements IFileSystem {
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||
const entries = await invoke<Array<{ name: string; isDir: boolean }>>('list_directory', { path });
|
||||
return entries.map(entry => ({
|
||||
const entries = await invoke<Array<{ name: string; path: string; is_dir: boolean }>>('list_directory', { path });
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDir,
|
||||
path: `${path}/${entry.name}`
|
||||
isDirectory: entry.is_dir,
|
||||
path: entry.path
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { hasChildren, isTabNode, isTabsetNode } from './FlexLayoutTypes';
|
||||
|
||||
export class LayoutMerger {
|
||||
static merge(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
|
||||
const currentPanelIds = new Set(currentPanels.map(p => p.id));
|
||||
const currentPanelIds = new Set(currentPanels.map((p) => p.id));
|
||||
const savedPanelIds = this.collectPanelIds(savedLayout);
|
||||
const newPanelIds = Array.from(currentPanelIds).filter(id => !savedPanelIds.has(id));
|
||||
const removedPanelIds = Array.from(savedPanelIds).filter(id => !currentPanelIds.has(id));
|
||||
const newPanelIds = Array.from(currentPanelIds).filter((id) => !savedPanelIds.has(id));
|
||||
const removedPanelIds = Array.from(savedPanelIds).filter((id) => !currentPanelIds.has(id));
|
||||
|
||||
const mergedLayout = JSON.parse(JSON.stringify(savedLayout));
|
||||
|
||||
|
||||
@@ -34,30 +34,28 @@
|
||||
|
||||
.inspector-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-md);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(121, 121, 121, 0.4);
|
||||
border-radius: 8px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-corner {
|
||||
@@ -216,6 +214,8 @@
|
||||
}
|
||||
|
||||
.component-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast);
|
||||
@@ -465,3 +465,249 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加组件菜单样式 */
|
||||
.section-title-with-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.component-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-component-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-inverse);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.add-component-trigger:hover {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.add-component-trigger:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.component-dropdown-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.component-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.component-dropdown-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.component-dropdown-empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.component-dropdown-list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.component-dropdown-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.component-dropdown-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.component-dropdown-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.component-dropdown-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.component-category-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.component-category-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.component-category-label {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.component-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.component-dropdown-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.component-dropdown-item:active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.component-dropdown-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.component-dropdown-item-desc {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 组件列表项样式 */
|
||||
.component-item-card {
|
||||
margin-bottom: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.component-item-card:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.component-item-card.expanded {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.component-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-bg-base);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.component-item-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.component-expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.component-item-card:hover .component-expand-icon,
|
||||
.component-item-card.expanded .component-expand-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.component-item-name {
|
||||
flex: 1;
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.component-item-card .component-remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.component-item-header:hover .component-remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.component-item-card .component-remove-btn:hover {
|
||||
background: var(--color-error);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.component-item-content {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-base);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
234
packages/editor-app/src/styles/GameView.css
Normal file
234
packages/editor-app/src/styles/GameView.css
Normal file
@@ -0,0 +1,234 @@
|
||||
.game-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.game-view-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-view-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-view-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.game-view-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.game-view-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.game-view-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-view-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.game-view-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--color-border-default);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.game-view-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.game-view-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-view-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-view-overlay-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.game-view-overlay-content svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-view-overlay-content span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.game-view-stats {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-view-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-view-stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.game-view-stat-value {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-view:fullscreen {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.game-view:fullscreen .game-view-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-view:fullscreen .game-view-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.game-view-btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.profiler-toolbar-left {
|
||||
@@ -122,13 +123,15 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.profiler-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profiler-mode-switch {
|
||||
@@ -138,9 +141,10 @@
|
||||
background: var(--color-bg-inset);
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
.profiler-window .profiler-mode-switch .mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -153,14 +157,19 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
.profiler-window .profiler-mode-switch .mode-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
.profiler-window .profiler-mode-switch .mode-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
@@ -257,7 +266,10 @@
|
||||
.profiler-stats-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
@@ -266,6 +278,7 @@
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-item svg {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
gap: 8px;
|
||||
transition: background-color var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.property-field:hover {
|
||||
@@ -37,6 +38,21 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-controlled-icon {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.property-asset-drop.controlled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.property-label-draggable {
|
||||
@@ -98,39 +114,98 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.property-input-select {
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a0a0a0' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
padding-right: 24px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
/* Custom Dropdown */
|
||||
.property-dropdown {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.property-input-select:hover {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.property-input-select:focus {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23007acc' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.property-input-select option {
|
||||
background-color: var(--color-bg-elevated);
|
||||
.property-dropdown-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-bg-inset);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
color: var(--color-text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-family-mono);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.property-input-number::-webkit-inner-spin-button {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
.property-dropdown-trigger:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.property-input-number:hover::-webkit-inner-spin-button {
|
||||
opacity: 0.5;
|
||||
width: auto;
|
||||
.property-dropdown-trigger.open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.property-dropdown-trigger:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.property-dropdown-value {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.property-dropdown-arrow {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.property-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.property-dropdown-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.property-dropdown-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.property-dropdown-item.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.property-input-number::-webkit-inner-spin-button,
|
||||
.property-input-number::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.property-field-boolean {
|
||||
@@ -240,6 +315,208 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Custom Color Picker */
|
||||
.color-picker-popup {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
width: 200px;
|
||||
padding: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.color-picker-saturation {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.color-picker-saturation-white {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to right, #fff, transparent);
|
||||
}
|
||||
|
||||
.color-picker-saturation-black {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, #000, transparent);
|
||||
}
|
||||
|
||||
.color-picker-cursor {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.color-picker-hue {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(to right,
|
||||
#ff0000 0%,
|
||||
#ffff00 17%,
|
||||
#00ff00 33%,
|
||||
#00ffff 50%,
|
||||
#0000ff 67%,
|
||||
#ff00ff 83%,
|
||||
#ff0000 100%
|
||||
);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-picker-hue-cursor {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
border-radius: 2px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.color-picker-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.color-picker-preview-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-picker-hex {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.property-asset-drop {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.property-asset-drop:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.property-asset-drop.dragging {
|
||||
border-color: var(--color-accent);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.property-asset-text {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.property-asset-drop.has-value .property-asset-text {
|
||||
color: var(--color-text-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.property-asset-clear {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-asset-drop:hover .property-asset-clear {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.property-asset-clear:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.property-asset-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.property-asset-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-asset-drop:hover .property-asset-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.property-asset-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.property-label-row {
|
||||
flex: 0 0 40%;
|
||||
display: flex;
|
||||
@@ -285,9 +562,8 @@
|
||||
}
|
||||
|
||||
.property-input-number-compact {
|
||||
flex: 1;
|
||||
min-width: 24px;
|
||||
max-width: 40px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
padding: 1px 4px;
|
||||
font-size: 11px;
|
||||
@@ -301,12 +577,20 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.property-vector-axis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.property-vector-axis .property-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.property-vector-axis-label {
|
||||
@@ -393,3 +677,416 @@ input[type="number"].property-input::-webkit-inner-spin-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Property Action Buttons */
|
||||
.property-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.property-action-btn:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.property-action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Animation Clips Editor */
|
||||
.animation-clips-editor {
|
||||
padding: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.clips-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.clips-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add-clip-btn {
|
||||
padding: 4px;
|
||||
background: var(--color-bg-default);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-clip-btn:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.clips-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.clips-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.clip-item {
|
||||
background: var(--color-bg-default);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clip-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.clip-header > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clip-name-input {
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.clip-name-input:focus {
|
||||
outline: none;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.frame-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-clip-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preview-clip-btn:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.preview-clip-btn.is-playing {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.preview-clip-btn.is-playing:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.set-default-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.set-default-btn:hover {
|
||||
background: rgba(250, 204, 21, 0.2);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.set-default-btn.is-default {
|
||||
color: #facc15;
|
||||
background: rgba(250, 204, 21, 0.15);
|
||||
}
|
||||
|
||||
.set-default-btn.is-default:hover {
|
||||
background: rgba(250, 204, 21, 0.25);
|
||||
}
|
||||
|
||||
.clip-draggable-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.clip-draggable-label {
|
||||
color: var(--color-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.clip-draggable-number input {
|
||||
width: 50px;
|
||||
padding: 2px 4px;
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.clip-draggable-number input::-webkit-outer-spin-button,
|
||||
.clip-draggable-number input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clip-draggable-number input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.remove-clip-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.remove-clip-btn:hover {
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.clip-content {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clip-settings {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.clip-settings label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.clip-settings input[type="checkbox"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.clip-settings input[type="number"] {
|
||||
width: 50px;
|
||||
padding: 2px 4px;
|
||||
background: var(--color-bg-default);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 2px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.frames-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.frames-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.frames-header button {
|
||||
padding: 2px 4px;
|
||||
background: var(--color-bg-default);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.frames-header button:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.frames-empty {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.frames-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.frames-drop-zone:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.frames-section {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.frames-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.frame-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--color-bg-default);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.frame-index {
|
||||
width: 16px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.frame-texture-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field__container {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field__icon {
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field__input {
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field__value {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.frame-texture-field .asset-field__button {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.frame-duration {
|
||||
width: 45px;
|
||||
padding: 2px 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 2px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.frame-item button {
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.frame-item button:hover {
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
125
packages/editor-app/src/styles/QRCodeDialog.css
Normal file
125
packages/editor-app/src/styles/QRCodeDialog.css
Normal file
@@ -0,0 +1,125 @@
|
||||
.qrcode-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.qrcode-dialog {
|
||||
background: var(--panel-background, #2d2d2d);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
width: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qrcode-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #404040);
|
||||
}
|
||||
|
||||
.qrcode-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.qrcode-dialog-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary, #808080);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qrcode-dialog-close:hover {
|
||||
background: var(--hover-background, #3d3d3d);
|
||||
color: var(--text-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.qrcode-dialog-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qrcode-dialog-content canvas,
|
||||
.qrcode-dialog-content img {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.qrcode-loading,
|
||||
.qrcode-error {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--input-background, #1e1e1e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #808080);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.qrcode-url-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qrcode-url-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--input-background, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #404040);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color, #e0e0e0);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.qrcode-url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.qrcode-copy-button {
|
||||
padding: 8px;
|
||||
background: var(--button-background, #3d3d3d);
|
||||
border: 1px solid var(--border-color, #404040);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color, #e0e0e0);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qrcode-copy-button:hover {
|
||||
background: var(--button-hover-background, #4d4d4d);
|
||||
}
|
||||
|
||||
.qrcode-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #808080);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -63,6 +63,60 @@
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.viewport-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.viewport-btn:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.viewport-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewport-dropdown .viewport-btn {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewport-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 160px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewport-dropdown-menu button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewport-dropdown-menu button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.viewport-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
|
||||
@@ -47,28 +47,62 @@ select {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 滚动条悬停时显示更宽 */
|
||||
*:hover::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-thumb {
|
||||
background: rgba(121, 121, 121, 0.4);
|
||||
border-radius: 8px;
|
||||
border: 3px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 5px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
96
packages/editor-app/src/utils/idGenerator.ts
Normal file
96
packages/editor-app/src/utils/idGenerator.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ID Generator Utility
|
||||
* 提供安全可靠的ID生成机制
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates unique sequential IDs for textures and other resources
|
||||
* 为纹理和其他资源生成唯一的顺序ID
|
||||
*/
|
||||
export class IdGenerator {
|
||||
private static counters = new Map<string, number>();
|
||||
private static usedIds = new Map<string, Set<number>>();
|
||||
|
||||
/**
|
||||
* Generate next sequential ID for a given namespace
|
||||
* 为给定的命名空间生成下一个顺序ID
|
||||
*/
|
||||
static nextId(namespace: string): number {
|
||||
const current = this.counters.get(namespace) || 1000;
|
||||
const next = current + 1;
|
||||
this.counters.set(namespace, next);
|
||||
|
||||
// Track used IDs
|
||||
if (!this.usedIds.has(namespace)) {
|
||||
this.usedIds.set(namespace, new Set());
|
||||
}
|
||||
this.usedIds.get(namespace)!.add(next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID v4
|
||||
* 生成 UUID v4
|
||||
*/
|
||||
static uuid(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ID is already used
|
||||
* 检查ID是否已被使用
|
||||
*/
|
||||
static isUsed(namespace: string, id: number): boolean {
|
||||
return this.usedIds.get(namespace)?.has(id) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a specific ID
|
||||
* 保留特定的ID
|
||||
*/
|
||||
static reserve(namespace: string, id: number): void {
|
||||
if (!this.usedIds.has(namespace)) {
|
||||
this.usedIds.set(namespace, new Set());
|
||||
}
|
||||
this.usedIds.get(namespace)!.add(id);
|
||||
|
||||
// Update counter if needed
|
||||
const current = this.counters.get(namespace) || 1000;
|
||||
if (id >= current) {
|
||||
this.counters.set(namespace, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release an ID for reuse
|
||||
* 释放ID以供重用
|
||||
*/
|
||||
static release(namespace: string, id: number): void {
|
||||
this.usedIds.get(namespace)?.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a namespace
|
||||
* 重置命名空间
|
||||
*/
|
||||
static reset(namespace: string): void {
|
||||
this.counters.delete(namespace);
|
||||
this.usedIds.delete(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a namespace
|
||||
* 获取命名空间的统计信息
|
||||
*/
|
||||
static getStats(namespace: string): { nextId: number; usedCount: number } {
|
||||
return {
|
||||
nextId: (this.counters.get(namespace) || 1000) + 1,
|
||||
usedCount: this.usedIds.get(namespace)?.size || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user