feat: UI输入框IME支持和编辑器Inspector重构 (#310)

UI系统改进:
- 添加 IMEHelper 支持中文/日文/韩文输入法
- UIInputFieldComponent 添加组合输入状态管理
- UIInputSystem 添加 IME 事件处理
- UIInputFieldRenderSystem 优化渲染逻辑
- UIRenderCollector 增强纹理处理

引擎改进:
- EngineBridge 添加新的渲染接口
- EngineRenderSystem 优化渲染流程
- Rust 引擎添加新的渲染功能

编辑器改进:
- 新增模块化 Inspector 组件架构
- EntityRefField 增强实体引用选择
- 优化 FlexLayoutDock 和 SceneHierarchy 样式
- 添加国际化文本
This commit is contained in:
YHH
2025-12-19 15:45:14 +08:00
committed by GitHub
parent 536c4c5593
commit ecdb8f2021
46 changed files with 5825 additions and 257 deletions

View File

@@ -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
// 检查值是否为 GUIDUUID 格式)并尝试解析为路径
// 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,8 @@
/**
* Inspector Header Components
* Inspector 头部组件导出
*/
export * from './InspectorHeader';
export * from './PropertySearch';
export * from './CategoryTabs';

View 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';

View File

@@ -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>
);
};

View File

@@ -0,0 +1,6 @@
/**
* Inspector Sections
* Inspector 分组导出
*/
export * from './PropertySection';

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View 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;
}