feat: 实现可扩展的字段编辑器系统与专业资产选择器 (#227)
This commit is contained in:
@@ -7,7 +7,8 @@ import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
|
|||||||
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
|
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
|
||||||
import { showToast } from '../../services/NotificationService';
|
import { showToast } from '../../services/NotificationService';
|
||||||
import { FolderOpen } from 'lucide-react';
|
import { FolderOpen } from 'lucide-react';
|
||||||
import type { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
||||||
|
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||||
import './BehaviorTreeEditorPanel.css';
|
import './BehaviorTreeEditorPanel.css';
|
||||||
|
|
||||||
const logger = createLogger('BehaviorTreeEditorPanel');
|
const logger = createLogger('BehaviorTreeEditorPanel');
|
||||||
@@ -69,7 +70,9 @@ export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const messageHub = Core.services.resolve(MessageHub);
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
const unsubscribe = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
|
|
||||||
|
// 订阅文件打开事件
|
||||||
|
const unsubscribeFileOpened = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
|
||||||
setCurrentFilePath(data.filePath);
|
setCurrentFilePath(data.filePath);
|
||||||
setCurrentFileName(data.fileName);
|
setCurrentFileName(data.fileName);
|
||||||
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||||
@@ -77,11 +80,51 @@ export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = (
|
|||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 订阅节点属性更改事件
|
||||||
|
const unsubscribePropertyChanged = messageHub.subscribe('behavior-tree:node-property-changed',
|
||||||
|
(data: { nodeId: string; propertyName: string; value: any }) => {
|
||||||
|
const state = useBehaviorTreeDataStore.getState();
|
||||||
|
const node = state.getNode(data.nodeId);
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
const newData = { ...node.data, [data.propertyName]: data.value };
|
||||||
|
|
||||||
|
// 更新节点数据
|
||||||
|
const updatedNode = new BehaviorTreeNode(
|
||||||
|
node.id,
|
||||||
|
node.template,
|
||||||
|
newData,
|
||||||
|
node.position,
|
||||||
|
Array.from(node.children)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新树
|
||||||
|
const nodes = state.getNodes().map(n =>
|
||||||
|
n.id === data.nodeId ? updatedNode : n
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTree = new BehaviorTree(
|
||||||
|
nodes,
|
||||||
|
state.getConnections(),
|
||||||
|
state.getBlackboard(),
|
||||||
|
state.getRootNodeId()
|
||||||
|
);
|
||||||
|
|
||||||
|
state.setTree(newTree);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
|
||||||
|
// 强制刷新画布
|
||||||
|
state.triggerForceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribeFileOpened();
|
||||||
|
unsubscribePropertyChanged();
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to subscribe to file-opened event:', error);
|
logger.error('Failed to subscribe to events:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { IInspectorProvider, InspectorContext, MessageHub } from '@esengine/editor-core';
|
import { IInspectorProvider, InspectorContext, MessageHub, FieldEditorRegistry, FieldEditorContext } from '@esengine/editor-core';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { Node as BehaviorTreeNode } from '../domain/models/Node';
|
import { Node as BehaviorTreeNode } from '../domain/models/Node';
|
||||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
@@ -18,6 +19,49 @@ const PropertyEditor: React.FC<PropertyEditorProps> = ({ property, value, onChan
|
|||||||
}, [property.name, onChange]);
|
}, [property.name, onChange]);
|
||||||
|
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
|
// 特殊处理 treeAssetId 字段使用 asset 编辑器
|
||||||
|
if (property.name === 'treeAssetId') {
|
||||||
|
const fieldRegistry = Core.services.resolve(FieldEditorRegistry);
|
||||||
|
const assetEditor = fieldRegistry.getEditor('asset');
|
||||||
|
|
||||||
|
if (assetEditor) {
|
||||||
|
const context: FieldEditorContext = {
|
||||||
|
readonly: false,
|
||||||
|
metadata: {
|
||||||
|
fileExtension: '.btree',
|
||||||
|
placeholder: '拖拽或选择行为树文件'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return assetEditor.render({
|
||||||
|
label: '',
|
||||||
|
value: value ?? property.defaultValue ?? null,
|
||||||
|
onChange: handleChange,
|
||||||
|
context
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有特定的字段编辑器类型
|
||||||
|
if (property.fieldEditor) {
|
||||||
|
const fieldRegistry = Core.services.resolve(FieldEditorRegistry);
|
||||||
|
const editor = fieldRegistry.getEditor(property.fieldEditor.type);
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
const context: FieldEditorContext = {
|
||||||
|
readonly: false,
|
||||||
|
metadata: property.fieldEditor.options
|
||||||
|
};
|
||||||
|
|
||||||
|
return editor.render({
|
||||||
|
label: '',
|
||||||
|
value: value ?? property.defaultValue,
|
||||||
|
onChange: handleChange,
|
||||||
|
context
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (property.type) {
|
switch (property.type) {
|
||||||
case 'number':
|
case 'number':
|
||||||
return (
|
return (
|
||||||
|
|||||||
248
packages/behavior-tree/src/Blackboard/BlackboardTypes.ts
Normal file
248
packages/behavior-tree/src/Blackboard/BlackboardTypes.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
export enum BlackboardValueType {
|
||||||
|
// 基础类型
|
||||||
|
String = 'string',
|
||||||
|
Number = 'number',
|
||||||
|
Boolean = 'boolean',
|
||||||
|
|
||||||
|
// 数学类型
|
||||||
|
Vector2 = 'vector2',
|
||||||
|
Vector3 = 'vector3',
|
||||||
|
Vector4 = 'vector4',
|
||||||
|
Quaternion = 'quaternion',
|
||||||
|
Color = 'color',
|
||||||
|
|
||||||
|
// 引用类型
|
||||||
|
GameObject = 'gameObject',
|
||||||
|
Transform = 'transform',
|
||||||
|
Component = 'component',
|
||||||
|
AssetReference = 'assetReference',
|
||||||
|
|
||||||
|
// 集合类型
|
||||||
|
Array = 'array',
|
||||||
|
Map = 'map',
|
||||||
|
|
||||||
|
// 高级类型
|
||||||
|
Enum = 'enum',
|
||||||
|
Struct = 'struct',
|
||||||
|
Function = 'function',
|
||||||
|
|
||||||
|
// 游戏特定类型
|
||||||
|
EntityId = 'entityId',
|
||||||
|
NodePath = 'nodePath',
|
||||||
|
ResourcePath = 'resourcePath',
|
||||||
|
AnimationState = 'animationState',
|
||||||
|
AudioClip = 'audioClip',
|
||||||
|
Material = 'material',
|
||||||
|
Texture = 'texture'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vector2 {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vector3 extends Vector2 {
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vector4 extends Vector3 {
|
||||||
|
w: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quaternion {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
w: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Color {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
a: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlackboardTypeDefinition {
|
||||||
|
type: BlackboardValueType;
|
||||||
|
displayName: string;
|
||||||
|
category: 'basic' | 'math' | 'reference' | 'collection' | 'advanced' | 'game';
|
||||||
|
defaultValue: any;
|
||||||
|
editorComponent?: string; // 自定义编辑器组件
|
||||||
|
validator?: (value: any) => boolean;
|
||||||
|
converter?: (value: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlackboardTypes: Record<BlackboardValueType, BlackboardTypeDefinition> = {
|
||||||
|
[BlackboardValueType.String]: {
|
||||||
|
type: BlackboardValueType.String,
|
||||||
|
displayName: '字符串',
|
||||||
|
category: 'basic',
|
||||||
|
defaultValue: '',
|
||||||
|
validator: (v) => typeof v === 'string'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Number]: {
|
||||||
|
type: BlackboardValueType.Number,
|
||||||
|
displayName: '数字',
|
||||||
|
category: 'basic',
|
||||||
|
defaultValue: 0,
|
||||||
|
validator: (v) => typeof v === 'number'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Boolean]: {
|
||||||
|
type: BlackboardValueType.Boolean,
|
||||||
|
displayName: '布尔值',
|
||||||
|
category: 'basic',
|
||||||
|
defaultValue: false,
|
||||||
|
validator: (v) => typeof v === 'boolean'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Vector2]: {
|
||||||
|
type: BlackboardValueType.Vector2,
|
||||||
|
displayName: '二维向量',
|
||||||
|
category: 'math',
|
||||||
|
defaultValue: { x: 0, y: 0 },
|
||||||
|
editorComponent: 'Vector2Editor',
|
||||||
|
validator: (v) => v && typeof v.x === 'number' && typeof v.y === 'number'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Vector3]: {
|
||||||
|
type: BlackboardValueType.Vector3,
|
||||||
|
displayName: '三维向量',
|
||||||
|
category: 'math',
|
||||||
|
defaultValue: { x: 0, y: 0, z: 0 },
|
||||||
|
editorComponent: 'Vector3Editor',
|
||||||
|
validator: (v) => v && typeof v.x === 'number' && typeof v.y === 'number' && typeof v.z === 'number'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Color]: {
|
||||||
|
type: BlackboardValueType.Color,
|
||||||
|
displayName: '颜色',
|
||||||
|
category: 'math',
|
||||||
|
defaultValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
editorComponent: 'ColorEditor',
|
||||||
|
validator: (v) => v && typeof v.r === 'number' && typeof v.g === 'number' && typeof v.b === 'number' && typeof v.a === 'number'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.GameObject]: {
|
||||||
|
type: BlackboardValueType.GameObject,
|
||||||
|
displayName: '游戏对象',
|
||||||
|
category: 'reference',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'GameObjectPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Transform]: {
|
||||||
|
type: BlackboardValueType.Transform,
|
||||||
|
displayName: '变换组件',
|
||||||
|
category: 'reference',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'ComponentPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.AssetReference]: {
|
||||||
|
type: BlackboardValueType.AssetReference,
|
||||||
|
displayName: '资源引用',
|
||||||
|
category: 'reference',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'AssetPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.EntityId]: {
|
||||||
|
type: BlackboardValueType.EntityId,
|
||||||
|
displayName: '实体ID',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: -1,
|
||||||
|
validator: (v) => typeof v === 'number' && v >= -1
|
||||||
|
},
|
||||||
|
[BlackboardValueType.ResourcePath]: {
|
||||||
|
type: BlackboardValueType.ResourcePath,
|
||||||
|
displayName: '资源路径',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: '',
|
||||||
|
editorComponent: 'AssetPathPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Array]: {
|
||||||
|
type: BlackboardValueType.Array,
|
||||||
|
displayName: '数组',
|
||||||
|
category: 'collection',
|
||||||
|
defaultValue: [],
|
||||||
|
editorComponent: 'ArrayEditor'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Map]: {
|
||||||
|
type: BlackboardValueType.Map,
|
||||||
|
displayName: '映射表',
|
||||||
|
category: 'collection',
|
||||||
|
defaultValue: {},
|
||||||
|
editorComponent: 'MapEditor'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Enum]: {
|
||||||
|
type: BlackboardValueType.Enum,
|
||||||
|
displayName: '枚举',
|
||||||
|
category: 'advanced',
|
||||||
|
defaultValue: '',
|
||||||
|
editorComponent: 'EnumPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.AnimationState]: {
|
||||||
|
type: BlackboardValueType.AnimationState,
|
||||||
|
displayName: '动画状态',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: '',
|
||||||
|
editorComponent: 'AnimationStatePicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.AudioClip]: {
|
||||||
|
type: BlackboardValueType.AudioClip,
|
||||||
|
displayName: '音频片段',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'AudioClipPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Material]: {
|
||||||
|
type: BlackboardValueType.Material,
|
||||||
|
displayName: '材质',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'MaterialPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Texture]: {
|
||||||
|
type: BlackboardValueType.Texture,
|
||||||
|
displayName: '纹理',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'TexturePicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Vector4]: {
|
||||||
|
type: BlackboardValueType.Vector4,
|
||||||
|
displayName: '四维向量',
|
||||||
|
category: 'math',
|
||||||
|
defaultValue: { x: 0, y: 0, z: 0, w: 0 },
|
||||||
|
editorComponent: 'Vector4Editor'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Quaternion]: {
|
||||||
|
type: BlackboardValueType.Quaternion,
|
||||||
|
displayName: '四元数',
|
||||||
|
category: 'math',
|
||||||
|
defaultValue: { x: 0, y: 0, z: 0, w: 1 },
|
||||||
|
editorComponent: 'QuaternionEditor'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Component]: {
|
||||||
|
type: BlackboardValueType.Component,
|
||||||
|
displayName: '组件',
|
||||||
|
category: 'reference',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'ComponentPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Struct]: {
|
||||||
|
type: BlackboardValueType.Struct,
|
||||||
|
displayName: '结构体',
|
||||||
|
category: 'advanced',
|
||||||
|
defaultValue: {},
|
||||||
|
editorComponent: 'StructEditor'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.Function]: {
|
||||||
|
type: BlackboardValueType.Function,
|
||||||
|
displayName: '函数',
|
||||||
|
category: 'advanced',
|
||||||
|
defaultValue: null,
|
||||||
|
editorComponent: 'FunctionPicker'
|
||||||
|
},
|
||||||
|
[BlackboardValueType.NodePath]: {
|
||||||
|
type: BlackboardValueType.NodePath,
|
||||||
|
displayName: '节点路径',
|
||||||
|
category: 'game',
|
||||||
|
defaultValue: '',
|
||||||
|
editorComponent: 'NodePathPicker'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -65,6 +65,24 @@ export interface PropertyDefinition {
|
|||||||
step?: number;
|
step?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段编辑器配置
|
||||||
|
*
|
||||||
|
* 指定使用哪个字段编辑器以及相关选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* fieldEditor: {
|
||||||
|
* type: 'asset',
|
||||||
|
* options: { fileExtension: '.btree' }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fieldEditor?: {
|
||||||
|
type: string;
|
||||||
|
options?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自定义渲染配置
|
* 自定义渲染配置
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
FileActionRegistry,
|
FileActionRegistry,
|
||||||
EditorPluginManager,
|
EditorPluginManager,
|
||||||
InspectorRegistry,
|
InspectorRegistry,
|
||||||
PropertyRendererRegistry
|
PropertyRendererRegistry,
|
||||||
|
FieldEditorRegistry
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||||
import { DIContainer } from '../../core/di/DIContainer';
|
import { DIContainer } from '../../core/di/DIContainer';
|
||||||
@@ -37,6 +38,13 @@ import {
|
|||||||
ArrayRenderer,
|
ArrayRenderer,
|
||||||
FallbackRenderer
|
FallbackRenderer
|
||||||
} from '../../infrastructure/property-renderers';
|
} from '../../infrastructure/property-renderers';
|
||||||
|
import {
|
||||||
|
AssetFieldEditor,
|
||||||
|
Vector2FieldEditor,
|
||||||
|
Vector3FieldEditor,
|
||||||
|
Vector4FieldEditor,
|
||||||
|
ColorFieldEditor
|
||||||
|
} from '../../infrastructure/field-editors';
|
||||||
|
|
||||||
export interface EditorServices {
|
export interface EditorServices {
|
||||||
uiRegistry: UIRegistry;
|
uiRegistry: UIRegistry;
|
||||||
@@ -61,6 +69,7 @@ export interface EditorServices {
|
|||||||
notification: NotificationService;
|
notification: NotificationService;
|
||||||
inspectorRegistry: InspectorRegistry;
|
inspectorRegistry: InspectorRegistry;
|
||||||
propertyRendererRegistry: PropertyRendererRegistry;
|
propertyRendererRegistry: PropertyRendererRegistry;
|
||||||
|
fieldEditorRegistry: FieldEditorRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServiceRegistry {
|
export class ServiceRegistry {
|
||||||
@@ -123,6 +132,15 @@ export class ServiceRegistry {
|
|||||||
propertyRendererRegistry.register(new ArrayRenderer());
|
propertyRendererRegistry.register(new ArrayRenderer());
|
||||||
propertyRendererRegistry.register(new FallbackRenderer());
|
propertyRendererRegistry.register(new FallbackRenderer());
|
||||||
|
|
||||||
|
const fieldEditorRegistry = new FieldEditorRegistry();
|
||||||
|
Core.services.registerInstance(FieldEditorRegistry, fieldEditorRegistry);
|
||||||
|
|
||||||
|
fieldEditorRegistry.register(new AssetFieldEditor());
|
||||||
|
fieldEditorRegistry.register(new Vector2FieldEditor());
|
||||||
|
fieldEditorRegistry.register(new Vector3FieldEditor());
|
||||||
|
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||||
|
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiRegistry,
|
uiRegistry,
|
||||||
messageHub,
|
messageHub,
|
||||||
@@ -145,7 +163,8 @@ export class ServiceRegistry {
|
|||||||
dialog,
|
dialog,
|
||||||
notification,
|
notification,
|
||||||
inspectorRegistry,
|
inspectorRegistry,
|
||||||
propertyRendererRegistry
|
propertyRendererRegistry,
|
||||||
|
fieldEditorRegistry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -674,6 +674,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
|||||||
onClick={() => handleAssetClick(asset)}
|
onClick={() => handleAssetClick(asset)}
|
||||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, 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'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getFileIcon(asset)}
|
{getFileIcon(asset)}
|
||||||
<div className="asset-info">
|
<div className="asset-info">
|
||||||
|
|||||||
@@ -630,10 +630,29 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
|
|||||||
<div key={node.path}>
|
<div key={node.path}>
|
||||||
<div
|
<div
|
||||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||||
style={{ paddingLeft: `${indent}px` }}
|
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
|
||||||
onClick={() => !isRenaming && handleNodeClick(node)}
|
onClick={() => !isRenaming && handleNodeClick(node)}
|
||||||
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, node)}
|
onContextMenu={(e) => handleContextMenu(e, node)}
|
||||||
|
draggable={node.type === 'file' && !isRenaming}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (node.type === 'file' && !isRenaming) {
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
// 设置拖拽的数据
|
||||||
|
e.dataTransfer.setData('asset-path', node.path);
|
||||||
|
e.dataTransfer.setData('asset-name', node.name);
|
||||||
|
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
|
||||||
|
e.dataTransfer.setData('asset-extension', ext || '');
|
||||||
|
e.dataTransfer.setData('text/plain', node.path);
|
||||||
|
|
||||||
|
// 添加视觉反馈
|
||||||
|
e.currentTarget.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
// 恢复透明度
|
||||||
|
e.currentTarget.style.opacity = '1';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="tree-arrow">
|
<span className="tree-arrow">
|
||||||
{node.type === 'folder' ? (
|
{node.type === 'folder' ? (
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/* 虚幻引擎风格的资产选择框 */
|
||||||
|
.asset-field {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__container.hovered {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__container.dragging {
|
||||||
|
border-color: #4ade80;
|
||||||
|
background: #1a2a1a;
|
||||||
|
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资产图标区域 */
|
||||||
|
.asset-field__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
background: #262626;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__container.hovered .asset-field__icon {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资产输入区域 */
|
||||||
|
.asset-field__input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__input:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__input.empty .asset-field__value {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮组 */
|
||||||
|
.asset-field__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__button:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__button:active {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 清除按钮特殊样式 */
|
||||||
|
.asset-field__button--clear:hover {
|
||||||
|
background: #4a2020;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁用状态 */
|
||||||
|
.asset-field__container[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉菜单样式(如果需要) */
|
||||||
|
.asset-field__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__dropdown-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__dropdown-item:hover {
|
||||||
|
background: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__dropdown-item-icon {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
@keyframes highlight {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__container.dragging {
|
||||||
|
animation: highlight 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.asset-field__button {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-field__icon {
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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 './AssetField.css';
|
||||||
|
|
||||||
|
interface AssetFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
fileExtension?: string; // 例如: '.btree'
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
onNavigate?: (path: string) => void; // 导航到资产
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
fileExtension = '',
|
||||||
|
placeholder = 'None',
|
||||||
|
readonly = false,
|
||||||
|
onNavigate
|
||||||
|
}: AssetFieldProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!readonly) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}, [readonly]);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
// 处理从文件系统拖入的文件
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
const file = files.find(f =>
|
||||||
|
!fileExtension || f.name.endsWith(fileExtension)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
// Web File API 没有 path 属性,使用 name
|
||||||
|
onChange(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理从资产面板拖入的文件路径
|
||||||
|
const assetPath = e.dataTransfer.getData('asset-path');
|
||||||
|
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
|
||||||
|
onChange(assetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容纯文本拖拽
|
||||||
|
const text = e.dataTransfer.getData('text/plain');
|
||||||
|
if (text && (!fileExtension || text.endsWith(fileExtension))) {
|
||||||
|
onChange(text);
|
||||||
|
}
|
||||||
|
}, [onChange, fileExtension, readonly]);
|
||||||
|
|
||||||
|
const handleBrowse = useCallback(async () => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
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 handleClear = useCallback(() => {
|
||||||
|
if (!readonly) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
}, [onChange, readonly]);
|
||||||
|
|
||||||
|
const getFileName = (path: string) => {
|
||||||
|
const parts = path.split(/[\\/]/);
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="asset-field">
|
||||||
|
<label className="asset-field__label">{label}</label>
|
||||||
|
<div
|
||||||
|
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{/* 资产图标 */}
|
||||||
|
<div className="asset-field__icon">
|
||||||
|
{value ? (
|
||||||
|
fileExtension === '.btree' ?
|
||||||
|
<FileText size={14} /> :
|
||||||
|
<Package size={14} />
|
||||||
|
) : (
|
||||||
|
<Package size={14} style={{ opacity: 0.5 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资产选择框 */}
|
||||||
|
<div
|
||||||
|
ref={inputRef}
|
||||||
|
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={!readonly ? handleBrowse : undefined}
|
||||||
|
title={value || placeholder}
|
||||||
|
>
|
||||||
|
<span className="asset-field__value">
|
||||||
|
{value ? getFileName(value) : placeholder}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮组 */}
|
||||||
|
<div className="asset-field__actions">
|
||||||
|
{/* 浏览按钮 */}
|
||||||
|
{!readonly && (
|
||||||
|
<button
|
||||||
|
className="asset-field__button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleBrowse();
|
||||||
|
}}
|
||||||
|
title="浏览..."
|
||||||
|
>
|
||||||
|
<Search size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 导航按钮 */}
|
||||||
|
{value && onNavigate && (
|
||||||
|
<button
|
||||||
|
className="asset-field__button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNavigate(value);
|
||||||
|
}}
|
||||||
|
title="在资产浏览器中显示"
|
||||||
|
>
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 清除按钮 */}
|
||||||
|
{value && !readonly && (
|
||||||
|
<button
|
||||||
|
className="asset-field__button asset-field__button--clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClear();
|
||||||
|
}}
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||||
|
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||||
|
|
||||||
|
export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||||
|
readonly type = 'asset';
|
||||||
|
readonly name = 'Asset Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'asset' || fieldType === 'assetReference' || fieldType === 'resourcePath';
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<string | null>): React.ReactElement {
|
||||||
|
const fileExtension = context.metadata?.fileExtension || '';
|
||||||
|
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssetField
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
fileExtension={fileExtension}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||||
|
|
||||||
|
interface Color {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
a: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbaToHex(color: Color): string {
|
||||||
|
const toHex = (c: number) => Math.round(c * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string): Color {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
if (result && result[1] && result[2] && result[3]) {
|
||||||
|
return {
|
||||||
|
r: parseInt(result[1], 16) / 255,
|
||||||
|
g: parseInt(result[2], 16) / 255,
|
||||||
|
b: parseInt(result[3], 16) / 255,
|
||||||
|
a: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { r: 0, g: 0, b: 0, a: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ColorFieldEditor implements IFieldEditor<Color> {
|
||||||
|
readonly type = 'color';
|
||||||
|
readonly name = 'Color Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'color' || fieldType === 'rgba' || fieldType === 'rgb';
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<Color>): React.ReactElement {
|
||||||
|
const color = value || { r: 1, g: 1, b: 1, a: 1 };
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowPicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const hexColor = rgbaToHex(color);
|
||||||
|
const rgbDisplay = `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${color.a.toFixed(2)})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="property-field" style={{ position: 'relative' }}>
|
||||||
|
<label className="property-label">{label}</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => !context.readonly && setShowPicker(!showPicker)}
|
||||||
|
disabled={context.readonly}
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: hexColor,
|
||||||
|
border: '2px solid #444',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: context.readonly ? 'default' : 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{color.a < 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: 'repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%)',
|
||||||
|
backgroundPosition: '0 0, 8px 8px',
|
||||||
|
backgroundSize: '16px 16px',
|
||||||
|
opacity: 1 - color.a
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ fontSize: '11px', color: '#888', fontFamily: 'monospace' }}>
|
||||||
|
{rgbDisplay}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div
|
||||||
|
ref={pickerRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '8px',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<label style={{ fontSize: '10px', color: '#888' }}>Hex: </label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hexColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColor = hexToRgba(e.target.value);
|
||||||
|
onChange({ ...newColor, a: color.a });
|
||||||
|
}}
|
||||||
|
style={{ marginLeft: '4px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Math.round(color.r * 255)}
|
||||||
|
onChange={(e) => onChange({ ...color, r: (parseInt(e.target.value) || 0) / 255 })}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '2px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Math.round(color.g * 255)}
|
||||||
|
onChange={(e) => onChange({ ...color, g: (parseInt(e.target.value) || 0) / 255 })}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '2px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Math.round(color.b * 255)}
|
||||||
|
onChange={(e) => onChange({ ...color, b: (parseInt(e.target.value) || 0) / 255 })}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '2px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<label style={{ fontSize: '10px', color: '#888' }}>Alpha:</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={color.a}
|
||||||
|
onChange={(e) => onChange({ ...color, a: parseFloat(e.target.value) })}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '10px', color: '#888', minWidth: '30px' }}>
|
||||||
|
{(color.a * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||||
|
|
||||||
|
interface Vector2 { x: number; y: number; }
|
||||||
|
interface Vector3 extends Vector2 { z: number; }
|
||||||
|
interface Vector4 extends Vector3 { w: number; }
|
||||||
|
|
||||||
|
const VectorInput: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
}> = ({ label, value, onChange, readonly }) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{ color: '#888', fontSize: '10px', minWidth: '12px' }}>{label}:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={readonly}
|
||||||
|
step={0.1}
|
||||||
|
style={{
|
||||||
|
width: '60px',
|
||||||
|
padding: '2px 4px',
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
|
||||||
|
readonly type = 'vector2';
|
||||||
|
readonly name = 'Vector2 Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'vector2' || fieldType === 'vec2';
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<Vector2>): React.ReactElement {
|
||||||
|
const v = value || { x: 0, y: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{label}</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<VectorInput
|
||||||
|
label="X"
|
||||||
|
value={v.x}
|
||||||
|
onChange={(x) => onChange({ ...v, x })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="Y"
|
||||||
|
value={v.y}
|
||||||
|
onChange={(y) => onChange({ ...v, y })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vector3FieldEditor implements IFieldEditor<Vector3> {
|
||||||
|
readonly type = 'vector3';
|
||||||
|
readonly name = 'Vector3 Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'vector3' || fieldType === 'vec3';
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<Vector3>): React.ReactElement {
|
||||||
|
const v = value || { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{label}</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<VectorInput
|
||||||
|
label="X"
|
||||||
|
value={v.x}
|
||||||
|
onChange={(x) => onChange({ ...v, x })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="Y"
|
||||||
|
value={v.y}
|
||||||
|
onChange={(y) => onChange({ ...v, y })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="Z"
|
||||||
|
value={v.z}
|
||||||
|
onChange={(z) => onChange({ ...v, z })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||||
|
readonly type = 'vector4';
|
||||||
|
readonly name = 'Vector4 Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'vector4' || fieldType === 'vec4' || fieldType === 'quaternion';
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
||||||
|
const v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{label}</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<VectorInput
|
||||||
|
label="X"
|
||||||
|
value={v.x}
|
||||||
|
onChange={(x) => onChange({ ...v, x })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="Y"
|
||||||
|
value={v.y}
|
||||||
|
onChange={(y) => onChange({ ...v, y })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="Z"
|
||||||
|
value={v.z}
|
||||||
|
onChange={(z) => onChange({ ...v, z })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
<VectorInput
|
||||||
|
label="W"
|
||||||
|
value={v.w}
|
||||||
|
onChange={(w) => onChange({ ...v, w })}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './AssetFieldEditor';
|
||||||
|
export * from './VectorFieldEditors';
|
||||||
|
export * from './ColorFieldEditor';
|
||||||
@@ -252,23 +252,54 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
transition: background-color 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-item[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-item:hover {
|
.asset-item:hover {
|
||||||
background: #2a2d2e;
|
background: #2a2d2e;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-item.selected {
|
.asset-item.selected {
|
||||||
background: #094771;
|
background: #094771;
|
||||||
|
box-shadow: 0 0 0 1px rgba(14, 108, 170, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-item.selected:hover {
|
.asset-item.selected:hover {
|
||||||
background: #0e6caa;
|
background: #0e6caa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 拖拽中的样式 */
|
||||||
|
.asset-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽时的指示器 */
|
||||||
|
.asset-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
border: 2px dashed transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-item[draggable="true"]:hover::before {
|
||||||
|
border-color: rgba(74, 222, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.asset-icon {
|
.asset-icon {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
|||||||
@@ -70,7 +70,16 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.1s ease;
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node:hover {
|
.tree-node:hover {
|
||||||
@@ -81,6 +90,16 @@
|
|||||||
background: #37373d;
|
background: #37373d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 拖拽时的样式 */
|
||||||
|
.tree-node.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node[draggable="true"]:hover {
|
||||||
|
background: #2a2d2e;
|
||||||
|
box-shadow: inset 2px 0 0 rgba(74, 222, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.tree-arrow {
|
.tree-arrow {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|||||||
50
packages/editor-core/src/Services/FieldEditorRegistry.ts
Normal file
50
packages/editor-core/src/Services/FieldEditorRegistry.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { IService, createLogger } from '@esengine/ecs-framework';
|
||||||
|
import { IFieldEditor, IFieldEditorRegistry, FieldEditorContext } from './IFieldEditor';
|
||||||
|
|
||||||
|
const logger = createLogger('FieldEditorRegistry');
|
||||||
|
|
||||||
|
export class FieldEditorRegistry implements IFieldEditorRegistry, IService {
|
||||||
|
private editors: Map<string, IFieldEditor> = new Map();
|
||||||
|
|
||||||
|
register(editor: IFieldEditor): void {
|
||||||
|
if (this.editors.has(editor.type)) {
|
||||||
|
logger.warn(`Overwriting existing field editor: ${editor.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editors.set(editor.type, editor);
|
||||||
|
logger.debug(`Registered field editor: ${editor.name} (${editor.type})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(type: string): void {
|
||||||
|
if (this.editors.delete(type)) {
|
||||||
|
logger.debug(`Unregistered field editor: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEditor(type: string, context?: FieldEditorContext): IFieldEditor | undefined {
|
||||||
|
const editor = this.editors.get(type);
|
||||||
|
if (editor) {
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editors = Array.from(this.editors.values())
|
||||||
|
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
|
||||||
|
for (const editor of editors) {
|
||||||
|
if (editor.canHandle(type, context)) {
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEditors(): IFieldEditor[] {
|
||||||
|
return Array.from(this.editors.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.editors.clear();
|
||||||
|
logger.debug('FieldEditorRegistry disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/editor-core/src/Services/IFieldEditor.ts
Normal file
45
packages/editor-core/src/Services/IFieldEditor.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export interface FieldEditorContext {
|
||||||
|
readonly?: boolean;
|
||||||
|
projectPath?: string;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldEditorProps<T = any> {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
context: FieldEditorContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFieldEditor<T = any> {
|
||||||
|
readonly type: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly priority?: number;
|
||||||
|
|
||||||
|
canHandle(fieldType: string, context?: FieldEditorContext): boolean;
|
||||||
|
render(props: FieldEditorProps<T>): ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFieldEditorRegistry {
|
||||||
|
register(editor: IFieldEditor): void;
|
||||||
|
unregister(type: string): void;
|
||||||
|
getEditor(type: string, context?: FieldEditorContext): IFieldEditor | undefined;
|
||||||
|
getAllEditors(): IFieldEditor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldMetadata {
|
||||||
|
type: string;
|
||||||
|
options?: {
|
||||||
|
fileExtension?: string;
|
||||||
|
enumValues?: Array<{ value: string; label: string }>;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
language?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ export * from './Services/IInspectorProvider';
|
|||||||
export * from './Services/InspectorRegistry';
|
export * from './Services/InspectorRegistry';
|
||||||
export * from './Services/IPropertyRenderer';
|
export * from './Services/IPropertyRenderer';
|
||||||
export * from './Services/PropertyRendererRegistry';
|
export * from './Services/PropertyRendererRegistry';
|
||||||
|
export * from './Services/IFieldEditor';
|
||||||
|
export * from './Services/FieldEditorRegistry';
|
||||||
|
|
||||||
export * from './Module/IEventBus';
|
export * from './Module/IEventBus';
|
||||||
export * from './Module/ICommandRegistry';
|
export * from './Module/ICommandRegistry';
|
||||||
|
|||||||
Reference in New Issue
Block a user