feat: UI输入框IME支持和编辑器Inspector重构 (#310)
UI系统改进: - 添加 IMEHelper 支持中文/日文/韩文输入法 - UIInputFieldComponent 添加组合输入状态管理 - UIInputSystem 添加 IME 事件处理 - UIInputFieldRenderSystem 优化渲染逻辑 - UIRenderCollector 增强纹理处理 引擎改进: - EngineBridge 添加新的渲染接口 - EngineRenderSystem 优化渲染流程 - Rust 引擎添加新的渲染功能 编辑器改进: - 新增模块化 Inspector 组件架构 - EntityRefField 增强实体引用选择 - 优化 FlexLayoutDock 和 SceneHierarchy 样式 - 添加国际化文本
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* ComponentPropertyEditor - 组件属性编辑器
|
||||
* ComponentPropertyEditor - Component property editor
|
||||
*
|
||||
* 使用新控件渲染组件属性
|
||||
* Renders component properties using new controls
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Component, Core, Entity, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, MessageHub, PrefabService, FileActionRegistry, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { Lock } from 'lucide-react';
|
||||
import {
|
||||
PropertyRow,
|
||||
NumberInput,
|
||||
StringInput,
|
||||
BooleanInput,
|
||||
VectorInput,
|
||||
EnumInput,
|
||||
ColorInput,
|
||||
AssetInput,
|
||||
EntityRefInput,
|
||||
ArrayInput
|
||||
} from './controls';
|
||||
|
||||
// ==================== 类型定义 | Type Definitions ====================
|
||||
|
||||
interface PropertyMetadata {
|
||||
type: string;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{ label: string; value: string | number } | string | number>;
|
||||
controls?: Array<{ component: string; property: string }>;
|
||||
category?: string;
|
||||
assetType?: string;
|
||||
extensions?: string[];
|
||||
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
reorderable?: boolean;
|
||||
actions?: Array<{ id: string; label: string; icon?: string; tooltip?: string }>;
|
||||
}
|
||||
|
||||
export interface ComponentPropertyEditorProps {
|
||||
/** 组件实例 | Component instance */
|
||||
component: Component;
|
||||
/** 所属实体 | Owner entity */
|
||||
entity?: Entity;
|
||||
/** 版本号 | Version number */
|
||||
version?: number;
|
||||
/** 属性变更回调 | Property change callback */
|
||||
onChange?: (propertyName: string, value: any) => void;
|
||||
/** 动作回调 | Action callback */
|
||||
onAction?: (actionId: string, propertyName: string, component: Component) => void;
|
||||
}
|
||||
|
||||
// ==================== 主组件 | Main Component ====================
|
||||
|
||||
export const ComponentPropertyEditor: React.FC<ComponentPropertyEditorProps> = ({
|
||||
component,
|
||||
entity,
|
||||
version,
|
||||
onChange,
|
||||
onAction
|
||||
}) => {
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
|
||||
|
||||
// 服务 | Services
|
||||
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
|
||||
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
|
||||
|
||||
// 预制体实例组件 | Prefab instance component
|
||||
const prefabInstanceComp = useMemo(() => {
|
||||
return entity?.getComponent(PrefabInstanceComponent) ?? null;
|
||||
}, [entity, version]);
|
||||
|
||||
// 检查属性是否被覆盖 | Check if property is overridden
|
||||
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
|
||||
if (!prefabInstanceComp) return false;
|
||||
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
|
||||
}, [prefabInstanceComp, componentTypeName]);
|
||||
|
||||
// 加载属性元数据 | Load property metadata
|
||||
useEffect(() => {
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const metadata = propertyMetadataService.getEditableProperties(component);
|
||||
setProperties(metadata as Record<string, PropertyMetadata>);
|
||||
}, [component]);
|
||||
|
||||
// 扫描控制字段 | Scan controlled fields
|
||||
useEffect(() => {
|
||||
if (!entity) return;
|
||||
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const controlled = new Map<string, string>();
|
||||
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent) as Record<string, PropertyMetadata>;
|
||||
const otherComponentName = getComponentInstanceTypeName(otherComponent);
|
||||
|
||||
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]);
|
||||
|
||||
// 关闭右键菜单 | Close context menu
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// 获取属性值 | Get property value
|
||||
const getValue = useCallback((propertyName: string) => {
|
||||
return (component as any)[propertyName];
|
||||
}, [component, version]);
|
||||
|
||||
// 处理属性变更 | Handle property change
|
||||
const handleChange = useCallback((propertyName: string, value: any) => {
|
||||
(component as any)[propertyName] = value;
|
||||
|
||||
if (onChange) {
|
||||
onChange(propertyName, value);
|
||||
}
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('scene:modified', {});
|
||||
}
|
||||
}, [component, onChange]);
|
||||
|
||||
// 处理动作 | Handle action
|
||||
const handleAction = useCallback((actionId: string, propertyName: string) => {
|
||||
if (onAction) {
|
||||
onAction(actionId, propertyName, component);
|
||||
}
|
||||
}, [onAction, component]);
|
||||
|
||||
// 还原属性 | Revert property
|
||||
const handleRevertProperty = useCallback(async () => {
|
||||
if (!contextMenu || !prefabService || !entity) return;
|
||||
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, prefabService, entity, componentTypeName]);
|
||||
|
||||
// 处理右键菜单 | Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
|
||||
if (!isPropertyOverridden(propertyName)) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
|
||||
}, [isPropertyOverridden]);
|
||||
|
||||
// 获取控制者 | Get controlled by
|
||||
const getControlledBy = (propertyName: string): string | undefined => {
|
||||
return controlledFields.get(propertyName);
|
||||
};
|
||||
|
||||
// ==================== 渲染属性 | Render Property ====================
|
||||
|
||||
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
|
||||
const value = getValue(propertyName);
|
||||
const label = metadata.label || propertyName;
|
||||
const readonly = metadata.readOnly || !!getControlledBy(propertyName);
|
||||
const controlledBy = getControlledBy(propertyName);
|
||||
|
||||
// 标签后缀(如果被控制)| Label suffix (if controlled)
|
||||
const labelElement = controlledBy ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{label}
|
||||
<span title={`Controlled by ${controlledBy}`}>
|
||||
<Lock size={10} style={{ color: 'var(--inspector-text-secondary)' }} />
|
||||
</span>
|
||||
</span>
|
||||
) : label;
|
||||
const labelTitle = label;
|
||||
|
||||
switch (metadata.type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle} draggable>
|
||||
<NumberInput
|
||||
value={value ?? 0}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
min={metadata.min}
|
||||
max={metadata.max}
|
||||
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
|
||||
integer={metadata.type === 'integer'}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<StringInput
|
||||
value={value ?? ''}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
placeholder={metadata.placeholder}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<BooleanInput
|
||||
value={value ?? false}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'color': {
|
||||
let colorValue = value ?? '#ffffff';
|
||||
const wasNumber = typeof colorValue === 'number';
|
||||
if (wasNumber) {
|
||||
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
|
||||
}
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<ColorInput
|
||||
value={colorValue}
|
||||
onChange={(v) => {
|
||||
if (wasNumber && typeof v === 'string') {
|
||||
handleChange(propertyName, parseInt(v.slice(1), 16));
|
||||
} else {
|
||||
handleChange(propertyName, v);
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'vector2':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
dimensions={2}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'vector3':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
dimensions={3}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'enum': {
|
||||
const options = (metadata.options || []).map(opt =>
|
||||
typeof opt === 'object' ? opt : { label: String(opt), value: opt }
|
||||
);
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<EnumInput
|
||||
value={value}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
options={options}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'asset': {
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
|
||||
const getCreationMapping = () => {
|
||||
if (!fileActionRegistry || !metadata.extensions) return null;
|
||||
for (const ext of metadata.extensions) {
|
||||
const mapping = (fileActionRegistry as any).getAssetCreationMapping?.(ext);
|
||||
if (mapping) return mapping;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const creationMapping = getCreationMapping();
|
||||
|
||||
// 解析资产值 | Resolve asset value
|
||||
// 检查值是否为 GUID(UUID 格式)并尝试解析为路径
|
||||
// Check if value is a GUID (UUID format) and try to resolve to path
|
||||
const resolveAssetValue = () => {
|
||||
if (!value) return null;
|
||||
const strValue = String(value);
|
||||
|
||||
// GUID 格式检查:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// UUID format check
|
||||
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strValue);
|
||||
|
||||
if (isGuid) {
|
||||
// 尝试从 AssetRegistryService 获取路径
|
||||
// Try to get path from AssetRegistryService
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (assetRegistry) {
|
||||
const assetMeta = assetRegistry.getAsset(strValue);
|
||||
if (assetMeta) {
|
||||
return {
|
||||
id: strValue,
|
||||
path: assetMeta.path,
|
||||
type: assetMeta.type
|
||||
};
|
||||
}
|
||||
}
|
||||
// 如果无法解析,仍然显示 GUID
|
||||
// If cannot resolve, still show GUID
|
||||
return { id: strValue, path: strValue };
|
||||
}
|
||||
|
||||
// 不是 GUID,假设是路径
|
||||
// Not a GUID, assume it's a path
|
||||
return { id: strValue, path: strValue };
|
||||
};
|
||||
|
||||
const assetValue = resolveAssetValue();
|
||||
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<AssetInput
|
||||
value={assetValue}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
handleChange(propertyName, '');
|
||||
} else if (typeof v === 'string') {
|
||||
handleChange(propertyName, v);
|
||||
} else {
|
||||
// 存储路径而不是 GUID
|
||||
// Store path instead of GUID
|
||||
handleChange(propertyName, v.path || v.id || '');
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
extensions={metadata.extensions}
|
||||
onPickAsset={() => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:pick', {
|
||||
extensions: metadata.extensions,
|
||||
onSelect: (path: string) => handleChange(propertyName, path)
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenAsset={(asset) => {
|
||||
if (asset.path) handleNavigate(asset.path);
|
||||
}}
|
||||
onLocateAsset={(asset) => {
|
||||
if (asset.path) handleNavigate(asset.path);
|
||||
}}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'entityRef':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<EntityRefInput
|
||||
value={value ?? null}
|
||||
onChange={(v) => {
|
||||
const id = typeof v === 'object' && v !== null ? v.id : v;
|
||||
handleChange(propertyName, id);
|
||||
}}
|
||||
readonly={readonly}
|
||||
resolveEntityName={(id) => {
|
||||
if (!entity) return undefined;
|
||||
const scene = entity.scene;
|
||||
if (!scene) return undefined;
|
||||
const targetEntity = (scene as any).getEntityById?.(Number(id));
|
||||
return targetEntity?.name;
|
||||
}}
|
||||
onLocateEntity={(id) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('hierarchy:select', { entityId: Number(id) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'array': {
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<ArrayInput
|
||||
value={value ?? []}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
minItems={metadata.minLength}
|
||||
maxItems={metadata.maxLength}
|
||||
sortable={metadata.reorderable ?? true}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 渲染 | Render ====================
|
||||
|
||||
return (
|
||||
<div className="component-property-editor">
|
||||
{Object.entries(properties).map(([propertyName, metadata]) => {
|
||||
const overridden = isPropertyOverridden(propertyName);
|
||||
return (
|
||||
<div
|
||||
key={propertyName}
|
||||
className={overridden ? 'property-overridden' : ''}
|
||||
onContextMenu={(e) => handleContextMenu(e, propertyName)}
|
||||
style={overridden ? { borderLeft: '2px solid var(--inspector-accent)' } : undefined}
|
||||
>
|
||||
{renderProperty(propertyName, metadata)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
background: 'var(--inspector-bg-section)',
|
||||
border: '1px solid var(--inspector-border-light)',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleRevertProperty}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--inspector-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--inspector-bg-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<span>↩</span>
|
||||
<span>Revert to Prefab</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* EntityInspectorPanel - 实体检视器面板
|
||||
* EntityInspectorPanel - Entity inspector panel
|
||||
*
|
||||
* 使用新 Inspector 架构的实体检视器
|
||||
* Entity inspector using new Inspector architecture
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
X,
|
||||
Box,
|
||||
Search,
|
||||
Lock,
|
||||
Unlock,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Entity,
|
||||
Component,
|
||||
Core,
|
||||
getComponentDependencies,
|
||||
getComponentTypeName,
|
||||
getComponentInstanceTypeName,
|
||||
isComponentInstanceHiddenInInspector,
|
||||
PrefabInstanceComponent
|
||||
} from '@esengine/ecs-framework';
|
||||
import {
|
||||
MessageHub,
|
||||
CommandManager,
|
||||
ComponentRegistry,
|
||||
ComponentActionRegistry,
|
||||
ComponentInspectorRegistry,
|
||||
PrefabService,
|
||||
PropertyMetadataService
|
||||
} from '@esengine/editor-core';
|
||||
import { NotificationService } from '../../services/NotificationService';
|
||||
import {
|
||||
RemoveComponentCommand,
|
||||
UpdateComponentCommand,
|
||||
AddComponentCommand
|
||||
} from '../../application/commands/component';
|
||||
import { PropertySearch, CategoryTabs } from './header';
|
||||
import { PropertySection } from './sections';
|
||||
import { ComponentPropertyEditor } from './ComponentPropertyEditor';
|
||||
import { CategoryConfig } from './types';
|
||||
import './styles/inspector.css';
|
||||
|
||||
// ==================== 类型定义 | Type Definitions ====================
|
||||
|
||||
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
type?: new () => Component;
|
||||
category?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface EntityInspectorPanelProps {
|
||||
/** 目标实体 | Target entity */
|
||||
entity: Entity;
|
||||
/** 消息中心 | Message hub */
|
||||
messageHub: MessageHub;
|
||||
/** 命令管理器 | Command manager */
|
||||
commandManager: CommandManager;
|
||||
/** 组件版本号 | Component version */
|
||||
componentVersion: number;
|
||||
/** 是否锁定 | Is locked */
|
||||
isLocked?: boolean;
|
||||
/** 锁定变更回调 | Lock change callback */
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
// ==================== 常量 | Constants ====================
|
||||
|
||||
const CATEGORY_MAP: Record<string, CategoryFilter> = {
|
||||
'components.category.core': 'general',
|
||||
'components.category.rendering': 'rendering',
|
||||
'components.category.physics': 'physics',
|
||||
'components.category.audio': 'audio',
|
||||
'components.category.ui': 'rendering',
|
||||
'components.category.ui.core': 'rendering',
|
||||
'components.category.ui.widgets': 'rendering',
|
||||
'components.category.other': 'other',
|
||||
};
|
||||
|
||||
const CATEGORY_TABS: CategoryConfig[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'transform', label: 'Transform' },
|
||||
{ id: 'rendering', label: 'Rendering' },
|
||||
{ id: 'physics', label: 'Physics' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
{ id: 'other', label: 'Other' },
|
||||
{ id: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'components.category.core': '核心',
|
||||
'components.category.rendering': '渲染',
|
||||
'components.category.physics': '物理',
|
||||
'components.category.audio': '音频',
|
||||
'components.category.ui': 'UI',
|
||||
'components.category.ui.core': 'UI 核心',
|
||||
'components.category.ui.widgets': 'UI 控件',
|
||||
'components.category.other': '其他',
|
||||
};
|
||||
|
||||
// ==================== 主组件 | Main Component ====================
|
||||
|
||||
export const EntityInspectorPanel: React.FC<EntityInspectorPanelProps> = ({
|
||||
entity,
|
||||
messageHub,
|
||||
commandManager,
|
||||
componentVersion,
|
||||
isLocked = false,
|
||||
onLockChange
|
||||
}) => {
|
||||
// ==================== 状态 | State ====================
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
|
||||
// 折叠状态(持久化)| Collapsed state (persisted)
|
||||
const [collapsedComponents, setCollapsedComponents] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('inspector-collapsed-components');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件添加菜单 | Component add menu
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const [addMenuSearch, setAddMenuSearch] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ==================== 服务 | Services ====================
|
||||
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// ==================== 计算属性 | Computed Properties ====================
|
||||
|
||||
const isPrefabInstance = useMemo(() => {
|
||||
return entity.hasComponent(PrefabInstanceComponent);
|
||||
}, [entity, componentVersion]);
|
||||
|
||||
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
if (componentInfo?.category) {
|
||||
return CATEGORY_MAP[componentInfo.category] || 'general';
|
||||
}
|
||||
return 'general';
|
||||
}, [componentRegistry]);
|
||||
|
||||
// 计算当前实体拥有的分类 | Compute categories present in current entity
|
||||
const availableCategories = useMemo((): CategoryConfig[] => {
|
||||
const categorySet = new Set<CategoryFilter>();
|
||||
|
||||
entity.components.forEach((component: Component) => {
|
||||
if (isComponentInstanceHiddenInInspector(component)) return;
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const category = getComponentCategory(componentName);
|
||||
categorySet.add(category);
|
||||
});
|
||||
|
||||
// 只显示实际存在的分类 + All | Only show categories that exist + All
|
||||
const categories: CategoryConfig[] = [];
|
||||
|
||||
// 按固定顺序添加存在的分类 | Add existing categories in fixed order
|
||||
const orderedCategories: { id: CategoryFilter; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'transform', label: 'Transform' },
|
||||
{ id: 'rendering', label: 'Rendering' },
|
||||
{ id: 'physics', label: 'Physics' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
{ id: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
for (const cat of orderedCategories) {
|
||||
if (categorySet.has(cat.id)) {
|
||||
categories.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有多个分类,添加 All 选项 | If multiple categories, add All option
|
||||
if (categories.length > 1) {
|
||||
categories.push({ id: 'all', label: 'All' });
|
||||
}
|
||||
|
||||
return categories;
|
||||
}, [entity.components, getComponentCategory, componentVersion]);
|
||||
|
||||
// 过滤组件列表 | Filter component list
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
if (isComponentInstanceHiddenInInspector(component)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
const category = getComponentCategory(componentName);
|
||||
if (category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!componentName.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [entity.components, categoryFilter, searchQuery, getComponentCategory, componentVersion]);
|
||||
|
||||
// 添加菜单组件分组 | Add menu component grouping
|
||||
const groupedComponents = useMemo(() => {
|
||||
const query = addMenuSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? availableComponents.filter(c =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
(c.description && c.description.toLowerCase().includes(query))
|
||||
)
|
||||
: availableComponents;
|
||||
|
||||
const grouped = new Map<string, ComponentInfo[]>();
|
||||
filtered.forEach((info) => {
|
||||
const cat = info.category || 'components.category.other';
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push(info);
|
||||
});
|
||||
return grouped;
|
||||
}, [availableComponents, addMenuSearch]);
|
||||
|
||||
// 扁平化列表(用于键盘导航)| Flat list (for keyboard navigation)
|
||||
const flatComponents = useMemo(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
for (const [category, components] of groupedComponents.entries()) {
|
||||
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
|
||||
if (!isCollapsed) {
|
||||
result.push(...components);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [groupedComponents, collapsedCategories, addMenuSearch]);
|
||||
|
||||
// ==================== 副作用 | Effects ====================
|
||||
|
||||
// 保存折叠状态 | Save collapsed state
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'inspector-collapsed-components',
|
||||
JSON.stringify([...collapsedComponents])
|
||||
);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, [collapsedComponents]);
|
||||
|
||||
// 打开添加菜单时聚焦搜索 | Focus search when opening add menu
|
||||
useEffect(() => {
|
||||
if (showAddMenu) {
|
||||
setAddMenuSearch('');
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [showAddMenu]);
|
||||
|
||||
// 重置选中索引 | Reset selected index
|
||||
useEffect(() => {
|
||||
setSelectedIndex(addMenuSearch ? 0 : -1);
|
||||
}, [addMenuSearch]);
|
||||
|
||||
// 当前分类不可用时重置 | Reset when current category is not available
|
||||
useEffect(() => {
|
||||
if (availableCategories.length <= 1) {
|
||||
// 只有一个或没有分类时,使用 all
|
||||
setCategoryFilter('all');
|
||||
} else if (categoryFilter !== 'all' && !availableCategories.some(c => c.id === categoryFilter)) {
|
||||
// 当前分类不在可用列表中,重置为 all
|
||||
setCategoryFilter('all');
|
||||
}
|
||||
}, [availableCategories, categoryFilter]);
|
||||
|
||||
// ==================== 事件处理 | Event Handlers ====================
|
||||
|
||||
const toggleComponentExpanded = useCallback((componentName: string) => {
|
||||
setCollapsedComponents(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(componentName)) {
|
||||
newSet.delete(componentName);
|
||||
} else {
|
||||
newSet.add(componentName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddComponent = useCallback((ComponentClass: new () => Component) => {
|
||||
const command = new AddComponentCommand(messageHub, entity, ComponentClass);
|
||||
commandManager.execute(command);
|
||||
setShowAddMenu(false);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handleRemoveComponent = useCallback((component: Component) => {
|
||||
const componentName = getComponentTypeName(component.constructor as any);
|
||||
|
||||
// 检查依赖 | Check dependencies
|
||||
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);
|
||||
if (dependencies && dependencies.includes(componentName)) {
|
||||
dependentComponents.push(otherName);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handlePropertyChange = useCallback((component: Component, propertyName: string, value: unknown) => {
|
||||
const command = new UpdateComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component,
|
||||
propertyName,
|
||||
value
|
||||
);
|
||||
commandManager.execute(command);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handlePropertyAction = useCallback(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) 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.src = assetUrl;
|
||||
} catch (error) {
|
||||
console.error('Error getting texture size:', error);
|
||||
}
|
||||
}
|
||||
}, [handlePropertyChange]);
|
||||
|
||||
const handleAddMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, flatComponents.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selected = flatComponents[selectedIndex];
|
||||
if (selected?.type) {
|
||||
handleAddComponent(selected.type);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowAddMenu(false);
|
||||
}
|
||||
}, [flatComponents, selectedIndex, handleAddComponent]);
|
||||
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setCollapsedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) next.delete(category);
|
||||
else next.add(category);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ==================== 渲染 | Render ====================
|
||||
|
||||
return (
|
||||
<div className="inspector-panel">
|
||||
{/* Header */}
|
||||
<div className="inspector-header">
|
||||
<div className="inspector-header-info">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<span className="inspector-header-icon">
|
||||
<Settings size={14} />
|
||||
</span>
|
||||
<span className="inspector-header-name">
|
||||
{entity.name || `Entity #${entity.id}`}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--inspector-text-secondary)' }}>
|
||||
1 object
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<PropertySearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search components..."
|
||||
/>
|
||||
|
||||
{/* Category Tabs - 只有多个分类时显示 | Only show when multiple categories */}
|
||||
{availableCategories.length > 1 && (
|
||||
<CategoryTabs
|
||||
categories={availableCategories}
|
||||
current={categoryFilter}
|
||||
onChange={(cat) => setCategoryFilter(cat as CategoryFilter)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="inspector-panel-content">
|
||||
{/* Add Component Section Header */}
|
||||
<div className="inspector-section">
|
||||
<div
|
||||
className="inspector-section-header"
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
>
|
||||
<span className="inspector-section-title">组件</span>
|
||||
<button
|
||||
ref={addButtonRef}
|
||||
className="inspector-header-add-btn"
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
{filteredComponents.length === 0 ? (
|
||||
<div className="inspector-empty">
|
||||
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
|
||||
</div>
|
||||
) : (
|
||||
filteredComponents.map((component: Component) => {
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const isExpanded = !collapsedComponents.has(componentName);
|
||||
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}-${entity.components.indexOf(component)}`} className="inspector-section">
|
||||
<div
|
||||
className="inspector-section-header"
|
||||
onClick={() => toggleComponentExpanded(componentName)}
|
||||
>
|
||||
<span className={`inspector-section-arrow ${isExpanded ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
<span style={{ marginRight: '6px', color: 'var(--inspector-text-secondary)' }}>
|
||||
{IconComponent ? <IconComponent size={14} /> : <Box size={14} />}
|
||||
</span>
|
||||
<span className="inspector-section-title">{componentName}</span>
|
||||
<button
|
||||
className="inspector-section-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(component);
|
||||
}}
|
||||
title="移除组件"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--inspector-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="inspector-section-content expanded">
|
||||
{componentInspectorRegistry?.hasInspector(component) ? (
|
||||
componentInspectorRegistry.render({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})
|
||||
) : (
|
||||
<ComponentPropertyEditor
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName, value) =>
|
||||
handlePropertyChange(component, propName, value)
|
||||
}
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Append inspectors */}
|
||||
{componentInspectorRegistry?.renderAppendInspectors({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})}
|
||||
|
||||
{/* Component actions */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||
const ActionIcon = typeof action.icon === 'string'
|
||||
? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon]
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
className="inspector-header-add-btn"
|
||||
style={{ width: '100%', marginTop: '8px', justifyContent: 'center' }}
|
||||
onClick={() => action.execute(component, entity)}
|
||||
>
|
||||
{ActionIcon ? <ActionIcon size={14} /> : action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Component Menu */}
|
||||
{showAddMenu && (
|
||||
<>
|
||||
<div
|
||||
className="inspector-dropdown-overlay"
|
||||
onClick={() => setShowAddMenu(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="inspector-dropdown-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: addButtonRef.current?.getBoundingClientRect().bottom ?? 0 + 4,
|
||||
right: window.innerWidth - (addButtonRef.current?.getBoundingClientRect().right ?? 0),
|
||||
width: '280px',
|
||||
maxHeight: '400px',
|
||||
zIndex: 100
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="inspector-search" style={{ borderBottom: '1px solid var(--inspector-border)' }}>
|
||||
<Search size={14} className="inspector-search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="inspector-search-input"
|
||||
placeholder="搜索组件..."
|
||||
value={addMenuSearch}
|
||||
onChange={(e) => setAddMenuSearch(e.target.value)}
|
||||
onKeyDown={handleAddMenuKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<div style={{ overflowY: 'auto', maxHeight: '350px' }}>
|
||||
{groupedComponents.size === 0 ? (
|
||||
<div className="inspector-empty">
|
||||
{addMenuSearch ? '未找到匹配的组件' : '没有可用组件'}
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
let globalIndex = 0;
|
||||
return Array.from(groupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
|
||||
const label = CATEGORY_LABELS[category] || category;
|
||||
const startIndex = globalIndex;
|
||||
if (!isCollapsed) {
|
||||
globalIndex += components.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div
|
||||
className="inspector-dropdown-item"
|
||||
onClick={() => toggleCategory(category)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--inspector-bg-section)'
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '10px',
|
||||
color: 'var(--inspector-text-secondary)'
|
||||
}}>
|
||||
{components.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && components.map((info, idx) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
const itemIndex = startIndex + idx;
|
||||
const isSelected = itemIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={info.name}
|
||||
className={`inspector-dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
paddingLeft: '24px'
|
||||
}}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span>{info.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
339
packages/editor-app/src/components/inspector/InspectorPanel.tsx
Normal file
339
packages/editor-app/src/components/inspector/InspectorPanel.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* InspectorPanel - 属性面板主组件
|
||||
* InspectorPanel - Property panel main component
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { PropertySection } from './sections';
|
||||
import {
|
||||
PropertyRow,
|
||||
NumberInput,
|
||||
StringInput,
|
||||
BooleanInput,
|
||||
VectorInput,
|
||||
EnumInput,
|
||||
ColorInput,
|
||||
AssetInput,
|
||||
EntityRefInput,
|
||||
ArrayInput
|
||||
} from './controls';
|
||||
import {
|
||||
InspectorHeader,
|
||||
PropertySearch,
|
||||
CategoryTabs
|
||||
} from './header';
|
||||
import {
|
||||
InspectorPanelProps,
|
||||
SectionConfig,
|
||||
PropertyConfig,
|
||||
PropertyType,
|
||||
CategoryConfig
|
||||
} from './types';
|
||||
import './styles/inspector.css';
|
||||
|
||||
/**
|
||||
* 渲染属性控件
|
||||
* Render property control
|
||||
*/
|
||||
const renderControl = (
|
||||
type: PropertyType,
|
||||
value: any,
|
||||
onChange: (value: any) => void,
|
||||
readonly: boolean,
|
||||
metadata?: Record<string, any>
|
||||
): React.ReactNode => {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
value={value ?? 0}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
min={metadata?.min}
|
||||
max={metadata?.max}
|
||||
step={metadata?.step}
|
||||
integer={metadata?.integer}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<StringInput
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
placeholder={metadata?.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanInput
|
||||
value={value ?? false}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector2':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={2}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector3':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector4':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0, w: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'enum':
|
||||
return (
|
||||
<EnumInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
options={metadata?.options ?? []}
|
||||
placeholder={metadata?.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'color':
|
||||
return (
|
||||
<ColorInput
|
||||
value={value ?? { r: 0, g: 0, b: 0, a: 1 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
showAlpha={metadata?.showAlpha}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'asset':
|
||||
return (
|
||||
<AssetInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
assetTypes={metadata?.assetTypes}
|
||||
extensions={metadata?.extensions}
|
||||
onPickAsset={metadata?.onPickAsset}
|
||||
onOpenAsset={metadata?.onOpenAsset}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'entityRef':
|
||||
return (
|
||||
<EntityRefInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
resolveEntityName={metadata?.resolveEntityName}
|
||||
onSelectEntity={metadata?.onSelectEntity}
|
||||
onLocateEntity={metadata?.onLocateEntity}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'array':
|
||||
return (
|
||||
<ArrayInput
|
||||
value={value ?? []}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
renderElement={metadata?.renderElement}
|
||||
createNewElement={metadata?.createNewElement}
|
||||
minItems={metadata?.minItems}
|
||||
maxItems={metadata?.maxItems}
|
||||
sortable={metadata?.sortable}
|
||||
collapsedTitle={metadata?.collapsedTitle}
|
||||
/>
|
||||
);
|
||||
|
||||
// TODO: 后续实现 | To be implemented
|
||||
case 'object':
|
||||
return <span style={{ color: '#666', fontSize: '10px' }}>[{type}]</span>;
|
||||
|
||||
default:
|
||||
return <span style={{ color: '#666', fontSize: '10px' }}>[unknown]</span>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认分类配置
|
||||
* Default category configuration
|
||||
*/
|
||||
const DEFAULT_CATEGORIES: CategoryConfig[] = [
|
||||
{ id: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
export const InspectorPanel: React.FC<InspectorPanelProps> = ({
|
||||
targetName,
|
||||
sections,
|
||||
categories,
|
||||
currentCategory: controlledCategory,
|
||||
onCategoryChange,
|
||||
getValue,
|
||||
onChange,
|
||||
readonly = false,
|
||||
searchQuery: controlledSearch,
|
||||
onSearchChange
|
||||
}) => {
|
||||
// 内部状态(非受控模式)| Internal state (uncontrolled mode)
|
||||
const [internalSearch, setInternalSearch] = useState('');
|
||||
const [internalCategory, setInternalCategory] = useState('all');
|
||||
|
||||
// 支持受控/非受控模式 | Support controlled/uncontrolled mode
|
||||
const searchQuery = controlledSearch ?? internalSearch;
|
||||
const currentCategory = controlledCategory ?? internalCategory;
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
if (onSearchChange) {
|
||||
onSearchChange(value);
|
||||
} else {
|
||||
setInternalSearch(value);
|
||||
}
|
||||
}, [onSearchChange]);
|
||||
|
||||
const handleCategoryChange = useCallback((category: string) => {
|
||||
if (onCategoryChange) {
|
||||
onCategoryChange(category);
|
||||
} else {
|
||||
setInternalCategory(category);
|
||||
}
|
||||
}, [onCategoryChange]);
|
||||
|
||||
// 使用提供的分类或默认分类 | Use provided categories or default
|
||||
const effectiveCategories = useMemo(() => {
|
||||
if (categories && categories.length > 0) {
|
||||
return categories;
|
||||
}
|
||||
return DEFAULT_CATEGORIES;
|
||||
}, [categories]);
|
||||
|
||||
// 是否显示分类标签 | Whether to show category tabs
|
||||
const showCategoryTabs = effectiveCategories.length > 1;
|
||||
|
||||
/**
|
||||
* 过滤属性(搜索 + 分类)
|
||||
* Filter properties (search + category)
|
||||
*/
|
||||
const filterProperty = useCallback((prop: PropertyConfig): boolean => {
|
||||
// 分类过滤 | Category filter
|
||||
if (currentCategory !== 'all' && prop.category && prop.category !== currentCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 搜索过滤 | Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
prop.name.toLowerCase().includes(query) ||
|
||||
prop.label.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [searchQuery, currentCategory]);
|
||||
|
||||
/**
|
||||
* 过滤后的 sections
|
||||
* Filtered sections
|
||||
*/
|
||||
const filteredSections = useMemo(() => {
|
||||
return sections
|
||||
.map(section => ({
|
||||
...section,
|
||||
properties: section.properties.filter(filterProperty)
|
||||
}))
|
||||
.filter(section => section.properties.length > 0);
|
||||
}, [sections, filterProperty]);
|
||||
|
||||
/**
|
||||
* 渲染 Section
|
||||
* Render section
|
||||
*/
|
||||
const renderSection = useCallback((section: SectionConfig, depth: number = 0) => {
|
||||
return (
|
||||
<PropertySection
|
||||
key={section.id}
|
||||
title={section.title}
|
||||
defaultExpanded={section.defaultExpanded ?? true}
|
||||
depth={depth}
|
||||
>
|
||||
{/* 属性列表 | Property list */}
|
||||
{section.properties.map(prop => (
|
||||
<PropertyRow
|
||||
key={prop.name}
|
||||
label={prop.label}
|
||||
depth={depth}
|
||||
draggable={prop.type === 'number'}
|
||||
>
|
||||
{renderControl(
|
||||
prop.type,
|
||||
getValue(prop.name),
|
||||
(value) => onChange(prop.name, value),
|
||||
readonly,
|
||||
prop.metadata
|
||||
)}
|
||||
</PropertyRow>
|
||||
))}
|
||||
|
||||
{/* 子 Section | Sub sections */}
|
||||
{section.subsections?.map(sub => renderSection(sub, depth + 1))}
|
||||
</PropertySection>
|
||||
);
|
||||
}, [getValue, onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div className="inspector-panel">
|
||||
{/* 头部 | Header */}
|
||||
{targetName && (
|
||||
<InspectorHeader name={targetName} />
|
||||
)}
|
||||
|
||||
{/* 搜索栏 | Search bar */}
|
||||
<PropertySearch
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search properties..."
|
||||
/>
|
||||
|
||||
{/* 分类标签 | Category tabs */}
|
||||
{showCategoryTabs && (
|
||||
<CategoryTabs
|
||||
categories={effectiveCategories}
|
||||
current={currentCategory}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 属性内容 | Property content */}
|
||||
<div className="inspector-panel-content">
|
||||
{filteredSections.length > 0 ? (
|
||||
filteredSections.map(section => renderSection(section))
|
||||
) : (
|
||||
<div className="inspector-empty">
|
||||
{searchQuery ? 'No matching properties' : 'No properties'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ArrayInput - 数组编辑控件
|
||||
* ArrayInput - Array editor control
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Plus, Trash2, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface ArrayInputProps<T = any> extends PropertyControlProps<T[]> {
|
||||
/** 元素渲染器 | Element renderer */
|
||||
renderElement?: (
|
||||
element: T,
|
||||
index: number,
|
||||
onChange: (value: T) => void,
|
||||
onRemove: () => void
|
||||
) => React.ReactNode;
|
||||
/** 创建新元素 | Create new element */
|
||||
createNewElement?: () => T;
|
||||
/** 最小元素数 | Minimum element count */
|
||||
minItems?: number;
|
||||
/** 最大元素数 | Maximum element count */
|
||||
maxItems?: number;
|
||||
/** 是否可排序 | Sortable */
|
||||
sortable?: boolean;
|
||||
/** 折叠标题 | Collapsed title */
|
||||
collapsedTitle?: (items: T[]) => string;
|
||||
}
|
||||
|
||||
export function ArrayInput<T = any>({
|
||||
value = [],
|
||||
onChange,
|
||||
readonly = false,
|
||||
renderElement,
|
||||
createNewElement,
|
||||
minItems = 0,
|
||||
maxItems,
|
||||
sortable = false,
|
||||
collapsedTitle
|
||||
}: ArrayInputProps<T>): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const items = value ?? [];
|
||||
const canAdd = !maxItems || items.length < maxItems;
|
||||
const canRemove = items.length > minItems;
|
||||
|
||||
// 展开/折叠 | Expand/Collapse
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 添加元素 | Add element
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canAdd || readonly) return;
|
||||
|
||||
const newElement = createNewElement ? createNewElement() : (null as T);
|
||||
onChange([...items, newElement]);
|
||||
}, [items, onChange, canAdd, readonly, createNewElement]);
|
||||
|
||||
// 移除元素 | Remove element
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
if (!canRemove || readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
}, [items, onChange, canRemove, readonly]);
|
||||
|
||||
// 更新元素 | Update element
|
||||
const handleElementChange = useCallback((index: number, newValue: T) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems[index] = newValue;
|
||||
onChange(newItems);
|
||||
}, [items, onChange, readonly]);
|
||||
|
||||
// ========== 拖拽排序 | Drag Sort ==========
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly) return;
|
||||
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
}, [sortable, readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly || dragIndex === null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverIndex(index);
|
||||
}, [sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!sortable || readonly || dragIndex === null || dragIndex === targetIndex) {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(dragIndex, 1);
|
||||
if (removed !== undefined) {
|
||||
newItems.splice(targetIndex, 0, removed);
|
||||
}
|
||||
|
||||
onChange(newItems);
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, [items, onChange, sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
// 获取折叠标题 | Get collapsed title
|
||||
const getTitle = (): string => {
|
||||
if (collapsedTitle) {
|
||||
return collapsedTitle(items);
|
||||
}
|
||||
return `${items.length} item${items.length !== 1 ? 's' : ''}`;
|
||||
};
|
||||
|
||||
// 默认元素渲染 | Default element renderer
|
||||
const defaultRenderElement = (element: T, index: number) => (
|
||||
<div className="inspector-array-element-default">
|
||||
{String(element)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inspector-array-input">
|
||||
{/* 头部 | Header */}
|
||||
<div className="inspector-array-header" onClick={toggleExpanded}>
|
||||
<span className="inspector-array-arrow">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="inspector-array-title">{getTitle()}</span>
|
||||
|
||||
{/* 添加按钮 | Add button */}
|
||||
{canAdd && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-add"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAdd();
|
||||
}}
|
||||
title="Add element"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元素列表 | Element list */}
|
||||
{expanded && (
|
||||
<div className="inspector-array-elements">
|
||||
{items.map((element, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`inspector-array-element ${dragOverIndex === index ? 'drag-over' : ''} ${dragIndex === index ? 'dragging' : ''}`}
|
||||
draggable={sortable && !readonly}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 拖拽手柄 | Drag handle */}
|
||||
{sortable && !readonly && (
|
||||
<div className="inspector-array-handle">
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 索引 | Index */}
|
||||
<span className="inspector-array-index">{index}</span>
|
||||
|
||||
{/* 内容 | Content */}
|
||||
<div className="inspector-array-content">
|
||||
{renderElement
|
||||
? renderElement(
|
||||
element,
|
||||
index,
|
||||
(val) => handleElementChange(index, val),
|
||||
() => handleRemove(index)
|
||||
)
|
||||
: defaultRenderElement(element, index)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 | Remove button */}
|
||||
{canRemove && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-remove"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{items.length === 0 && (
|
||||
<div className="inspector-array-empty">
|
||||
No items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* AssetInput - 资产引用选择控件
|
||||
* AssetInput - Asset reference picker control
|
||||
*
|
||||
* 功能 | Features:
|
||||
* - 缩略图预览 | Thumbnail preview
|
||||
* - 下拉选择 | Dropdown selection
|
||||
* - 拖放支持 | Drag and drop support
|
||||
* - 操作按钮 | Action buttons (browse, copy, locate, clear)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, FolderOpen, Copy, Navigation, X, FileImage, Image, Music, Film, FileText, Box } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface AssetReference {
|
||||
/** 资产 ID | Asset ID */
|
||||
id: string;
|
||||
/** 资产路径 | Asset path */
|
||||
path?: string;
|
||||
/** 资产类型 | Asset type */
|
||||
type?: string;
|
||||
/** 缩略图 URL | Thumbnail URL */
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface AssetInputProps extends PropertyControlProps<AssetReference | string | null> {
|
||||
/** 允许的资产类型 | Allowed asset types */
|
||||
assetTypes?: string[];
|
||||
/** 允许的文件扩展名 | Allowed file extensions */
|
||||
extensions?: string[];
|
||||
/** 打开资产选择器回调 | Open asset picker callback */
|
||||
onPickAsset?: () => void;
|
||||
/** 打开资产回调 | Open asset callback */
|
||||
onOpenAsset?: (asset: AssetReference) => void;
|
||||
/** 定位资产回调 | Locate asset callback */
|
||||
onLocateAsset?: (asset: AssetReference) => void;
|
||||
/** 复制路径回调 | Copy path callback */
|
||||
onCopyPath?: (path: string) => void;
|
||||
/** 获取缩略图 URL | Get thumbnail URL */
|
||||
getThumbnail?: (asset: AssetReference) => string | undefined;
|
||||
/** 最近使用的资产 | Recently used assets */
|
||||
recentAssets?: AssetReference[];
|
||||
/** 显示缩略图 | Show thumbnail */
|
||||
showThumbnail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产显示名称
|
||||
* Get asset display name
|
||||
*/
|
||||
const getAssetDisplayName = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
// 从路径中提取文件名 | Extract filename from path
|
||||
const parts = value.split('/');
|
||||
return parts[parts.length - 1] ?? value;
|
||||
}
|
||||
if (value.path) {
|
||||
const parts = value.path.split('/');
|
||||
return parts[parts.length - 1] ?? value.id;
|
||||
}
|
||||
return value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取资产路径
|
||||
* Get asset path
|
||||
*/
|
||||
const getAssetPath = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return value.path || value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据扩展名获取图标
|
||||
* Get icon by extension
|
||||
*/
|
||||
const getAssetIcon = (value: AssetReference | string | null) => {
|
||||
const path = getAssetPath(value).toLowerCase();
|
||||
if (path.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/)) return Image;
|
||||
if (path.match(/\.(mp3|wav|ogg|flac|aac)$/)) return Music;
|
||||
if (path.match(/\.(mp4|webm|avi|mov|mkv)$/)) return Film;
|
||||
if (path.match(/\.(txt|json|xml|yaml|yml|md)$/)) return FileText;
|
||||
if (path.match(/\.(fbx|obj|gltf|glb|dae)$/)) return Box;
|
||||
return FileImage;
|
||||
};
|
||||
|
||||
export const AssetInput: React.FC<AssetInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
extensions,
|
||||
onPickAsset,
|
||||
onOpenAsset,
|
||||
onLocateAsset,
|
||||
onCopyPath,
|
||||
getThumbnail,
|
||||
recentAssets = [],
|
||||
showThumbnail = true
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = getAssetDisplayName(value);
|
||||
const assetPath = getAssetPath(value);
|
||||
const hasValue = !!value;
|
||||
const IconComponent = getAssetIcon(value);
|
||||
|
||||
// 获取缩略图 | Get thumbnail
|
||||
const thumbnailUrl = value && getThumbnail
|
||||
? getThumbnail(typeof value === 'string' ? { id: value, path: value } : value)
|
||||
: undefined;
|
||||
|
||||
// 关闭下拉菜单 | Close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDropdown]);
|
||||
|
||||
// 清除值 | Clear value
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
onChange(null);
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
// 打开选择器 | Open picker
|
||||
const handleBrowse = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly && onPickAsset) {
|
||||
onPickAsset();
|
||||
}
|
||||
setShowDropdown(false);
|
||||
}, [readonly, onPickAsset]);
|
||||
|
||||
// 定位资产 | Locate asset
|
||||
const handleLocate = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (value && onLocateAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onLocateAsset(asset);
|
||||
}
|
||||
}, [value, onLocateAsset]);
|
||||
|
||||
// 复制路径 | Copy path
|
||||
const handleCopy = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (assetPath) {
|
||||
if (onCopyPath) {
|
||||
onCopyPath(assetPath);
|
||||
} else {
|
||||
navigator.clipboard.writeText(assetPath);
|
||||
}
|
||||
}
|
||||
}, [assetPath, onCopyPath]);
|
||||
|
||||
// 双击打开资产 | Double click to open asset
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (value && onOpenAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onOpenAsset(asset);
|
||||
}
|
||||
}, [value, onOpenAsset]);
|
||||
|
||||
// 切换下拉菜单 | Toggle dropdown
|
||||
const handleToggleDropdown = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setShowDropdown(!showDropdown);
|
||||
}
|
||||
}, [readonly, showDropdown]);
|
||||
|
||||
// 选择资产 | Select asset
|
||||
const handleSelectAsset = useCallback((asset: AssetReference) => {
|
||||
onChange(asset);
|
||||
setShowDropdown(false);
|
||||
}, [onChange]);
|
||||
|
||||
// 拖放处理 | Drag and drop handling
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
const assetId = e.dataTransfer.getData('asset-id');
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
const assetType = e.dataTransfer.getData('asset-type');
|
||||
|
||||
if (assetId || assetPath) {
|
||||
// 检查扩展名匹配 | Check extension match
|
||||
if (extensions && assetPath) {
|
||||
const ext = assetPath.split('.').pop()?.toLowerCase();
|
||||
if (ext && !extensions.some(e => e.toLowerCase() === ext || e.toLowerCase() === `.${ext}`)) {
|
||||
console.warn(`Extension "${ext}" not allowed. Allowed: ${extensions.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
id: assetId || assetPath,
|
||||
path: assetPath || undefined,
|
||||
type: assetType || undefined
|
||||
});
|
||||
}
|
||||
}, [onChange, readonly, extensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`inspector-asset-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 缩略图 | Thumbnail */}
|
||||
{showThumbnail && (
|
||||
<div className="inspector-asset-thumbnail" onDoubleClick={handleDoubleClick}>
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<IconComponent size={16} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 值显示和下拉按钮 | Value display and dropdown button */}
|
||||
<div className="inspector-asset-main" onClick={handleToggleDropdown}>
|
||||
<div
|
||||
className="inspector-asset-value"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title={assetPath || 'None'}
|
||||
>
|
||||
{displayName || <span className="inspector-asset-placeholder">None</span>}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<ChevronDown size={12} className={`inspector-asset-arrow ${showDropdown ? 'open' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="inspector-asset-actions">
|
||||
{/* 定位按钮 | Locate button */}
|
||||
{hasValue && onLocateAsset && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleLocate}
|
||||
title="Locate in Content Browser"
|
||||
>
|
||||
<Navigation size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 | Copy button */}
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleCopy}
|
||||
title="Copy Path"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 浏览按钮 | Browse button */}
|
||||
{onPickAsset && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleBrowse}
|
||||
title="Browse"
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 | Clear button */}
|
||||
{hasValue && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn inspector-asset-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单 | Dropdown menu */}
|
||||
{showDropdown && (
|
||||
<div ref={dropdownRef} className="inspector-asset-dropdown">
|
||||
{/* 浏览选项 | Browse option */}
|
||||
{onPickAsset && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleBrowse}>
|
||||
<FolderOpen size={14} />
|
||||
<span>Browse...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 清除选项 | Clear option */}
|
||||
{hasValue && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleClear}>
|
||||
<X size={14} />
|
||||
<span>Clear</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分割线 | Divider */}
|
||||
{recentAssets.length > 0 && (
|
||||
<>
|
||||
<div className="inspector-asset-dropdown-divider" />
|
||||
<div className="inspector-asset-dropdown-label">Recent</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 最近使用 | Recent assets */}
|
||||
{recentAssets.map((asset, index) => (
|
||||
<div
|
||||
key={asset.id || index}
|
||||
className="inspector-asset-dropdown-item"
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
>
|
||||
{asset.thumbnail ? (
|
||||
<img src={asset.thumbnail} alt="" className="inspector-asset-dropdown-thumb" />
|
||||
) : (
|
||||
<FileImage size={14} />
|
||||
)}
|
||||
<span>{getAssetDisplayName(asset)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{!onPickAsset && !hasValue && recentAssets.length === 0 && (
|
||||
<div className="inspector-asset-dropdown-empty">No assets available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* BooleanInput - 复选框控件
|
||||
* BooleanInput - Checkbox control
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface BooleanInputProps extends PropertyControlProps<boolean> {}
|
||||
|
||||
export const BooleanInput: React.FC<BooleanInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (!readonly) {
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!readonly && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inspector-checkbox ${value ? 'checked' : ''}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
role="checkbox"
|
||||
aria-checked={value}
|
||||
aria-disabled={readonly}
|
||||
>
|
||||
<Check size={12} className="inspector-checkbox-icon" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ColorInput - 颜色选择控件
|
||||
* ColorInput - Color picker control
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface ColorValue {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface ColorInputProps extends PropertyControlProps<ColorValue | string> {
|
||||
/** 是否显示 Alpha 通道 | Show alpha channel */
|
||||
showAlpha?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将颜色值转换为 CSS 颜色字符串
|
||||
* Convert color value to CSS color string
|
||||
*/
|
||||
const toHexString = (color: ColorValue | string): string => {
|
||||
if (typeof color === 'string') {
|
||||
return color;
|
||||
}
|
||||
|
||||
const r = Math.round(Math.max(0, Math.min(255, color.r)));
|
||||
const g = Math.round(Math.max(0, Math.min(255, color.g)));
|
||||
const b = Math.round(Math.max(0, Math.min(255, color.b)));
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 Hex 字符串解析颜色
|
||||
* Parse color from hex string
|
||||
*/
|
||||
const parseHex = (hex: string): ColorValue => {
|
||||
const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i);
|
||||
if (match && match[1] && match[2] && match[3]) {
|
||||
return {
|
||||
r: parseInt(match[1], 16),
|
||||
g: parseInt(match[2], 16),
|
||||
b: parseInt(match[3], 16),
|
||||
a: match[4] ? parseInt(match[4], 16) / 255 : 1
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 1 };
|
||||
};
|
||||
|
||||
export const ColorInput: React.FC<ColorInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
showAlpha = false
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// 标准化颜色值 | Normalize color value
|
||||
const normalizedValue: ColorValue = typeof value === 'string'
|
||||
? parseHex(value)
|
||||
: (value ?? { r: 0, g: 0, b: 0, a: 1 });
|
||||
|
||||
const hexValue = toHexString(normalizedValue);
|
||||
|
||||
const handleColorChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newHex = e.target.value;
|
||||
const newColor = parseHex(newHex);
|
||||
|
||||
// 保持原始 alpha | Preserve original alpha
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
newColor.a = value.a;
|
||||
}
|
||||
|
||||
onChange(typeof value === 'string' ? newHex : newColor);
|
||||
}, [onChange, readonly, value]);
|
||||
|
||||
const handleSwatchClick = useCallback(() => {
|
||||
if (readonly) return;
|
||||
inputRef.current?.click();
|
||||
}, [readonly]);
|
||||
|
||||
// Hex 输入处理 | Hex input handling
|
||||
const [hexInput, setHexInput] = useState(hexValue);
|
||||
|
||||
useEffect(() => {
|
||||
setHexInput(hexValue);
|
||||
}, [hexValue]);
|
||||
|
||||
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setHexInput(newValue);
|
||||
|
||||
// 验证并应用 | Validate and apply
|
||||
if (/^#?[a-f\d]{6}$/i.test(newValue)) {
|
||||
const newColor = parseHex(newValue);
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
newColor.a = value.a;
|
||||
}
|
||||
onChange(typeof value === 'string' ? newValue : newColor);
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
const handleHexInputBlur = useCallback(() => {
|
||||
// 恢复有效值 | Restore valid value
|
||||
setHexInput(hexValue);
|
||||
}, [hexValue]);
|
||||
|
||||
return (
|
||||
<div className="inspector-color-input">
|
||||
{/* 颜色预览块 | Color swatch */}
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-color-swatch"
|
||||
style={{ backgroundColor: hexValue }}
|
||||
onClick={handleSwatchClick}
|
||||
disabled={readonly}
|
||||
title="Click to pick color"
|
||||
/>
|
||||
|
||||
{/* 隐藏的原生颜色选择器 | Hidden native color picker */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
className="inspector-color-native"
|
||||
value={hexValue}
|
||||
onChange={handleColorChange}
|
||||
disabled={readonly}
|
||||
/>
|
||||
|
||||
{/* Hex 输入框 | Hex input */}
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-color-hex"
|
||||
value={hexInput}
|
||||
onChange={handleHexInputChange}
|
||||
onBlur={handleHexInputBlur}
|
||||
disabled={readonly}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
|
||||
{/* Alpha 滑块 | Alpha slider */}
|
||||
{showAlpha && (
|
||||
<input
|
||||
type="range"
|
||||
className="inspector-color-alpha"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={normalizedValue.a ?? 1}
|
||||
onChange={(e) => {
|
||||
if (readonly) return;
|
||||
const newAlpha = parseFloat(e.target.value);
|
||||
onChange({
|
||||
...normalizedValue,
|
||||
a: newAlpha
|
||||
});
|
||||
}}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* EntityRefInput - 实体引用选择控件
|
||||
* EntityRefInput - Entity reference picker control
|
||||
*
|
||||
* 支持从场景层级面板拖放实体
|
||||
* Supports drag and drop entities from scene hierarchy panel
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { Box, X, Target, Link } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface EntityReference {
|
||||
/** 实体 ID | Entity ID */
|
||||
id: number | string;
|
||||
/** 实体名称 | Entity name */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface EntityRefInputProps extends PropertyControlProps<EntityReference | number | string | null> {
|
||||
/** 实体名称解析器 | Entity name resolver */
|
||||
resolveEntityName?: (id: number | string) => string | undefined;
|
||||
/** 选择实体回调 | Select entity callback */
|
||||
onSelectEntity?: () => void;
|
||||
/** 定位实体回调 | Locate entity callback */
|
||||
onLocateEntity?: (id: number | string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体 ID
|
||||
* Get entity ID
|
||||
*/
|
||||
const getEntityId = (value: EntityReference | number | string | null): number | string | null => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'object') return value.id;
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
* Get display name
|
||||
*/
|
||||
const getDisplayName = (
|
||||
value: EntityReference | number | string | null,
|
||||
resolver?: (id: number | string) => string | undefined
|
||||
): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
// 如果是完整引用对象且有名称 | If full reference with name
|
||||
if (typeof value === 'object' && value.name) {
|
||||
return value.name;
|
||||
}
|
||||
|
||||
const id = getEntityId(value);
|
||||
if (id === null) return '';
|
||||
|
||||
// 尝试通过解析器获取名称 | Try to resolve name
|
||||
if (resolver) {
|
||||
const resolved = resolver(id);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
// 回退到 ID | Fallback to ID
|
||||
return `Entity ${id}`;
|
||||
};
|
||||
|
||||
export const EntityRefInput: React.FC<EntityRefInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
resolveEntityName,
|
||||
onSelectEntity,
|
||||
onLocateEntity
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const entityId = getEntityId(value);
|
||||
const displayName = getDisplayName(value, resolveEntityName);
|
||||
const hasValue = entityId !== null;
|
||||
|
||||
// 清除值 | Clear value
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
onChange(null);
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
// 定位实体 | Locate entity
|
||||
const handleLocate = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (entityId !== null && onLocateEntity) {
|
||||
onLocateEntity(entityId);
|
||||
}
|
||||
}, [entityId, onLocateEntity]);
|
||||
|
||||
// 选择实体 | Select entity
|
||||
const handleSelect = useCallback(() => {
|
||||
if (!readonly && onSelectEntity) {
|
||||
onSelectEntity();
|
||||
}
|
||||
}, [readonly, onSelectEntity]);
|
||||
|
||||
// ========== 拖放处理 | Drag and Drop Handling ==========
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 检查是否有实体数据 | Check for entity data
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
if (types.includes('entity-id') || types.includes('text/plain')) {
|
||||
setIsDragOver(true);
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 必须设置 dropEffect 才能接收 drop | Must set dropEffect to receive drop
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 确保离开的是当前元素而非子元素 | Ensure leaving current element not child
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (dropZoneRef.current && !dropZoneRef.current.contains(relatedTarget)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 尝试获取实体 ID | Try to get entity ID
|
||||
let droppedId: number | string | null = null;
|
||||
let droppedName: string | undefined;
|
||||
|
||||
// 优先使用 entity-id | Prefer entity-id
|
||||
const entityIdData = e.dataTransfer.getData('entity-id');
|
||||
if (entityIdData) {
|
||||
droppedId = isNaN(Number(entityIdData)) ? entityIdData : Number(entityIdData);
|
||||
}
|
||||
|
||||
// 获取实体名称 | Get entity name
|
||||
const entityNameData = e.dataTransfer.getData('entity-name');
|
||||
if (entityNameData) {
|
||||
droppedName = entityNameData;
|
||||
}
|
||||
|
||||
// 回退到 text/plain | Fallback to text/plain
|
||||
if (droppedId === null) {
|
||||
const textData = e.dataTransfer.getData('text/plain');
|
||||
if (textData) {
|
||||
droppedId = isNaN(Number(textData)) ? textData : Number(textData);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedId !== null) {
|
||||
// 创建完整引用或简单值 | Create full reference or simple value
|
||||
if (droppedName) {
|
||||
onChange({ id: droppedId, name: droppedName });
|
||||
} else {
|
||||
onChange(droppedId);
|
||||
}
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={`inspector-entity-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 图标 | Icon */}
|
||||
<Box size={14} className="inspector-entity-icon" />
|
||||
|
||||
{/* 值显示 | Value display */}
|
||||
<div
|
||||
className="inspector-entity-value"
|
||||
onClick={handleSelect}
|
||||
title={hasValue ? `${displayName} (ID: ${entityId})` : 'None - Drag entity here'}
|
||||
>
|
||||
{displayName || <span className="inspector-entity-placeholder">None</span>}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="inspector-entity-actions">
|
||||
{/* 定位按钮 | Locate button */}
|
||||
{hasValue && onLocateEntity && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn"
|
||||
onClick={handleLocate}
|
||||
title="Locate in hierarchy"
|
||||
>
|
||||
<Target size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 选择按钮 | Select button */}
|
||||
{onSelectEntity && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn"
|
||||
onClick={handleSelect}
|
||||
title="Select entity"
|
||||
>
|
||||
<Link size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 | Clear button */}
|
||||
{hasValue && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn inspector-entity-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拖放提示 | Drop hint */}
|
||||
{isDragOver && (
|
||||
<div className="inspector-entity-drop-hint">
|
||||
Drop to assign
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* EnumInput - 下拉选择控件
|
||||
* EnumInput - Dropdown select control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface EnumOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface EnumInputProps extends PropertyControlProps<string | number> {
|
||||
/** 选项列表 | Options list */
|
||||
options: EnumOption[];
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const EnumInput: React.FC<EnumInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
options = [],
|
||||
placeholder = '选择...'
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击外部关闭 | Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!readonly) {
|
||||
setIsOpen(prev => !prev);
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleSelect = useCallback((optionValue: string | number) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value);
|
||||
const displayValue = selectedOption?.label ?? placeholder;
|
||||
|
||||
return (
|
||||
<div className="inspector-dropdown" ref={containerRef}>
|
||||
<div
|
||||
className={`inspector-dropdown-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="inspector-dropdown-value">{displayValue}</span>
|
||||
<ChevronDown size={12} className="inspector-dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="inspector-dropdown-menu">
|
||||
{options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`inspector-dropdown-item ${option.value === value ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* NumberInput - 数值输入控件
|
||||
* NumberInput - Number input control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface NumberInputProps extends PropertyControlProps<number> {
|
||||
/** 最小值 | Minimum value */
|
||||
min?: number;
|
||||
/** 最大值 | Maximum value */
|
||||
max?: number;
|
||||
/** 步进值 | Step value */
|
||||
step?: number;
|
||||
/** 是否为整数 | Integer only */
|
||||
integer?: boolean;
|
||||
}
|
||||
|
||||
export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
integer = false
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// 同步外部值 | Sync external value
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
let num = parseFloat(localValue);
|
||||
|
||||
if (isNaN(num)) {
|
||||
num = value ?? 0;
|
||||
}
|
||||
|
||||
// 应用约束 | Apply constraints
|
||||
if (integer) {
|
||||
num = Math.round(num);
|
||||
}
|
||||
if (min !== undefined) {
|
||||
num = Math.max(min, num);
|
||||
}
|
||||
if (max !== undefined) {
|
||||
num = Math.min(max, num);
|
||||
}
|
||||
|
||||
setLocalValue(String(num));
|
||||
if (num !== value) {
|
||||
onChange(num);
|
||||
}
|
||||
}, [localValue, value, onChange, integer, min, max]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-input"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* PropertyRow - 属性行容器
|
||||
* PropertyRow - Property row container
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export interface PropertyRowProps {
|
||||
/** 属性标签 | Property label */
|
||||
label: ReactNode;
|
||||
/** 标签工具提示 | Label tooltip */
|
||||
labelTitle?: string;
|
||||
/** 嵌套深度 | Nesting depth */
|
||||
depth?: number;
|
||||
/** 标签是否可拖拽(用于数值调整)| Label draggable for value adjustment */
|
||||
draggable?: boolean;
|
||||
/** 拖拽开始回调 | Drag start callback */
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
/** 子内容(控件)| Children content (control) */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PropertyRow: React.FC<PropertyRowProps> = ({
|
||||
label,
|
||||
labelTitle,
|
||||
depth = 0,
|
||||
draggable = false,
|
||||
onDragStart,
|
||||
children
|
||||
}) => {
|
||||
const labelClassName = `inspector-property-label ${draggable ? 'draggable' : ''}`;
|
||||
|
||||
// 生成 title | Generate title
|
||||
const title = labelTitle ?? (typeof label === 'string' ? label : undefined);
|
||||
|
||||
return (
|
||||
<div className="inspector-property-row" data-depth={depth}>
|
||||
<span
|
||||
className={labelClassName}
|
||||
title={title}
|
||||
onMouseDown={draggable ? onDragStart : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="inspector-property-control">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* StringInput - 文本输入控件
|
||||
* StringInput - String input control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface StringInputProps extends PropertyControlProps<string> {
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
/** 是否多行 | Multiline mode */
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export const StringInput: React.FC<StringInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
placeholder = ''
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value ?? '');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// 同步外部值 | Sync external value
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(value ?? '');
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, value, onChange]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(value ?? '');
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-input"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* VectorInput - 向量输入控件(支持 2D/3D/4D)
|
||||
* VectorInput - Vector input control (supports 2D/3D/4D)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { PropertyControlProps, Vector2, Vector3, Vector4 } from '../types';
|
||||
|
||||
type VectorValue = Vector2 | Vector3 | Vector4;
|
||||
type AxisKey = 'x' | 'y' | 'z' | 'w';
|
||||
|
||||
export interface VectorInputProps extends PropertyControlProps<VectorValue> {
|
||||
/** 向量维度 | Vector dimensions */
|
||||
dimensions?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
interface AxisInputProps {
|
||||
axis: AxisKey;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const AxisInput: React.FC<AxisInputProps> = ({ axis, value, onChange, readonly }) => {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
let num = parseFloat(localValue);
|
||||
if (isNaN(num)) {
|
||||
num = value ?? 0;
|
||||
}
|
||||
setLocalValue(String(num));
|
||||
if (num !== value) {
|
||||
onChange(num);
|
||||
}
|
||||
}, [localValue, value, onChange]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="inspector-vector-axis">
|
||||
<span className={`inspector-vector-axis-bar ${axis}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VectorInput: React.FC<VectorInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
dimensions = 3
|
||||
}) => {
|
||||
const axes = useMemo<AxisKey[]>(() => {
|
||||
if (dimensions === 2) return ['x', 'y'];
|
||||
if (dimensions === 4) return ['x', 'y', 'z', 'w'];
|
||||
return ['x', 'y', 'z'];
|
||||
}, [dimensions]);
|
||||
|
||||
const handleAxisChange = useCallback((axis: AxisKey, newValue: number) => {
|
||||
const newVector = { ...value, [axis]: newValue } as VectorValue;
|
||||
onChange(newVector);
|
||||
}, [value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="inspector-vector-input">
|
||||
{axes.map(axis => (
|
||||
<AxisInput
|
||||
key={axis}
|
||||
axis={axis}
|
||||
value={(value as any)?.[axis] ?? 0}
|
||||
onChange={(v) => handleAxisChange(axis, v)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Inspector Controls
|
||||
* Inspector 控件导出
|
||||
*/
|
||||
|
||||
// 布局组件 | Layout components
|
||||
export * from './PropertyRow';
|
||||
|
||||
// 基础控件 | Basic controls
|
||||
export * from './NumberInput';
|
||||
export * from './StringInput';
|
||||
export * from './BooleanInput';
|
||||
export * from './VectorInput';
|
||||
export * from './EnumInput';
|
||||
|
||||
// 高级控件 | Advanced controls
|
||||
export * from './ColorInput';
|
||||
export * from './AssetInput';
|
||||
export * from './EntityRefInput';
|
||||
export * from './ArrayInput';
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* CategoryTabs - 分类标签切换
|
||||
* CategoryTabs - Category tab switcher
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { CategoryConfig } from '../types';
|
||||
|
||||
export interface CategoryTabsProps {
|
||||
/** 分类列表 | Category list */
|
||||
categories: CategoryConfig[];
|
||||
/** 当前选中分类 | Current selected category */
|
||||
current: string;
|
||||
/** 分类变更回调 | Category change callback */
|
||||
onChange: (category: string) => void;
|
||||
}
|
||||
|
||||
export const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
current,
|
||||
onChange
|
||||
}) => {
|
||||
const handleClick = useCallback((categoryId: string) => {
|
||||
onChange(categoryId);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className="inspector-category-tabs">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`inspector-category-tab ${current === cat.id ? 'active' : ''}`}
|
||||
onClick={() => handleClick(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* InspectorHeader - 头部组件(对象名称 + Add 按钮)
|
||||
* InspectorHeader - Header component (object name + Add button)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
export interface InspectorHeaderProps {
|
||||
/** 目标对象名称 | Target object name */
|
||||
name: string;
|
||||
/** 对象图标 | Object icon */
|
||||
icon?: React.ReactNode;
|
||||
/** 添加按钮点击 | Add button click */
|
||||
onAdd?: () => void;
|
||||
/** 是否显示添加按钮 | Show add button */
|
||||
showAddButton?: boolean;
|
||||
}
|
||||
|
||||
export const InspectorHeader: React.FC<InspectorHeaderProps> = ({
|
||||
name,
|
||||
icon,
|
||||
onAdd,
|
||||
showAddButton = true
|
||||
}) => {
|
||||
return (
|
||||
<div className="inspector-header">
|
||||
<div className="inspector-header-info">
|
||||
{icon && <span className="inspector-header-icon">{icon}</span>}
|
||||
<span className="inspector-header-name" title={name}>{name}</span>
|
||||
</div>
|
||||
{showAddButton && onAdd && (
|
||||
<button
|
||||
className="inspector-header-add-btn"
|
||||
onClick={onAdd}
|
||||
title="添加组件 | Add Component"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* PropertySearch - 属性搜索栏
|
||||
* PropertySearch - Property search bar
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
export interface PropertySearchProps {
|
||||
/** 搜索关键词 | Search query */
|
||||
value: string;
|
||||
/** 搜索变更回调 | Search change callback */
|
||||
onChange: (value: string) => void;
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const PropertySearch: React.FC<PropertySearchProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...'
|
||||
}) => {
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
}, [onChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className="inspector-search">
|
||||
<Search size={14} className="inspector-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-search-input"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
className="inspector-search-clear"
|
||||
onClick={handleClear}
|
||||
title="清除 | Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Inspector Header Components
|
||||
* Inspector 头部组件导出
|
||||
*/
|
||||
|
||||
export * from './InspectorHeader';
|
||||
export * from './PropertySearch';
|
||||
export * from './CategoryTabs';
|
||||
21
packages/editor-app/src/components/inspector/index.ts
Normal file
21
packages/editor-app/src/components/inspector/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Inspector Components
|
||||
* Inspector 组件导出
|
||||
*/
|
||||
|
||||
// 主组件 | Main components
|
||||
export * from './InspectorPanel';
|
||||
export * from './EntityInspectorPanel';
|
||||
export * from './ComponentPropertyEditor';
|
||||
|
||||
// 类型 | Types
|
||||
export * from './types';
|
||||
|
||||
// 头部组件 | Header components
|
||||
export * from './header';
|
||||
|
||||
// 分组组件 | Section components
|
||||
export * from './sections';
|
||||
|
||||
// 控件组件 | Control components
|
||||
export * from './controls';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PropertySection - 可折叠的属性分组
|
||||
* PropertySection - Collapsible property group
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface PropertySectionProps {
|
||||
/** Section 标题 | Section title */
|
||||
title: string;
|
||||
/** 默认展开状态 | Default expanded state */
|
||||
defaultExpanded?: boolean;
|
||||
/** 子内容 | Children content */
|
||||
children: ReactNode;
|
||||
/** 嵌套深度 | Nesting depth */
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const PropertySection: React.FC<PropertySectionProps> = ({
|
||||
title,
|
||||
defaultExpanded = true,
|
||||
children,
|
||||
depth = 0
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const paddingLeft = depth * 16;
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div
|
||||
className="inspector-section-header"
|
||||
onClick={handleToggle}
|
||||
style={{ paddingLeft: paddingLeft + 8 }}
|
||||
>
|
||||
<span className={`inspector-section-arrow ${expanded ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={12} />
|
||||
</span>
|
||||
<span className="inspector-section-title">{title}</span>
|
||||
</div>
|
||||
<div className={`inspector-section-content ${expanded ? 'expanded' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Inspector Sections
|
||||
* Inspector 分组导出
|
||||
*/
|
||||
|
||||
export * from './PropertySection';
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Inspector CSS Variables
|
||||
* Inspector CSS 变量
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ==================== 背景层级 | Background Layers ==================== */
|
||||
--inspector-bg-base: #1a1a1a;
|
||||
--inspector-bg-section: #2a2a2a;
|
||||
--inspector-bg-input: #0d0d0d;
|
||||
--inspector-bg-hover: #333333;
|
||||
--inspector-bg-active: #3a3a3a;
|
||||
|
||||
/* ==================== 边框 | Borders ==================== */
|
||||
--inspector-border: #333333;
|
||||
--inspector-border-light: #444444;
|
||||
--inspector-border-focus: #4a90d9;
|
||||
|
||||
/* ==================== 文字 | Text ==================== */
|
||||
--inspector-text-primary: #cccccc;
|
||||
--inspector-text-secondary: #888888;
|
||||
--inspector-text-label: #999999;
|
||||
--inspector-text-placeholder: #666666;
|
||||
|
||||
/* ==================== 强调色 | Accent Colors ==================== */
|
||||
--inspector-accent: #4a90d9;
|
||||
--inspector-accent-hover: #5a9fe9;
|
||||
--inspector-checkbox-checked: #3b82f6;
|
||||
|
||||
/* ==================== 向量轴颜色 | Vector Axis Colors ==================== */
|
||||
--inspector-axis-x: #c04040;
|
||||
--inspector-axis-y: #40a040;
|
||||
--inspector-axis-z: #4060c0;
|
||||
--inspector-axis-w: #a040a0;
|
||||
|
||||
/* ==================== 尺寸 | Dimensions ==================== */
|
||||
--inspector-row-height: 26px;
|
||||
--inspector-label-width: 40%;
|
||||
--inspector-section-padding: 0 8px;
|
||||
--inspector-indent: 16px;
|
||||
--inspector-input-height: 20px;
|
||||
|
||||
/* ==================== 字体 | Typography ==================== */
|
||||
--inspector-font-size: 11px;
|
||||
--inspector-font-size-small: 10px;
|
||||
--inspector-font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--inspector-font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
|
||||
/* ==================== 动画 | Animations ==================== */
|
||||
--inspector-transition-fast: 0.1s ease;
|
||||
--inspector-transition-normal: 0.15s ease;
|
||||
|
||||
/* ==================== 圆角 | Border Radius ==================== */
|
||||
--inspector-radius-sm: 2px;
|
||||
--inspector-radius-md: 3px;
|
||||
}
|
||||
1028
packages/editor-app/src/components/inspector/styles/inspector.css
Normal file
1028
packages/editor-app/src/components/inspector/styles/inspector.css
Normal file
File diff suppressed because it is too large
Load Diff
177
packages/editor-app/src/components/inspector/types.ts
Normal file
177
packages/editor-app/src/components/inspector/types.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Inspector Type Definitions
|
||||
* Inspector 类型定义
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* 属性控件 Props
|
||||
* Property Control Props
|
||||
*/
|
||||
export interface PropertyControlProps<T = any> {
|
||||
/** 当前值 | Current value */
|
||||
value: T;
|
||||
/** 值变更回调 | Value change callback */
|
||||
onChange: (value: T) => void;
|
||||
/** 是否只读 | Read-only mode */
|
||||
readonly?: boolean;
|
||||
/** 属性元数据 | Property metadata */
|
||||
metadata?: PropertyMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性元数据
|
||||
* Property Metadata
|
||||
*/
|
||||
export interface PropertyMetadata {
|
||||
/** 最小值 | Minimum value */
|
||||
min?: number;
|
||||
/** 最大值 | Maximum value */
|
||||
max?: number;
|
||||
/** 步进值 | Step value */
|
||||
step?: number;
|
||||
/** 是否为整数 | Integer only */
|
||||
integer?: boolean;
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
/** 枚举选项 | Enum options */
|
||||
options?: Array<{ label: string; value: string | number }>;
|
||||
/** 文件扩展名 | File extensions */
|
||||
extensions?: string[];
|
||||
/** 资产类型 | Asset type */
|
||||
assetType?: string;
|
||||
/** 自定义数据 | Custom data */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性配置
|
||||
* Property Configuration
|
||||
*/
|
||||
export interface PropertyConfig {
|
||||
/** 属性名 | Property name */
|
||||
name: string;
|
||||
/** 显示标签 | Display label */
|
||||
label: string;
|
||||
/** 属性类型 | Property type */
|
||||
type: PropertyType;
|
||||
/** 属性元数据 | Property metadata */
|
||||
metadata?: PropertyMetadata;
|
||||
/** 分类 | Category */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性类型
|
||||
* Property Types
|
||||
*/
|
||||
export type PropertyType =
|
||||
| 'number'
|
||||
| 'string'
|
||||
| 'boolean'
|
||||
| 'enum'
|
||||
| 'vector2'
|
||||
| 'vector3'
|
||||
| 'vector4'
|
||||
| 'color'
|
||||
| 'asset'
|
||||
| 'entityRef'
|
||||
| 'array'
|
||||
| 'object';
|
||||
|
||||
/**
|
||||
* Section 配置
|
||||
* Section Configuration
|
||||
*/
|
||||
export interface SectionConfig {
|
||||
/** Section ID */
|
||||
id: string;
|
||||
/** 标题 | Title */
|
||||
title: string;
|
||||
/** 分类 | Category */
|
||||
category?: string;
|
||||
/** 默认展开 | Default expanded */
|
||||
defaultExpanded?: boolean;
|
||||
/** 属性列表 | Property list */
|
||||
properties: PropertyConfig[];
|
||||
/** 子 Section | Sub sections */
|
||||
subsections?: SectionConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类配置
|
||||
* Category Configuration
|
||||
*/
|
||||
export interface CategoryConfig {
|
||||
/** 分类 ID | Category ID */
|
||||
id: string;
|
||||
/** 显示名称 | Display name */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性控件接口
|
||||
* Property Control Interface
|
||||
*/
|
||||
export interface IPropertyControl<T = any> {
|
||||
/** 控件类型 | Control type */
|
||||
readonly type: string;
|
||||
/** 控件名称 | Control name */
|
||||
readonly name: string;
|
||||
/** 优先级 | Priority */
|
||||
readonly priority?: number;
|
||||
/** 检查是否可处理 | Check if can handle */
|
||||
canHandle?(fieldType: string, metadata?: PropertyMetadata): boolean;
|
||||
/** 渲染控件 | Render control */
|
||||
render(props: PropertyControlProps<T>): ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspector 面板 Props
|
||||
* Inspector Panel Props
|
||||
*/
|
||||
export interface InspectorPanelProps {
|
||||
/** 目标对象名称 | Target object name */
|
||||
targetName?: string;
|
||||
/** Section 列表 | Section list */
|
||||
sections: SectionConfig[];
|
||||
/** 分类列表 | Category list */
|
||||
categories?: CategoryConfig[];
|
||||
/** 当前分类 | Current category */
|
||||
currentCategory?: string;
|
||||
/** 分类变更回调 | Category change callback */
|
||||
onCategoryChange?: (category: string) => void;
|
||||
/** 属性值获取器 | Property value getter */
|
||||
getValue: (propertyName: string) => any;
|
||||
/** 属性值变更回调 | Property value change callback */
|
||||
onChange: (propertyName: string, value: any) => void;
|
||||
/** 是否只读 | Read-only mode */
|
||||
readonly?: boolean;
|
||||
/** 搜索关键词 | Search keyword */
|
||||
searchQuery?: string;
|
||||
/** 搜索变更回调 | Search change callback */
|
||||
onSearchChange?: (query: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量值类型
|
||||
* Vector Value Types
|
||||
*/
|
||||
export interface Vector2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Vector3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface Vector4 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}
|
||||
Reference in New Issue
Block a user