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