feat: UI输入框IME支持和编辑器Inspector重构 (#310)
UI系统改进: - 添加 IMEHelper 支持中文/日文/韩文输入法 - UIInputFieldComponent 添加组合输入状态管理 - UIInputSystem 添加 IME 事件处理 - UIInputFieldRenderSystem 优化渲染逻辑 - UIRenderCollector 增强纹理处理 引擎改进: - EngineBridge 添加新的渲染接口 - EngineRenderSystem 优化渲染流程 - Rust 引擎添加新的渲染功能 编辑器改进: - 新增模块化 Inspector 组件架构 - EntityRefField 增强实体引用选择 - 优化 FlexLayoutDock 和 SceneHierarchy 样式 - 添加国际化文本
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ArrayInput - 数组编辑控件
|
||||
* ArrayInput - Array editor control
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Plus, Trash2, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface ArrayInputProps<T = any> extends PropertyControlProps<T[]> {
|
||||
/** 元素渲染器 | Element renderer */
|
||||
renderElement?: (
|
||||
element: T,
|
||||
index: number,
|
||||
onChange: (value: T) => void,
|
||||
onRemove: () => void
|
||||
) => React.ReactNode;
|
||||
/** 创建新元素 | Create new element */
|
||||
createNewElement?: () => T;
|
||||
/** 最小元素数 | Minimum element count */
|
||||
minItems?: number;
|
||||
/** 最大元素数 | Maximum element count */
|
||||
maxItems?: number;
|
||||
/** 是否可排序 | Sortable */
|
||||
sortable?: boolean;
|
||||
/** 折叠标题 | Collapsed title */
|
||||
collapsedTitle?: (items: T[]) => string;
|
||||
}
|
||||
|
||||
export function ArrayInput<T = any>({
|
||||
value = [],
|
||||
onChange,
|
||||
readonly = false,
|
||||
renderElement,
|
||||
createNewElement,
|
||||
minItems = 0,
|
||||
maxItems,
|
||||
sortable = false,
|
||||
collapsedTitle
|
||||
}: ArrayInputProps<T>): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const items = value ?? [];
|
||||
const canAdd = !maxItems || items.length < maxItems;
|
||||
const canRemove = items.length > minItems;
|
||||
|
||||
// 展开/折叠 | Expand/Collapse
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 添加元素 | Add element
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canAdd || readonly) return;
|
||||
|
||||
const newElement = createNewElement ? createNewElement() : (null as T);
|
||||
onChange([...items, newElement]);
|
||||
}, [items, onChange, canAdd, readonly, createNewElement]);
|
||||
|
||||
// 移除元素 | Remove element
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
if (!canRemove || readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
}, [items, onChange, canRemove, readonly]);
|
||||
|
||||
// 更新元素 | Update element
|
||||
const handleElementChange = useCallback((index: number, newValue: T) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems[index] = newValue;
|
||||
onChange(newItems);
|
||||
}, [items, onChange, readonly]);
|
||||
|
||||
// ========== 拖拽排序 | Drag Sort ==========
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly) return;
|
||||
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
}, [sortable, readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly || dragIndex === null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverIndex(index);
|
||||
}, [sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!sortable || readonly || dragIndex === null || dragIndex === targetIndex) {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(dragIndex, 1);
|
||||
if (removed !== undefined) {
|
||||
newItems.splice(targetIndex, 0, removed);
|
||||
}
|
||||
|
||||
onChange(newItems);
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, [items, onChange, sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
// 获取折叠标题 | Get collapsed title
|
||||
const getTitle = (): string => {
|
||||
if (collapsedTitle) {
|
||||
return collapsedTitle(items);
|
||||
}
|
||||
return `${items.length} item${items.length !== 1 ? 's' : ''}`;
|
||||
};
|
||||
|
||||
// 默认元素渲染 | Default element renderer
|
||||
const defaultRenderElement = (element: T, index: number) => (
|
||||
<div className="inspector-array-element-default">
|
||||
{String(element)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inspector-array-input">
|
||||
{/* 头部 | Header */}
|
||||
<div className="inspector-array-header" onClick={toggleExpanded}>
|
||||
<span className="inspector-array-arrow">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="inspector-array-title">{getTitle()}</span>
|
||||
|
||||
{/* 添加按钮 | Add button */}
|
||||
{canAdd && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-add"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAdd();
|
||||
}}
|
||||
title="Add element"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元素列表 | Element list */}
|
||||
{expanded && (
|
||||
<div className="inspector-array-elements">
|
||||
{items.map((element, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`inspector-array-element ${dragOverIndex === index ? 'drag-over' : ''} ${dragIndex === index ? 'dragging' : ''}`}
|
||||
draggable={sortable && !readonly}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 拖拽手柄 | Drag handle */}
|
||||
{sortable && !readonly && (
|
||||
<div className="inspector-array-handle">
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 索引 | Index */}
|
||||
<span className="inspector-array-index">{index}</span>
|
||||
|
||||
{/* 内容 | Content */}
|
||||
<div className="inspector-array-content">
|
||||
{renderElement
|
||||
? renderElement(
|
||||
element,
|
||||
index,
|
||||
(val) => handleElementChange(index, val),
|
||||
() => handleRemove(index)
|
||||
)
|
||||
: defaultRenderElement(element, index)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 | Remove button */}
|
||||
{canRemove && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-remove"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{items.length === 0 && (
|
||||
<div className="inspector-array-empty">
|
||||
No items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* AssetInput - 资产引用选择控件
|
||||
* AssetInput - Asset reference picker control
|
||||
*
|
||||
* 功能 | Features:
|
||||
* - 缩略图预览 | Thumbnail preview
|
||||
* - 下拉选择 | Dropdown selection
|
||||
* - 拖放支持 | Drag and drop support
|
||||
* - 操作按钮 | Action buttons (browse, copy, locate, clear)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, FolderOpen, Copy, Navigation, X, FileImage, Image, Music, Film, FileText, Box } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface AssetReference {
|
||||
/** 资产 ID | Asset ID */
|
||||
id: string;
|
||||
/** 资产路径 | Asset path */
|
||||
path?: string;
|
||||
/** 资产类型 | Asset type */
|
||||
type?: string;
|
||||
/** 缩略图 URL | Thumbnail URL */
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface AssetInputProps extends PropertyControlProps<AssetReference | string | null> {
|
||||
/** 允许的资产类型 | Allowed asset types */
|
||||
assetTypes?: string[];
|
||||
/** 允许的文件扩展名 | Allowed file extensions */
|
||||
extensions?: string[];
|
||||
/** 打开资产选择器回调 | Open asset picker callback */
|
||||
onPickAsset?: () => void;
|
||||
/** 打开资产回调 | Open asset callback */
|
||||
onOpenAsset?: (asset: AssetReference) => void;
|
||||
/** 定位资产回调 | Locate asset callback */
|
||||
onLocateAsset?: (asset: AssetReference) => void;
|
||||
/** 复制路径回调 | Copy path callback */
|
||||
onCopyPath?: (path: string) => void;
|
||||
/** 获取缩略图 URL | Get thumbnail URL */
|
||||
getThumbnail?: (asset: AssetReference) => string | undefined;
|
||||
/** 最近使用的资产 | Recently used assets */
|
||||
recentAssets?: AssetReference[];
|
||||
/** 显示缩略图 | Show thumbnail */
|
||||
showThumbnail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产显示名称
|
||||
* Get asset display name
|
||||
*/
|
||||
const getAssetDisplayName = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
// 从路径中提取文件名 | Extract filename from path
|
||||
const parts = value.split('/');
|
||||
return parts[parts.length - 1] ?? value;
|
||||
}
|
||||
if (value.path) {
|
||||
const parts = value.path.split('/');
|
||||
return parts[parts.length - 1] ?? value.id;
|
||||
}
|
||||
return value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取资产路径
|
||||
* Get asset path
|
||||
*/
|
||||
const getAssetPath = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return value.path || value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据扩展名获取图标
|
||||
* Get icon by extension
|
||||
*/
|
||||
const getAssetIcon = (value: AssetReference | string | null) => {
|
||||
const path = getAssetPath(value).toLowerCase();
|
||||
if (path.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/)) return Image;
|
||||
if (path.match(/\.(mp3|wav|ogg|flac|aac)$/)) return Music;
|
||||
if (path.match(/\.(mp4|webm|avi|mov|mkv)$/)) return Film;
|
||||
if (path.match(/\.(txt|json|xml|yaml|yml|md)$/)) return FileText;
|
||||
if (path.match(/\.(fbx|obj|gltf|glb|dae)$/)) return Box;
|
||||
return FileImage;
|
||||
};
|
||||
|
||||
export const AssetInput: React.FC<AssetInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
extensions,
|
||||
onPickAsset,
|
||||
onOpenAsset,
|
||||
onLocateAsset,
|
||||
onCopyPath,
|
||||
getThumbnail,
|
||||
recentAssets = [],
|
||||
showThumbnail = true
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = getAssetDisplayName(value);
|
||||
const assetPath = getAssetPath(value);
|
||||
const hasValue = !!value;
|
||||
const IconComponent = getAssetIcon(value);
|
||||
|
||||
// 获取缩略图 | Get thumbnail
|
||||
const thumbnailUrl = value && getThumbnail
|
||||
? getThumbnail(typeof value === 'string' ? { id: value, path: value } : value)
|
||||
: undefined;
|
||||
|
||||
// 关闭下拉菜单 | Close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDropdown]);
|
||||
|
||||
// 清除值 | Clear value
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
onChange(null);
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
// 打开选择器 | Open picker
|
||||
const handleBrowse = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly && onPickAsset) {
|
||||
onPickAsset();
|
||||
}
|
||||
setShowDropdown(false);
|
||||
}, [readonly, onPickAsset]);
|
||||
|
||||
// 定位资产 | Locate asset
|
||||
const handleLocate = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (value && onLocateAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onLocateAsset(asset);
|
||||
}
|
||||
}, [value, onLocateAsset]);
|
||||
|
||||
// 复制路径 | Copy path
|
||||
const handleCopy = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (assetPath) {
|
||||
if (onCopyPath) {
|
||||
onCopyPath(assetPath);
|
||||
} else {
|
||||
navigator.clipboard.writeText(assetPath);
|
||||
}
|
||||
}
|
||||
}, [assetPath, onCopyPath]);
|
||||
|
||||
// 双击打开资产 | Double click to open asset
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (value && onOpenAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onOpenAsset(asset);
|
||||
}
|
||||
}, [value, onOpenAsset]);
|
||||
|
||||
// 切换下拉菜单 | Toggle dropdown
|
||||
const handleToggleDropdown = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setShowDropdown(!showDropdown);
|
||||
}
|
||||
}, [readonly, showDropdown]);
|
||||
|
||||
// 选择资产 | Select asset
|
||||
const handleSelectAsset = useCallback((asset: AssetReference) => {
|
||||
onChange(asset);
|
||||
setShowDropdown(false);
|
||||
}, [onChange]);
|
||||
|
||||
// 拖放处理 | Drag and drop handling
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
const assetId = e.dataTransfer.getData('asset-id');
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
const assetType = e.dataTransfer.getData('asset-type');
|
||||
|
||||
if (assetId || assetPath) {
|
||||
// 检查扩展名匹配 | Check extension match
|
||||
if (extensions && assetPath) {
|
||||
const ext = assetPath.split('.').pop()?.toLowerCase();
|
||||
if (ext && !extensions.some(e => e.toLowerCase() === ext || e.toLowerCase() === `.${ext}`)) {
|
||||
console.warn(`Extension "${ext}" not allowed. Allowed: ${extensions.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
id: assetId || assetPath,
|
||||
path: assetPath || undefined,
|
||||
type: assetType || undefined
|
||||
});
|
||||
}
|
||||
}, [onChange, readonly, extensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`inspector-asset-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 缩略图 | Thumbnail */}
|
||||
{showThumbnail && (
|
||||
<div className="inspector-asset-thumbnail" onDoubleClick={handleDoubleClick}>
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<IconComponent size={16} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 值显示和下拉按钮 | Value display and dropdown button */}
|
||||
<div className="inspector-asset-main" onClick={handleToggleDropdown}>
|
||||
<div
|
||||
className="inspector-asset-value"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title={assetPath || 'None'}
|
||||
>
|
||||
{displayName || <span className="inspector-asset-placeholder">None</span>}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<ChevronDown size={12} className={`inspector-asset-arrow ${showDropdown ? 'open' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="inspector-asset-actions">
|
||||
{/* 定位按钮 | Locate button */}
|
||||
{hasValue && onLocateAsset && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleLocate}
|
||||
title="Locate in Content Browser"
|
||||
>
|
||||
<Navigation size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 | Copy button */}
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleCopy}
|
||||
title="Copy Path"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 浏览按钮 | Browse button */}
|
||||
{onPickAsset && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleBrowse}
|
||||
title="Browse"
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 | Clear button */}
|
||||
{hasValue && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn inspector-asset-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单 | Dropdown menu */}
|
||||
{showDropdown && (
|
||||
<div ref={dropdownRef} className="inspector-asset-dropdown">
|
||||
{/* 浏览选项 | Browse option */}
|
||||
{onPickAsset && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleBrowse}>
|
||||
<FolderOpen size={14} />
|
||||
<span>Browse...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 清除选项 | Clear option */}
|
||||
{hasValue && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleClear}>
|
||||
<X size={14} />
|
||||
<span>Clear</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分割线 | Divider */}
|
||||
{recentAssets.length > 0 && (
|
||||
<>
|
||||
<div className="inspector-asset-dropdown-divider" />
|
||||
<div className="inspector-asset-dropdown-label">Recent</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 最近使用 | Recent assets */}
|
||||
{recentAssets.map((asset, index) => (
|
||||
<div
|
||||
key={asset.id || index}
|
||||
className="inspector-asset-dropdown-item"
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
>
|
||||
{asset.thumbnail ? (
|
||||
<img src={asset.thumbnail} alt="" className="inspector-asset-dropdown-thumb" />
|
||||
) : (
|
||||
<FileImage size={14} />
|
||||
)}
|
||||
<span>{getAssetDisplayName(asset)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{!onPickAsset && !hasValue && recentAssets.length === 0 && (
|
||||
<div className="inspector-asset-dropdown-empty">No assets available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* BooleanInput - 复选框控件
|
||||
* BooleanInput - Checkbox control
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface BooleanInputProps extends PropertyControlProps<boolean> {}
|
||||
|
||||
export const BooleanInput: React.FC<BooleanInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (!readonly) {
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!readonly && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inspector-checkbox ${value ? 'checked' : ''}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
role="checkbox"
|
||||
aria-checked={value}
|
||||
aria-disabled={readonly}
|
||||
>
|
||||
<Check size={12} className="inspector-checkbox-icon" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ColorInput - 颜色选择控件
|
||||
* ColorInput - Color picker control
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface ColorValue {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface ColorInputProps extends PropertyControlProps<ColorValue | string> {
|
||||
/** 是否显示 Alpha 通道 | Show alpha channel */
|
||||
showAlpha?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将颜色值转换为 CSS 颜色字符串
|
||||
* Convert color value to CSS color string
|
||||
*/
|
||||
const toHexString = (color: ColorValue | string): string => {
|
||||
if (typeof color === 'string') {
|
||||
return color;
|
||||
}
|
||||
|
||||
const r = Math.round(Math.max(0, Math.min(255, color.r)));
|
||||
const g = Math.round(Math.max(0, Math.min(255, color.g)));
|
||||
const b = Math.round(Math.max(0, Math.min(255, color.b)));
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 Hex 字符串解析颜色
|
||||
* Parse color from hex string
|
||||
*/
|
||||
const parseHex = (hex: string): ColorValue => {
|
||||
const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i);
|
||||
if (match && match[1] && match[2] && match[3]) {
|
||||
return {
|
||||
r: parseInt(match[1], 16),
|
||||
g: parseInt(match[2], 16),
|
||||
b: parseInt(match[3], 16),
|
||||
a: match[4] ? parseInt(match[4], 16) / 255 : 1
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 1 };
|
||||
};
|
||||
|
||||
export const ColorInput: React.FC<ColorInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
showAlpha = false
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// 标准化颜色值 | Normalize color value
|
||||
const normalizedValue: ColorValue = typeof value === 'string'
|
||||
? parseHex(value)
|
||||
: (value ?? { r: 0, g: 0, b: 0, a: 1 });
|
||||
|
||||
const hexValue = toHexString(normalizedValue);
|
||||
|
||||
const handleColorChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newHex = e.target.value;
|
||||
const newColor = parseHex(newHex);
|
||||
|
||||
// 保持原始 alpha | Preserve original alpha
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
newColor.a = value.a;
|
||||
}
|
||||
|
||||
onChange(typeof value === 'string' ? newHex : newColor);
|
||||
}, [onChange, readonly, value]);
|
||||
|
||||
const handleSwatchClick = useCallback(() => {
|
||||
if (readonly) return;
|
||||
inputRef.current?.click();
|
||||
}, [readonly]);
|
||||
|
||||
// Hex 输入处理 | Hex input handling
|
||||
const [hexInput, setHexInput] = useState(hexValue);
|
||||
|
||||
useEffect(() => {
|
||||
setHexInput(hexValue);
|
||||
}, [hexValue]);
|
||||
|
||||
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setHexInput(newValue);
|
||||
|
||||
// 验证并应用 | Validate and apply
|
||||
if (/^#?[a-f\d]{6}$/i.test(newValue)) {
|
||||
const newColor = parseHex(newValue);
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
newColor.a = value.a;
|
||||
}
|
||||
onChange(typeof value === 'string' ? newValue : newColor);
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
const handleHexInputBlur = useCallback(() => {
|
||||
// 恢复有效值 | Restore valid value
|
||||
setHexInput(hexValue);
|
||||
}, [hexValue]);
|
||||
|
||||
return (
|
||||
<div className="inspector-color-input">
|
||||
{/* 颜色预览块 | Color swatch */}
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-color-swatch"
|
||||
style={{ backgroundColor: hexValue }}
|
||||
onClick={handleSwatchClick}
|
||||
disabled={readonly}
|
||||
title="Click to pick color"
|
||||
/>
|
||||
|
||||
{/* 隐藏的原生颜色选择器 | Hidden native color picker */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
className="inspector-color-native"
|
||||
value={hexValue}
|
||||
onChange={handleColorChange}
|
||||
disabled={readonly}
|
||||
/>
|
||||
|
||||
{/* Hex 输入框 | Hex input */}
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-color-hex"
|
||||
value={hexInput}
|
||||
onChange={handleHexInputChange}
|
||||
onBlur={handleHexInputBlur}
|
||||
disabled={readonly}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
|
||||
{/* Alpha 滑块 | Alpha slider */}
|
||||
{showAlpha && (
|
||||
<input
|
||||
type="range"
|
||||
className="inspector-color-alpha"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={normalizedValue.a ?? 1}
|
||||
onChange={(e) => {
|
||||
if (readonly) return;
|
||||
const newAlpha = parseFloat(e.target.value);
|
||||
onChange({
|
||||
...normalizedValue,
|
||||
a: newAlpha
|
||||
});
|
||||
}}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* EntityRefInput - 实体引用选择控件
|
||||
* EntityRefInput - Entity reference picker control
|
||||
*
|
||||
* 支持从场景层级面板拖放实体
|
||||
* Supports drag and drop entities from scene hierarchy panel
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { Box, X, Target, Link } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface EntityReference {
|
||||
/** 实体 ID | Entity ID */
|
||||
id: number | string;
|
||||
/** 实体名称 | Entity name */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface EntityRefInputProps extends PropertyControlProps<EntityReference | number | string | null> {
|
||||
/** 实体名称解析器 | Entity name resolver */
|
||||
resolveEntityName?: (id: number | string) => string | undefined;
|
||||
/** 选择实体回调 | Select entity callback */
|
||||
onSelectEntity?: () => void;
|
||||
/** 定位实体回调 | Locate entity callback */
|
||||
onLocateEntity?: (id: number | string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体 ID
|
||||
* Get entity ID
|
||||
*/
|
||||
const getEntityId = (value: EntityReference | number | string | null): number | string | null => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'object') return value.id;
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
* Get display name
|
||||
*/
|
||||
const getDisplayName = (
|
||||
value: EntityReference | number | string | null,
|
||||
resolver?: (id: number | string) => string | undefined
|
||||
): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
// 如果是完整引用对象且有名称 | If full reference with name
|
||||
if (typeof value === 'object' && value.name) {
|
||||
return value.name;
|
||||
}
|
||||
|
||||
const id = getEntityId(value);
|
||||
if (id === null) return '';
|
||||
|
||||
// 尝试通过解析器获取名称 | Try to resolve name
|
||||
if (resolver) {
|
||||
const resolved = resolver(id);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
// 回退到 ID | Fallback to ID
|
||||
return `Entity ${id}`;
|
||||
};
|
||||
|
||||
export const EntityRefInput: React.FC<EntityRefInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
resolveEntityName,
|
||||
onSelectEntity,
|
||||
onLocateEntity
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const entityId = getEntityId(value);
|
||||
const displayName = getDisplayName(value, resolveEntityName);
|
||||
const hasValue = entityId !== null;
|
||||
|
||||
// 清除值 | Clear value
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
onChange(null);
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
// 定位实体 | Locate entity
|
||||
const handleLocate = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (entityId !== null && onLocateEntity) {
|
||||
onLocateEntity(entityId);
|
||||
}
|
||||
}, [entityId, onLocateEntity]);
|
||||
|
||||
// 选择实体 | Select entity
|
||||
const handleSelect = useCallback(() => {
|
||||
if (!readonly && onSelectEntity) {
|
||||
onSelectEntity();
|
||||
}
|
||||
}, [readonly, onSelectEntity]);
|
||||
|
||||
// ========== 拖放处理 | Drag and Drop Handling ==========
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 检查是否有实体数据 | Check for entity data
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
if (types.includes('entity-id') || types.includes('text/plain')) {
|
||||
setIsDragOver(true);
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 必须设置 dropEffect 才能接收 drop | Must set dropEffect to receive drop
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 确保离开的是当前元素而非子元素 | Ensure leaving current element not child
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (dropZoneRef.current && !dropZoneRef.current.contains(relatedTarget)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
// 尝试获取实体 ID | Try to get entity ID
|
||||
let droppedId: number | string | null = null;
|
||||
let droppedName: string | undefined;
|
||||
|
||||
// 优先使用 entity-id | Prefer entity-id
|
||||
const entityIdData = e.dataTransfer.getData('entity-id');
|
||||
if (entityIdData) {
|
||||
droppedId = isNaN(Number(entityIdData)) ? entityIdData : Number(entityIdData);
|
||||
}
|
||||
|
||||
// 获取实体名称 | Get entity name
|
||||
const entityNameData = e.dataTransfer.getData('entity-name');
|
||||
if (entityNameData) {
|
||||
droppedName = entityNameData;
|
||||
}
|
||||
|
||||
// 回退到 text/plain | Fallback to text/plain
|
||||
if (droppedId === null) {
|
||||
const textData = e.dataTransfer.getData('text/plain');
|
||||
if (textData) {
|
||||
droppedId = isNaN(Number(textData)) ? textData : Number(textData);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedId !== null) {
|
||||
// 创建完整引用或简单值 | Create full reference or simple value
|
||||
if (droppedName) {
|
||||
onChange({ id: droppedId, name: droppedName });
|
||||
} else {
|
||||
onChange(droppedId);
|
||||
}
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={`inspector-entity-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 图标 | Icon */}
|
||||
<Box size={14} className="inspector-entity-icon" />
|
||||
|
||||
{/* 值显示 | Value display */}
|
||||
<div
|
||||
className="inspector-entity-value"
|
||||
onClick={handleSelect}
|
||||
title={hasValue ? `${displayName} (ID: ${entityId})` : 'None - Drag entity here'}
|
||||
>
|
||||
{displayName || <span className="inspector-entity-placeholder">None</span>}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="inspector-entity-actions">
|
||||
{/* 定位按钮 | Locate button */}
|
||||
{hasValue && onLocateEntity && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn"
|
||||
onClick={handleLocate}
|
||||
title="Locate in hierarchy"
|
||||
>
|
||||
<Target size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 选择按钮 | Select button */}
|
||||
{onSelectEntity && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn"
|
||||
onClick={handleSelect}
|
||||
title="Select entity"
|
||||
>
|
||||
<Link size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 | Clear button */}
|
||||
{hasValue && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-entity-btn inspector-entity-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拖放提示 | Drop hint */}
|
||||
{isDragOver && (
|
||||
<div className="inspector-entity-drop-hint">
|
||||
Drop to assign
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* EnumInput - 下拉选择控件
|
||||
* EnumInput - Dropdown select control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface EnumOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface EnumInputProps extends PropertyControlProps<string | number> {
|
||||
/** 选项列表 | Options list */
|
||||
options: EnumOption[];
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const EnumInput: React.FC<EnumInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
options = [],
|
||||
placeholder = '选择...'
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击外部关闭 | Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!readonly) {
|
||||
setIsOpen(prev => !prev);
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleSelect = useCallback((optionValue: string | number) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value);
|
||||
const displayValue = selectedOption?.label ?? placeholder;
|
||||
|
||||
return (
|
||||
<div className="inspector-dropdown" ref={containerRef}>
|
||||
<div
|
||||
className={`inspector-dropdown-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="inspector-dropdown-value">{displayValue}</span>
|
||||
<ChevronDown size={12} className="inspector-dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="inspector-dropdown-menu">
|
||||
{options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`inspector-dropdown-item ${option.value === value ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* NumberInput - 数值输入控件
|
||||
* NumberInput - Number input control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface NumberInputProps extends PropertyControlProps<number> {
|
||||
/** 最小值 | Minimum value */
|
||||
min?: number;
|
||||
/** 最大值 | Maximum value */
|
||||
max?: number;
|
||||
/** 步进值 | Step value */
|
||||
step?: number;
|
||||
/** 是否为整数 | Integer only */
|
||||
integer?: boolean;
|
||||
}
|
||||
|
||||
export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
integer = false
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// 同步外部值 | Sync external value
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
let num = parseFloat(localValue);
|
||||
|
||||
if (isNaN(num)) {
|
||||
num = value ?? 0;
|
||||
}
|
||||
|
||||
// 应用约束 | Apply constraints
|
||||
if (integer) {
|
||||
num = Math.round(num);
|
||||
}
|
||||
if (min !== undefined) {
|
||||
num = Math.max(min, num);
|
||||
}
|
||||
if (max !== undefined) {
|
||||
num = Math.min(max, num);
|
||||
}
|
||||
|
||||
setLocalValue(String(num));
|
||||
if (num !== value) {
|
||||
onChange(num);
|
||||
}
|
||||
}, [localValue, value, onChange, integer, min, max]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-input"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* PropertyRow - 属性行容器
|
||||
* PropertyRow - Property row container
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export interface PropertyRowProps {
|
||||
/** 属性标签 | Property label */
|
||||
label: ReactNode;
|
||||
/** 标签工具提示 | Label tooltip */
|
||||
labelTitle?: string;
|
||||
/** 嵌套深度 | Nesting depth */
|
||||
depth?: number;
|
||||
/** 标签是否可拖拽(用于数值调整)| Label draggable for value adjustment */
|
||||
draggable?: boolean;
|
||||
/** 拖拽开始回调 | Drag start callback */
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
/** 子内容(控件)| Children content (control) */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PropertyRow: React.FC<PropertyRowProps> = ({
|
||||
label,
|
||||
labelTitle,
|
||||
depth = 0,
|
||||
draggable = false,
|
||||
onDragStart,
|
||||
children
|
||||
}) => {
|
||||
const labelClassName = `inspector-property-label ${draggable ? 'draggable' : ''}`;
|
||||
|
||||
// 生成 title | Generate title
|
||||
const title = labelTitle ?? (typeof label === 'string' ? label : undefined);
|
||||
|
||||
return (
|
||||
<div className="inspector-property-row" data-depth={depth}>
|
||||
<span
|
||||
className={labelClassName}
|
||||
title={title}
|
||||
onMouseDown={draggable ? onDragStart : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="inspector-property-control">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* StringInput - 文本输入控件
|
||||
* StringInput - String input control
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface StringInputProps extends PropertyControlProps<string> {
|
||||
/** 占位文本 | Placeholder text */
|
||||
placeholder?: string;
|
||||
/** 是否多行 | Multiline mode */
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export const StringInput: React.FC<StringInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
placeholder = ''
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value ?? '');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// 同步外部值 | Sync external value
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(value ?? '');
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, value, onChange]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(value ?? '');
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="inspector-input"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* VectorInput - 向量输入控件(支持 2D/3D/4D)
|
||||
* VectorInput - Vector input control (supports 2D/3D/4D)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { PropertyControlProps, Vector2, Vector3, Vector4 } from '../types';
|
||||
|
||||
type VectorValue = Vector2 | Vector3 | Vector4;
|
||||
type AxisKey = 'x' | 'y' | 'z' | 'w';
|
||||
|
||||
export interface VectorInputProps extends PropertyControlProps<VectorValue> {
|
||||
/** 向量维度 | Vector dimensions */
|
||||
dimensions?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
interface AxisInputProps {
|
||||
axis: AxisKey;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const AxisInput: React.FC<AxisInputProps> = ({ axis, value, onChange, readonly }) => {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setLocalValue(String(value ?? 0));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
let num = parseFloat(localValue);
|
||||
if (isNaN(num)) {
|
||||
num = value ?? 0;
|
||||
}
|
||||
setLocalValue(String(num));
|
||||
if (num !== value) {
|
||||
onChange(num);
|
||||
}
|
||||
}, [localValue, value, onChange]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(value ?? 0));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="inspector-vector-axis">
|
||||
<span className={`inspector-vector-axis-bar ${axis}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VectorInput: React.FC<VectorInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
dimensions = 3
|
||||
}) => {
|
||||
const axes = useMemo<AxisKey[]>(() => {
|
||||
if (dimensions === 2) return ['x', 'y'];
|
||||
if (dimensions === 4) return ['x', 'y', 'z', 'w'];
|
||||
return ['x', 'y', 'z'];
|
||||
}, [dimensions]);
|
||||
|
||||
const handleAxisChange = useCallback((axis: AxisKey, newValue: number) => {
|
||||
const newVector = { ...value, [axis]: newValue } as VectorValue;
|
||||
onChange(newVector);
|
||||
}, [value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="inspector-vector-input">
|
||||
{axes.map(axis => (
|
||||
<AxisInput
|
||||
key={axis}
|
||||
axis={axis}
|
||||
value={(value as any)?.[axis] ?? 0}
|
||||
onChange={(v) => handleAxisChange(axis, v)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Inspector Controls
|
||||
* Inspector 控件导出
|
||||
*/
|
||||
|
||||
// 布局组件 | Layout components
|
||||
export * from './PropertyRow';
|
||||
|
||||
// 基础控件 | Basic controls
|
||||
export * from './NumberInput';
|
||||
export * from './StringInput';
|
||||
export * from './BooleanInput';
|
||||
export * from './VectorInput';
|
||||
export * from './EnumInput';
|
||||
|
||||
// 高级控件 | Advanced controls
|
||||
export * from './ColorInput';
|
||||
export * from './AssetInput';
|
||||
export * from './EntityRefInput';
|
||||
export * from './ArrayInput';
|
||||
Reference in New Issue
Block a user