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

@@ -293,6 +293,32 @@ export class EngineBridge implements ITextureEngineBridge {
this.getEngine().renderOverlay(); this.getEngine().renderOverlay();
} }
/**
* Set scissor rect for clipping (screen coordinates, Y-down).
* 设置裁剪矩形屏幕坐标Y 轴向下)。
*
* Content outside this rect will be clipped.
* 此矩形外的内容将被裁剪。
*
* @param x - Left edge in screen coordinates | 屏幕坐标中的左边缘
* @param y - Top edge in screen coordinates (Y-down) | 屏幕坐标中的上边缘Y 向下)
* @param width - Rect width | 矩形宽度
* @param height - Rect height | 矩形高度
*/
setScissorRect(x: number, y: number, width: number, height: number): void {
if (!this.initialized) return;
this.getEngine().setScissorRect(x, y, width, height);
}
/**
* Clear scissor rect (disable clipping).
* 清除裁剪矩形(禁用裁剪)。
*/
clearScissorRect(): void {
if (!this.initialized) return;
this.getEngine().clearScissorRect();
}
/** /**
* Load a texture. * Load a texture.
* 加载纹理。 * 加载纹理。

View File

@@ -51,6 +51,13 @@ export interface ProviderRenderData {
materialIds?: Uint32Array; materialIds?: Uint32Array;
/** Material overrides (per-group). | 材质覆盖(按组)。 */ /** Material overrides (per-group). | 材质覆盖(按组)。 */
materialOverrides?: MaterialOverrides; materialOverrides?: MaterialOverrides;
/**
* Clip rectangle for scissor test (screen coordinates).
* All primitives in this batch will be clipped to this rect.
* 裁剪矩形用于 scissor test屏幕坐标
* 此批次中的所有原语将被裁剪到此矩形。
*/
clipRect?: { x: number; y: number; width: number; height: number };
} }
/** /**
@@ -615,16 +622,77 @@ export class EngineRenderSystem extends EntitySystem {
this.bridge.pushScreenSpaceMode(canvasWidth, canvasHeight); this.bridge.pushScreenSpaceMode(canvasWidth, canvasHeight);
// Clear batcher for screen space content // Group sprites by clipRect (in render order)
// 清空批处理器用于屏幕空间内容 // 按 clipRect 分组 sprites按渲染顺序
this.batcher.clear(); type ClipGroup = {
clipRect: { x: number; y: number; width: number; height: number } | undefined;
sprites: SpriteRenderData[];
};
// Submit screen space sprites const clipGroups: ClipGroup[] = [];
// 提交屏幕空间 sprites let currentClipRect: { x: number; y: number; width: number; height: number } | undefined = undefined;
let currentGroup: SpriteRenderData[] = [];
// Helper to check if two clip rects are equal
// 辅助函数检查两个裁剪矩形是否相等
const clipRectsEqual = (
a: { x: number; y: number; width: number; height: number } | undefined,
b: { x: number; y: number; width: number; height: number } | undefined
): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
};
// Group sprites by consecutive clipRect
// 按连续的 clipRect 分组 sprites
for (const item of screenSpaceItems) { for (const item of screenSpaceItems) {
for (const sprite of item.sprites) { for (const sprite of item.sprites) {
this.batcher.addSprite(sprite); const spriteClipRect = sprite.clipRect;
if (!clipRectsEqual(spriteClipRect, currentClipRect)) {
// Save current group if not empty
// 如果当前组不为空则保存
if (currentGroup.length > 0) {
clipGroups.push({ clipRect: currentClipRect, sprites: currentGroup });
} }
// Start new group
// 开始新组
currentClipRect = spriteClipRect;
currentGroup = [sprite];
} else {
currentGroup.push(sprite);
}
}
}
// Don't forget the last group
// 别忘了最后一组
if (currentGroup.length > 0) {
clipGroups.push({ clipRect: currentClipRect, sprites: currentGroup });
}
// Render each clip group
// 渲染每个裁剪组
for (const group of clipGroups) {
// Set or clear scissor rect
// 设置或清除裁剪矩形
if (group.clipRect) {
this.bridge.setScissorRect(
group.clipRect.x,
group.clipRect.y,
group.clipRect.width,
group.clipRect.height
);
} else {
this.bridge.clearScissorRect();
}
// Clear batcher and add sprites
// 清空批处理器并添加 sprites
this.batcher.clear();
for (const sprite of group.sprites) {
this.batcher.addSprite(sprite);
} }
if (!this.batcher.isEmpty) { if (!this.batcher.isEmpty) {
@@ -639,6 +707,11 @@ export class EngineRenderSystem extends EntitySystem {
// 渲染叠加层(不清屏) // 渲染叠加层(不清屏)
this.bridge.renderOverlay(); this.bridge.renderOverlay();
} }
}
// Clear scissor rect after all groups
// 所有组渲染完后清除裁剪矩形
this.bridge.clearScissorRect();
// Restore world space camera // Restore world space camera
// 恢复世界空间相机 // 恢复世界空间相机
@@ -802,6 +875,7 @@ export class EngineRenderSystem extends EntitySystem {
// 检查材质数据 // 检查材质数据
const hasMaterialIds = data.materialIds && data.materialIds.length > 0; const hasMaterialIds = data.materialIds && data.materialIds.length > 0;
const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0; const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0;
const hasClipRect = !!data.clipRect;
const sprites: SpriteRenderData[] = []; const sprites: SpriteRenderData[] = [];
for (let i = 0; i < data.tileCount; i++) { for (let i = 0; i < data.tileCount; i++) {
@@ -836,6 +910,11 @@ export class EngineRenderSystem extends EntitySystem {
if (hasMaterialOverrides) { if (hasMaterialOverrides) {
renderData.materialOverrides = data.materialOverrides; renderData.materialOverrides = data.materialOverrides;
} }
// Add clipRect if present (all sprites in batch share same clipRect)
// 如果存在 clipRect添加它批次中所有精灵共享相同 clipRect
if (hasClipRect) {
renderData.clipRect = data.clipRect;
}
sprites.push(renderData); sprites.push(renderData);
} }

View File

@@ -52,6 +52,13 @@ export interface SpriteRenderData {
* 材质属性覆盖(实例级别)。 * 材质属性覆盖(实例级别)。
*/ */
materialOverrides?: MaterialOverrides; materialOverrides?: MaterialOverrides;
/**
* Clip rectangle for scissor test (screen coordinates).
* Content outside this rect will be clipped.
* 裁剪矩形用于 scissor test屏幕坐标
* 此矩形外的内容将被裁剪。
*/
clipRect?: { x: number; y: number; width: number; height: number };
} }
/** /**

View File

@@ -217,6 +217,20 @@ export class GameEngine {
* * `id` - Texture ID | 纹理ID * * `id` - Texture ID | 纹理ID
*/ */
isTextureReady(id: number): boolean; isTextureReady(id: number): boolean;
/**
* Set scissor rect for clipping (screen coordinates, Y-down).
* 设置裁剪矩形屏幕坐标Y 轴向下)。
*
* Content outside this rect will be clipped.
* 此矩形外的内容将被裁剪。
*
* # Arguments | 参数
* * `x` - Left edge in screen coordinates | 屏幕坐标中的左边缘
* * `y` - Top edge in screen coordinates (Y-down) | 屏幕坐标中的上边缘Y 向下)
* * `width` - Rect width | 矩形宽度
* * `height` - Rect height | 矩形高度
*/
setScissorRect(x: number, y: number, width: number, height: number): void;
/** /**
* Add a capsule gizmo outline. * Add a capsule gizmo outline.
* 添加胶囊Gizmo边框。 * 添加胶囊Gizmo边框。
@@ -269,6 +283,11 @@ export class GameEngine {
* 请谨慎使用,因为所有纹理引用都将变得无效。 * 请谨慎使用,因为所有纹理引用都将变得无效。
*/ */
clearAllTextures(): void; clearAllTextures(): void;
/**
* Clear scissor rect (disable clipping).
* 清除裁剪矩形(禁用裁剪)。
*/
clearScissorRect(): void;
/** /**
* Render to a specific viewport. * Render to a specific viewport.
* 渲染到特定视口。 * 渲染到特定视口。
@@ -489,6 +508,7 @@ export interface InitOutput {
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void; readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void; readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_clearAllTextures: (a: number) => void; readonly gameengine_clearAllTextures: (a: number) => void;
readonly gameengine_clearScissorRect: (a: number) => void;
readonly gameengine_clearTexturePathCache: (a: number) => void; readonly gameengine_clearTexturePathCache: (a: number) => void;
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number]; readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
@@ -532,6 +552,7 @@ export interface InitOutput {
readonly gameengine_setMaterialVec2: (a: number, b: number, c: number, d: number, e: number, f: number) => number; readonly gameengine_setMaterialVec2: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
readonly gameengine_setMaterialVec3: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; readonly gameengine_setMaterialVec3: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
readonly gameengine_setMaterialVec4: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number; readonly gameengine_setMaterialVec4: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number;
readonly gameengine_setScissorRect: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_setShowGizmos: (a: number, b: number) => void; readonly gameengine_setShowGizmos: (a: number, b: number) => void;
readonly gameengine_setShowGrid: (a: number, b: number) => void; readonly gameengine_setShowGrid: (a: number, b: number) => void;
readonly gameengine_setTransformMode: (a: number, b: number) => void; readonly gameengine_setTransformMode: (a: number, b: number) => void;

View File

@@ -51,6 +51,7 @@ import { ConfirmDialog } from './components/ConfirmDialog';
import { ExternalModificationDialog } from './components/ExternalModificationDialog'; import { ExternalModificationDialog } from './components/ExternalModificationDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { BuildSettingsWindow } from './components/BuildSettingsWindow'; import { BuildSettingsWindow } from './components/BuildSettingsWindow';
import { AssetPickerDialog } from './components/dialogs/AssetPickerDialog';
import { ForumPanel } from './components/forum'; import { ForumPanel } from './components/forum';
import { ToastProvider, useToast } from './components/Toast'; import { ToastProvider, useToast } from './components/Toast';
import { TitleBar } from './components/TitleBar'; import { TitleBar } from './components/TitleBar';
@@ -210,6 +211,13 @@ function App() {
externalModificationDialog, setExternalModificationDialog externalModificationDialog, setExternalModificationDialog
} = useDialogStore(); } = useDialogStore();
// 资产选择器对话框状态 | Asset picker dialog state
const [assetPickerState, setAssetPickerState] = useState<{
isOpen: boolean;
extensions?: string[];
onSelect?: (path: string) => void;
}>({ isOpen: false });
// 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests // 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests
useEffect(() => { useEffect(() => {
let broadcastInterval: ReturnType<typeof setInterval> | null = null; let broadcastInterval: ReturnType<typeof setInterval> | null = null;
@@ -490,6 +498,26 @@ function App() {
return () => unsubscribe?.(); return () => unsubscribe?.();
}, [initialized, addDynamicPanel, setActivePanelId]); }, [initialized, addDynamicPanel, setActivePanelId]);
// 资产选择器消息订阅 | Asset picker message subscription
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = hub.subscribe('asset:pick', (data: {
extensions?: string[];
onSelect?: (path: string) => void;
}) => {
logger.info('Opening asset picker dialog with extensions:', data.extensions);
setAssetPickerState({
isOpen: true,
extensions: data.extensions,
onSelect: data.onSelect
});
});
return () => unsubscribe?.();
}, [initialized]);
useEffect(() => { useEffect(() => {
if (!initialized || !messageHubRef.current) return; if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current; const hub = messageHubRef.current;
@@ -1427,6 +1455,20 @@ function App() {
/> />
)} )}
{/* 资产选择器对话框 | Asset Picker Dialog */}
<AssetPickerDialog
isOpen={assetPickerState.isOpen}
onClose={() => setAssetPickerState({ isOpen: false })}
onSelect={(path) => {
if (assetPickerState.onSelect) {
assetPickerState.onSelect(path);
}
setAssetPickerState({ isOpen: false });
}}
title={t('asset.selectAsset')}
fileExtensions={assetPickerState.extensions}
/>
{/* 渲染调试面板 | Render Debug Panel */} {/* 渲染调试面板 | Render Debug Panel */}
<RenderDebugPanel <RenderDebugPanel
visible={showRenderDebug} visible={showRenderDebug}

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

View File

@@ -15,9 +15,9 @@ import {
ExtensionInspector, ExtensionInspector,
AssetFileInspector, AssetFileInspector,
RemoteEntityInspector, RemoteEntityInspector,
EntityInspector,
PrefabInspector PrefabInspector
} from './views'; } from './views';
import { EntityInspectorPanel } from '../inspector';
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) { export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) {
// ===== 从 InspectorStore 获取状态 | Get state from InspectorStore ===== // ===== 从 InspectorStore 获取状态 | Get state from InspectorStore =====
@@ -101,7 +101,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
if (target.type === 'entity') { if (target.type === 'entity') {
return ( return (
<EntityInspector <EntityInspectorPanel
entity={target.data} entity={target.data}
messageHub={messageHub} messageHub={messageHub}
commandManager={commandManager} commandManager={commandManager}

View File

@@ -6,32 +6,65 @@
* 使用 PropertyInspector.css 中的 property-field 和 property-label 以保持一致性。 * 使用 PropertyInspector.css 中的 property-field 和 property-label 以保持一致性。
*/ */
/* Input container - matches property-input styling */ /* Input container - matches property-asset-drop styling */
.entity-ref-field__input { .entity-ref-field__input {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 22px; height: 22px;
padding: 0 8px; padding: 0 8px;
background: #1a1a1a; background: #1a1a1a;
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
border-radius: 2px; border-radius: 2px;
gap: 4px; gap: 6px;
transition: border-color 0.15s ease, background-color 0.15s ease; transition: border-color 0.15s ease, background-color 0.15s ease;
min-width: 0;
cursor: pointer;
/* Ensure element can receive drag events | 确保元素可以接收拖拽事件 */
pointer-events: auto;
} }
.entity-ref-field__input:hover:not(.readonly) { .entity-ref-field__input:hover:not(.readonly) {
border-color: #4a4a4a; border-color: #4a4a4a;
background: #1e1e1e;
} }
.entity-ref-field__input:focus-within {
border-color: #d4a029;
background: #1e1e1e;
}
/* Drag over state - enhanced visual feedback */
.entity-ref-field__input.drag-over { .entity-ref-field__input.drag-over {
border-color: var(--accent-color, #4a9eff); border-color: #3b82f6;
background: rgba(74, 158, 255, 0.1); background: rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3);
} }
.entity-ref-field__input.readonly { .entity-ref-field__input.readonly {
opacity: 0.7; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
background: #1a1a1a;
}
/* Drop icon indicator */
.entity-ref-field__drop-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #555;
flex-shrink: 0;
transition: color 0.15s ease;
}
.entity-ref-field__input.drag-over .entity-ref-field__drop-icon {
color: #3b82f6;
}
.entity-ref-field__input.has-value .entity-ref-field__drop-icon {
color: #666;
} }
/* Entity name - clickable to navigate */ /* Entity name - clickable to navigate */
@@ -42,6 +75,7 @@
color: #ddd; color: #ddd;
cursor: pointer; cursor: pointer;
padding: 2px 4px; padding: 2px 4px;
margin: -2px -4px;
border-radius: 2px; border-radius: 2px;
transition: background-color 0.15s ease, color 0.15s ease; transition: background-color 0.15s ease, color 0.15s ease;
overflow: hidden; overflow: hidden;
@@ -50,11 +84,11 @@
} }
.entity-ref-field__name:hover { .entity-ref-field__name:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.08);
color: var(--accent-color, #4a9eff); color: #3b82f6;
} }
/* Clear button */ /* Clear button - hidden by default, show on hover */
.entity-ref-field__clear { .entity-ref-field__clear {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -65,23 +99,42 @@
background: transparent; background: transparent;
border: none; border: none;
border-radius: 2px; border-radius: 2px;
color: #999; color: #666;
font-size: 12px; font-size: 14px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease; transition: all 0.15s ease;
flex-shrink: 0; flex-shrink: 0;
opacity: 0;
}
.entity-ref-field__input:hover .entity-ref-field__clear {
opacity: 1;
} }
.entity-ref-field__clear:hover { .entity-ref-field__clear:hover {
background: rgba(255, 100, 100, 0.2); background: rgba(239, 68, 68, 0.2);
color: #ff6464; color: #ef4444;
} }
/* Placeholder text */ /* Placeholder text */
.entity-ref-field__placeholder { .entity-ref-field__placeholder {
flex: 1;
font-size: 11px; font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace; font-family: 'Consolas', 'Monaco', monospace;
color: #666; color: #666;
font-style: italic; font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.entity-ref-field__input,
.entity-ref-field__name,
.entity-ref-field__clear,
.entity-ref-field__drop-icon {
transition: none;
}
} }

View File

@@ -6,8 +6,9 @@
* 支持从场景层级面板拖拽实体。 * 支持从场景层级面板拖拽实体。
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Core } from '@esengine/ecs-framework'; import { Core } from '@esengine/ecs-framework';
import { Box, X } from 'lucide-react';
import { useHierarchyStore } from '../../../stores'; import { useHierarchyStore } from '../../../stores';
import './EntityRefField.css'; import './EntityRefField.css';
@@ -28,7 +29,7 @@ export const EntityRefField: React.FC<EntityRefFieldProps> = ({
label, label,
value, value,
onChange, onChange,
placeholder = '拖拽实体到此处 / Drop entity here', placeholder = '拖拽实体到此处',
readonly = false readonly = false
}) => { }) => {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -44,39 +45,77 @@ export const EntityRefField: React.FC<EntityRefFieldProps> = ({
}, [value]); }, [value]);
const entityName = getEntityName(); const entityName = getEntityName();
const hasValue = !!entityName;
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
console.log('[EntityRefField] DragEnter, types:', Array.from(e.dataTransfer.types));
setIsDragOver(true);
}, [readonly]);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
if (readonly) return; if (readonly) return;
// Always accept drag over - validate on drop
// Check if dragging an entity // 始终接受拖拽悬停 - 在放置时验证
// 检查是否拖拽实体
if (e.dataTransfer.types.includes('entity-id')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'link'; e.dataTransfer.dropEffect = 'link';
if (!isDragOver) {
setIsDragOver(true); setIsDragOver(true);
} }
}, [readonly]); }, [readonly, isDragOver]);
const handleDragLeave = useCallback(() => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set drag over false if leaving the element entirely
// 只有完全离开元素时才取消拖拽状态
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragOver(false); setIsDragOver(false);
}
}, []); }, []);
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback((e: React.DragEvent) => {
if (readonly) return; if (readonly) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
const entityIdStr = e.dataTransfer.getData('entity-id'); // Debug: log all available types and data
// 调试:记录所有可用的类型和数据
const types = Array.from(e.dataTransfer.types);
console.log('[EntityRefField] Drop - types:', types);
types.forEach(type => {
console.log(`[EntityRefField] Drop - ${type}:`, e.dataTransfer.getData(type));
});
// Try entity-id first, then fall back to text/plain
// 优先尝试 entity-id然后回退到 text/plain
let entityIdStr = e.dataTransfer.getData('entity-id');
if (!entityIdStr) {
entityIdStr = e.dataTransfer.getData('text/plain');
}
console.log('[EntityRefField] Drop received, entityIdStr:', entityIdStr);
if (entityIdStr) { if (entityIdStr) {
const entityId = parseInt(entityIdStr, 10); const entityId = parseInt(entityIdStr, 10);
if (!isNaN(entityId) && entityId > 0) { if (!isNaN(entityId) && entityId > 0) {
console.log('[EntityRefField] Calling onChange with entityId:', entityId);
onChange(entityId); onChange(entityId);
} }
} }
}, [readonly, onChange]); }, [readonly, onChange]);
const handleClear = useCallback(() => { const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (readonly) return; if (readonly) return;
onChange(0); onChange(0);
}, [readonly, onChange]); }, [readonly, onChange]);
@@ -90,15 +129,29 @@ export const EntityRefField: React.FC<EntityRefFieldProps> = ({
setSelectedIds(new Set([value])); setSelectedIds(new Set([value]));
}, [value]); }, [value]);
const inputClassName = [
'entity-ref-field__input',
isDragOver && 'drag-over',
readonly && 'readonly',
hasValue && 'has-value'
].filter(Boolean).join(' ');
return ( return (
<div className="property-field entity-ref-field"> <div className="property-field entity-ref-field">
<label className="property-label">{label}</label> <label className="property-label">{label}</label>
<div <div
className={`entity-ref-field__input ${isDragOver ? 'drag-over' : ''} ${readonly ? 'readonly' : ''}`} className={inputClassName}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onDropCapture={handleDrop}
> >
{/* Drop icon */}
<span className="entity-ref-field__drop-icon">
<Box size={14} />
</span>
{entityName ? ( {entityName ? (
<> <>
<span <span
@@ -114,7 +167,7 @@ export const EntityRefField: React.FC<EntityRefFieldProps> = ({
onClick={handleClear} onClick={handleClear}
title="清除引用 / Clear reference" title="清除引用 / Clear reference"
> >
× <X size={12} />
</button> </button>
)} )}
</> </>

View File

@@ -292,6 +292,19 @@ export const en: Translations = {
resetLayout: 'Reset Layout' resetLayout: 'Reset Layout'
}, },
// ========================================
// Asset
// ========================================
asset: {
selectAsset: 'Select Asset',
none: 'None',
browse: 'Browse...',
clear: 'Clear',
copy: 'Copy Path',
locate: 'Locate in Content Browser',
noGuid: 'Asset has no GUID'
},
// ======================================== // ========================================
// Scene // Scene
// ======================================== // ========================================

View File

@@ -292,6 +292,19 @@ export const zh: Translations = {
resetLayout: '重置布局' resetLayout: '重置布局'
}, },
// ========================================
// Asset
// ========================================
asset: {
selectAsset: '选择资产',
none: '无',
browse: '浏览...',
clear: '清除',
copy: '复制路径',
locate: '在内容浏览器中定位',
noGuid: '资产没有 GUID'
},
// ======================================== // ========================================
// Scene // Scene
// ======================================== // ========================================

View File

@@ -1,91 +1,110 @@
/* ==================== Container ==================== */ /**
* FlexLayout Dock Styles
* FlexLayout 停靠布局样式
*
* 结构层次 | Structure hierarchy:
* - .flexlayout-dock-container 主容器 | Main container
* - .flexlayout__layout 布局根节点 | Layout root
* - .flexlayout__tabset 面板组容器 | Panel group container
* - .flexlayout__tabset_header / _tabbar_outer 标签栏 | Tab bar
* - .flexlayout__tab_button 标签按钮 | Tab button
* - .flexlayout__tabset_content 内容区域 | Content area
* - .flexlayout__tab_moveable 可移动内容容器 | Moveable content
* - .flexlayout__splitter 分割线 | Splitter
*/
/* ==================== CSS Variables | CSS 变量 ==================== */
:root {
/* 背景色 | Background colors */
--flexlayout-bg-base: #1a1a1a;
--flexlayout-bg-panel: #242424;
--flexlayout-bg-header: #2a2a2a;
--flexlayout-bg-hover: rgba(255, 255, 255, 0.08);
/* 文字色 | Text colors */
--flexlayout-text-muted: #888888;
--flexlayout-text-normal: #cccccc;
--flexlayout-text-active: #ffffff;
/* 边框色 | Border colors */
--flexlayout-border: #1a1a1a;
--flexlayout-border-light: #3a3a3a;
/* 强调色 | Accent color */
--flexlayout-accent: #4a9eff;
/* 尺寸 | Dimensions */
--flexlayout-tab-height: 26px;
--flexlayout-font-size: 11px;
/* 滚动条 | Scrollbar */
--flexlayout-scrollbar-width: 8px;
--flexlayout-scrollbar-thumb: rgba(255, 255, 255, 0.15);
--flexlayout-scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
}
/* ==================== Main Container | 主容器 ==================== */
.flexlayout-dock-container { .flexlayout-dock-container {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #1a1a1a; background: var(--flexlayout-bg-base);
position: relative;
} }
.flexlayout__layout { .flexlayout__layout {
background: #1a1a1a; position: absolute;
position: absolute !important;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: var(--flexlayout-bg-base);
} }
/* ==================== Tabset (Panel Container) ==================== */ /* ==================== Tabset (Panel Group) | 面板组 ==================== */
.flexlayout__tabset { .flexlayout__tabset,
background: #242424; .flexlayout__tabset-selected {
background: var(--flexlayout-bg-panel);
border-radius: 0; border-radius: 0;
} }
.flexlayout__tabset-selected { .flexlayout__tabset_header,
background: #242424;
}
.flexlayout__tabset_header {
background: #2a2a2a;
border-bottom: 1px solid #1a1a1a;
height: 26px;
min-height: 26px;
}
.flexlayout__tabset_tabbar_outer { .flexlayout__tabset_tabbar_outer {
background: #2a2a2a; position: relative;
z-index: 10;
height: var(--flexlayout-tab-height);
min-height: var(--flexlayout-tab-height);
background: var(--flexlayout-bg-header);
border-bottom: 1px solid var(--flexlayout-border);
} }
/* ==================== Tab Buttons ==================== */ /* ==================== Tab Buttons | 标签按钮 ==================== */
.flexlayout__tab { .flexlayout__tab,
.flexlayout__tab_button {
background: transparent; background: transparent;
color: #888888; color: var(--flexlayout-text-muted);
border: none; border: none;
padding: 0 12px; padding: 0 12px;
height: 26px; height: var(--flexlayout-tab-height);
line-height: 26px; line-height: var(--flexlayout-tab-height);
cursor: default;
transition: color 0.1s ease;
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif; font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
font-size: 11px; font-size: var(--flexlayout-font-size);
position: relative;
}
.flexlayout__tab:hover {
color: #cccccc;
background: transparent;
}
.flexlayout__tab::after {
display: none;
}
.flexlayout__tab_button {
background: transparent !important;
color: #888888;
border: none !important;
border-right: none !important;
padding: 0 12px;
height: 26px;
cursor: pointer; cursor: pointer;
transition: color 0.1s ease; transition: color 0.1s ease;
position: relative;
font-size: 11px;
} }
.flexlayout__tab::after,
.flexlayout__tab_button::after { .flexlayout__tab_button::after {
display: none; display: none;
} }
.flexlayout__tab:hover,
.flexlayout__tab_button:hover { .flexlayout__tab_button:hover {
background: transparent !important; color: var(--flexlayout-text-normal);
color: #cccccc; background: transparent;
} }
.flexlayout__tab_button--selected { .flexlayout__tab_button--selected {
background: transparent !important; color: var(--flexlayout-text-active);
color: #ffffff !important;
border-bottom: none !important;
} }
.flexlayout__tab_button_leading { .flexlayout__tab_button_leading {
@@ -95,28 +114,24 @@
} }
.flexlayout__tab_button_content { .flexlayout__tab_button_content {
max-width: 140px;
font-size: var(--flexlayout-font-size);
font-weight: 400;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 140px;
font-size: 11px;
font-weight: 400;
} }
.flexlayout__tab_button--selected .flexlayout__tab_button_content { /* Tab close button | 标签关闭按钮 */
font-weight: 400;
}
/* Tab close button */
.flexlayout__tab_button_trailing { .flexlayout__tab_button_trailing {
margin-left: 6px;
opacity: 0;
transition: opacity 0.1s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: 6px;
padding: 2px; padding: 2px;
border-radius: 2px; border-radius: 2px;
opacity: 0;
transition: opacity 0.1s ease;
} }
.flexlayout__tab_button:hover .flexlayout__tab_button_trailing { .flexlayout__tab_button:hover .flexlayout__tab_button_trailing {
@@ -124,8 +139,8 @@
} }
.flexlayout__tab_button_trailing:hover { .flexlayout__tab_button_trailing:hover {
opacity: 1 !important; opacity: 1;
background: rgba(255, 255, 255, 0.1); background: var(--flexlayout-bg-hover);
} }
.flexlayout__tab_button_trailing svg { .flexlayout__tab_button_trailing svg {
@@ -135,98 +150,83 @@
} }
.flexlayout__tab_button_trailing:hover svg { .flexlayout__tab_button_trailing:hover svg {
color: #ffffff; color: var(--flexlayout-text-active);
} }
/* ==================== Splitter (Divider between panels) ==================== */ /* ==================== Panel Content | 面板内容 ==================== */
.flexlayout__splitter { /*
background: #1a1a1a !important; * 重要:面板内容容器不设置滚动,由各面板自己管理滚动
transition: background 0.15s ease; * Important: Content containers don't scroll, each panel manages its own scroll
} */
.flexlayout__splitter:hover {
background: #4a9eff !important;
}
.flexlayout__splitter_horz {
cursor: row-resize !important;
}
.flexlayout__splitter_vert {
cursor: col-resize !important;
}
.flexlayout__splitter_border {
background: #1a1a1a !important;
}
/* ==================== Panel Content ==================== */
.flexlayout__tabset_content { .flexlayout__tabset_content {
background: #242424; background: var(--flexlayout-bg-panel);
overflow: auto; overflow: hidden;
} }
.flexlayout__tab_moveable {
overflow: hidden;
}
/* 默认光标 | Default cursor */
.flexlayout__tabset_content * { .flexlayout__tabset_content * {
cursor: default !important; cursor: default;
} }
/* 可交互元素恢复指针光标 | Restore pointer cursor for interactive elements */
.flexlayout__tabset_content button, .flexlayout__tabset_content button,
.flexlayout__tabset_content a, .flexlayout__tabset_content a,
.flexlayout__tabset_content [role="button"], .flexlayout__tabset_content [role="button"],
.flexlayout__tabset_content input, .flexlayout__tabset_content input,
.flexlayout__tabset_content select, .flexlayout__tabset_content select,
.flexlayout__tabset_content textarea { .flexlayout__tabset_content textarea {
cursor: pointer !important; cursor: pointer;
} }
/* ==================== Drag & Drop ==================== */ /* ==================== Splitter | 分割线 ==================== */
.flexlayout__outline_rect { .flexlayout__splitter,
border: 1px solid #4a9eff; .flexlayout__splitter_border {
box-shadow: 0 0 12px rgba(74, 158, 255, 0.3); background: var(--flexlayout-border);
background: rgba(74, 158, 255, 0.08); transition: background 0.15s ease;
border-radius: 2px;
} }
.flexlayout__edge_rect { .flexlayout__splitter:hover {
background: rgba(74, 158, 255, 0.15); background: var(--flexlayout-accent);
border: 1px solid #4a9eff;
} }
.flexlayout__drag_rect { .flexlayout__splitter_horz {
border: 1px solid #4a9eff; cursor: row-resize;
background: rgba(74, 158, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-radius: 2px;
} }
/* ==================== Tab Toolbar ==================== */ .flexlayout__splitter_vert {
cursor: col-resize;
}
/* ==================== Tab Toolbar | 标签工具栏 ==================== */
.flexlayout__tab_toolbar { .flexlayout__tab_toolbar {
display: flex !important; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 0 4px; padding: 0 4px;
visibility: visible !important;
opacity: 1 !important;
} }
.flexlayout__tab_toolbar_button { .flexlayout__tab_toolbar_button {
background: transparent; display: flex;
border: none;
color: #666666;
cursor: pointer;
padding: 3px;
border-radius: 2px;
transition: all 0.1s ease;
display: flex !important;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 20px;
height: 20px; height: 20px;
padding: 3px;
background: transparent;
border: none;
border-radius: 2px;
color: #666666;
cursor: pointer;
transition: all 0.1s ease;
} }
.flexlayout__tab_toolbar_button:hover { .flexlayout__tab_toolbar_button:hover {
background: rgba(255, 255, 255, 0.08); background: var(--flexlayout-bg-hover);
color: #cccccc; color: var(--flexlayout-text-normal);
} }
.flexlayout__tab_toolbar_button svg { .flexlayout__tab_toolbar_button svg {
@@ -234,51 +234,70 @@
height: 12px; height: 12px;
} }
.flexlayout__tab_toolbar_button-min, /* Maximize button active state | 最大化按钮激活状态 */
.flexlayout__tab_toolbar_button-max {
display: flex !important;
visibility: visible !important;
}
/* Maximized tabset styling */
.flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max { .flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max {
color: #4a9eff; color: var(--flexlayout-accent);
} }
.flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max:hover { .flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max:hover {
background: rgba(74, 158, 255, 0.2); background: rgba(74, 158, 255, 0.2);
color: #ffffff; color: var(--flexlayout-text-active);
} }
/* ==================== Popup Menu ==================== */ .flexlayout__tabset_maximized .flexlayout__tabset_header,
.flexlayout__tabset_maximized .flexlayout__tabset_tabbar_outer {
z-index: 100;
}
/* ==================== Drag & Drop | 拖放 ==================== */
.flexlayout__outline_rect {
border: 1px solid var(--flexlayout-accent);
border-radius: 2px;
background: rgba(74, 158, 255, 0.08);
box-shadow: 0 0 12px rgba(74, 158, 255, 0.3);
}
.flexlayout__edge_rect {
border: 1px solid var(--flexlayout-accent);
background: rgba(74, 158, 255, 0.15);
}
.flexlayout__drag_rect {
border: 1px solid var(--flexlayout-accent);
border-radius: 2px;
background: rgba(74, 158, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* ==================== Popup Menu | 弹出菜单 ==================== */
.flexlayout__popup_menu { .flexlayout__popup_menu {
background: #2d2d2d;
border: 1px solid #3a3a3a;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
border-radius: 4px;
padding: 4px 0; padding: 4px 0;
background: #2d2d2d;
border: 1px solid var(--flexlayout-border-light);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
} }
.flexlayout__popup_menu_item { .flexlayout__popup_menu_item {
color: #cccccc;
padding: 6px 12px; padding: 6px 12px;
color: var(--flexlayout-text-normal);
font-size: var(--flexlayout-font-size);
cursor: pointer; cursor: pointer;
transition: background 0.1s ease; transition: background 0.1s ease;
font-size: 11px;
} }
.flexlayout__popup_menu_item:hover { .flexlayout__popup_menu_item:hover {
background: #3a3a3a; background: var(--flexlayout-border-light);
color: #ffffff; color: var(--flexlayout-text-active);
} }
.flexlayout__popup_menu_item:active { .flexlayout__popup_menu_item:active {
background: #4a9eff; background: var(--flexlayout-accent);
} }
/* ==================== Border Panels ==================== */ /* ==================== Border Panels | 边框面板 ==================== */
.flexlayout__border { .flexlayout__border {
background: #242424; background: var(--flexlayout-bg-panel);
border: none; border: none;
} }
@@ -295,15 +314,13 @@
} }
.flexlayout__border_button { .flexlayout__border_button {
background: transparent;
color: #888888;
border: none;
border-bottom: none;
padding: 6px 10px; padding: 6px 10px;
background: transparent;
border: none;
color: var(--flexlayout-text-muted);
font-size: var(--flexlayout-font-size);
cursor: pointer; cursor: pointer;
transition: color 0.1s ease; transition: color 0.1s ease;
position: relative;
font-size: 11px;
} }
.flexlayout__border_button::after { .flexlayout__border_button::after {
@@ -312,19 +329,19 @@
.flexlayout__border_button:hover { .flexlayout__border_button:hover {
background: transparent; background: transparent;
color: #cccccc; color: var(--flexlayout-text-normal);
} }
.flexlayout__border_button--selected { .flexlayout__border_button--selected {
background: transparent; background: transparent;
color: #ffffff; color: var(--flexlayout-text-active);
} }
/* ==================== Error Boundary ==================== */ /* ==================== Error Boundary | 错误边界 ==================== */
.flexlayout__error_boundary_container { .flexlayout__error_boundary_container {
background: #242424;
color: #f48771;
padding: 16px; padding: 16px;
background: var(--flexlayout-bg-panel);
color: #f48771;
font-family: monospace; font-family: monospace;
} }
@@ -333,11 +350,11 @@
font-weight: 600; font-weight: 600;
} }
/* ==================== Scrollbar ==================== */ /* ==================== Scrollbar | 滚动条 ==================== */
.flexlayout__tabset_content::-webkit-scrollbar, .flexlayout__tabset_content::-webkit-scrollbar,
.flexlayout__tab_moveable::-webkit-scrollbar { .flexlayout__tab_moveable::-webkit-scrollbar {
width: 8px; width: var(--flexlayout-scrollbar-width);
height: 8px; height: var(--flexlayout-scrollbar-width);
} }
.flexlayout__tabset_content::-webkit-scrollbar-track, .flexlayout__tabset_content::-webkit-scrollbar-track,
@@ -347,13 +364,13 @@
.flexlayout__tabset_content::-webkit-scrollbar-thumb, .flexlayout__tabset_content::-webkit-scrollbar-thumb,
.flexlayout__tab_moveable::-webkit-scrollbar-thumb { .flexlayout__tab_moveable::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15); background: var(--flexlayout-scrollbar-thumb);
border-radius: 4px; border-radius: 4px;
} }
.flexlayout__tabset_content::-webkit-scrollbar-thumb:hover, .flexlayout__tabset_content::-webkit-scrollbar-thumb:hover,
.flexlayout__tab_moveable::-webkit-scrollbar-thumb:hover { .flexlayout__tab_moveable::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25); background: var(--flexlayout-scrollbar-thumb-hover);
} }
.flexlayout__tabset_content::-webkit-scrollbar-corner, .flexlayout__tabset_content::-webkit-scrollbar-corner,
@@ -361,7 +378,7 @@
background: transparent; background: transparent;
} }
/* ==================== Persistent Panels ==================== */ /* ==================== Persistent Panels | 持久化面板 ==================== */
.persistent-panel-placeholder { .persistent-panel-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -369,18 +386,5 @@
} }
.persistent-panel-container { .persistent-panel-container {
background: #242424; background: var(--flexlayout-bg-panel);
}
/* 确保 tabset header 在 persistent panel 之上 */
.flexlayout__tabset_header,
.flexlayout__tabset_tabbar_outer {
position: relative;
z-index: 10;
}
/* 最大化时确保 tab bar 可见 */
.flexlayout__tabset_maximized .flexlayout__tabset_header,
.flexlayout__tabset_maximized .flexlayout__tabset_tabbar_outer {
z-index: 100;
} }

View File

@@ -1,6 +1,7 @@
.scene-hierarchy { .scene-hierarchy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: 100%; height: 100%;
background-color: #2a2a2a; background-color: #2a2a2a;
color: #e0e0e0; color: #e0e0e0;
@@ -205,19 +206,26 @@
/* ==================== Content Area ==================== */ /* ==================== Content Area ==================== */
.outliner-content { .outliner-content {
flex: 1; flex: 1;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
} }
/* 隐藏滚动条但保留滚动功能 */ /* 滚动条样式 | Scrollbar styling */
/* Hide scrollbar but keep scroll functionality */
.outliner-content::-webkit-scrollbar { .outliner-content::-webkit-scrollbar {
width: 0; width: 8px;
height: 0;
} }
.outliner-content { .outliner-content::-webkit-scrollbar-track {
scrollbar-width: none; /* Firefox */ background: transparent;
-ms-overflow-style: none; /* IE/Edge */ }
.outliner-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.outliner-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
} }
.outliner-list { .outliner-list {

View File

@@ -253,6 +253,18 @@ impl Engine {
Ok(()) Ok(())
} }
/// Set scissor rect for clipping (screen coordinates, Y-down).
/// 设置裁剪矩形屏幕坐标Y 轴向下)。
pub fn set_scissor_rect(&mut self, x: f32, y: f32, width: f32, height: f32) {
self.renderer.set_scissor_rect(x, y, width, height);
}
/// Clear scissor rect (disable clipping).
/// 清除裁剪矩形(禁用裁剪)。
pub fn clear_scissor_rect(&mut self) {
self.renderer.clear_scissor_rect();
}
/// Add a rectangle gizmo. /// Add a rectangle gizmo.
/// 添加矩形Gizmo。 /// 添加矩形Gizmo。
pub fn add_gizmo_rect( pub fn add_gizmo_rect(

View File

@@ -177,6 +177,29 @@ impl GameEngine {
.map_err(|e| JsValue::from_str(&e.to_string())) .map_err(|e| JsValue::from_str(&e.to_string()))
} }
/// Set scissor rect for clipping (screen coordinates, Y-down).
/// 设置裁剪矩形屏幕坐标Y 轴向下)。
///
/// Content outside this rect will be clipped.
/// 此矩形外的内容将被裁剪。
///
/// # Arguments | 参数
/// * `x` - Left edge in screen coordinates | 屏幕坐标中的左边缘
/// * `y` - Top edge in screen coordinates (Y-down) | 屏幕坐标中的上边缘Y 向下)
/// * `width` - Rect width | 矩形宽度
/// * `height` - Rect height | 矩形高度
#[wasm_bindgen(js_name = setScissorRect)]
pub fn set_scissor_rect(&mut self, x: f32, y: f32, width: f32, height: f32) {
self.engine.set_scissor_rect(x, y, width, height);
}
/// Clear scissor rect (disable clipping).
/// 清除裁剪矩形(禁用裁剪)。
#[wasm_bindgen(js_name = clearScissorRect)]
pub fn clear_scissor_rect(&mut self) {
self.engine.clear_scissor_rect();
}
/// Load a texture from URL. /// Load a texture from URL.
/// 从URL加载纹理。 /// 从URL加载纹理。
/// ///

View File

@@ -46,6 +46,15 @@ pub struct Renderer2D {
/// 当前激活的材质ID。 /// 当前激活的材质ID。
#[allow(dead_code)] #[allow(dead_code)]
current_material_id: u32, current_material_id: u32,
/// Current scissor rect (x, y, width, height) in screen coordinates.
/// None means scissor test is disabled.
/// 当前裁剪矩形屏幕坐标。None 表示禁用裁剪测试。
scissor_rect: Option<[f32; 4]>,
/// Viewport height for scissor coordinate conversion.
/// 视口高度,用于裁剪坐标转换。
viewport_height: f32,
} }
impl Renderer2D { impl Renderer2D {
@@ -81,6 +90,8 @@ impl Renderer2D {
clear_color: [0.1, 0.1, 0.12, 1.0], clear_color: [0.1, 0.1, 0.12, 1.0],
current_shader_id: 0, current_shader_id: 0,
current_material_id: 0, current_material_id: 0,
scissor_rect: None,
viewport_height: canvas.1,
}) })
} }
@@ -120,6 +131,10 @@ impl Renderer2D {
return Ok(()); return Ok(());
} }
// Apply scissor test if enabled
// 如果启用,应用裁剪测试
self.apply_scissor(gl);
// Track current state to minimize state changes | 跟踪当前状态以最小化状态切换 // Track current state to minimize state changes | 跟踪当前状态以最小化状态切换
let mut current_material_id: u32 = u32::MAX; let mut current_material_id: u32 = u32::MAX;
let mut current_texture_id: u32 = u32::MAX; let mut current_texture_id: u32 = u32::MAX;
@@ -209,6 +224,47 @@ impl Renderer2D {
/// 更新相机视口大小。 /// 更新相机视口大小。
pub fn resize(&mut self, width: f32, height: f32) { pub fn resize(&mut self, width: f32, height: f32) {
self.camera.set_viewport(width, height); self.camera.set_viewport(width, height);
self.viewport_height = height;
}
// ============= Scissor Test =============
// ============= 裁剪测试 =============
/// Set scissor rect for clipping (screen coordinates, Y-down).
/// 设置裁剪矩形屏幕坐标Y 轴向下)。
///
/// Content outside this rect will be clipped.
/// 此矩形外的内容将被裁剪。
///
/// # Arguments | 参数
/// * `x` - Left edge in screen coordinates | 屏幕坐标中的左边缘
/// * `y` - Top edge in screen coordinates (Y-down) | 屏幕坐标中的上边缘Y 向下)
/// * `width` - Rect width | 矩形宽度
/// * `height` - Rect height | 矩形高度
pub fn set_scissor_rect(&mut self, x: f32, y: f32, width: f32, height: f32) {
self.scissor_rect = Some([x, y, width, height]);
}
/// Clear scissor rect (disable clipping).
/// 清除裁剪矩形(禁用裁剪)。
pub fn clear_scissor_rect(&mut self) {
self.scissor_rect = None;
}
/// Apply current scissor state to GL context.
/// 应用当前裁剪状态到 GL 上下文。
fn apply_scissor(&self, gl: &WebGl2RenderingContext) {
if let Some([x, y, width, height]) = self.scissor_rect {
gl.enable(WebGl2RenderingContext::SCISSOR_TEST);
// WebGL scissor uses bottom-left origin with Y-up
// Convert from screen coordinates (top-left origin, Y-down)
// WebGL scissor 使用左下角原点Y 轴向上
// 从屏幕坐标转换左上角原点Y 轴向下)
let gl_y = self.viewport_height - y - height;
gl.scissor(x as i32, gl_y as i32, width as i32, height as i32);
} else {
gl.disable(WebGl2RenderingContext::SCISSOR_TEST);
}
} }
// ============= Shader Management ============= // ============= Shader Management =============

View File

@@ -21,7 +21,7 @@ import {
type IPlugin, type IPlugin,
type IRuntimeSceneManager type IRuntimeSceneManager
} from '@esengine/runtime-core'; } from '@esengine/runtime-core';
import { isValidGUID, type IAssetManager } from '@esengine/asset-system'; import { isValidGUID, setGlobalAssetFileLoader, type IAssetManager, type IAssetFileLoader } from '@esengine/asset-system';
import { BrowserAssetReader } from './BrowserAssetReader'; import { BrowserAssetReader } from './BrowserAssetReader';
import { WebInputSubsystem } from './subsystems/WebInputSubsystem'; import { WebInputSubsystem } from './subsystems/WebInputSubsystem';
@@ -158,6 +158,16 @@ export class BrowserRuntime {
const catalog = this._fileSystem.catalog; const catalog = this._fileSystem.catalog;
this._runtime.assetManager.initializeFromCatalog(catalog); this._runtime.assetManager.initializeFromCatalog(catalog);
} }
// Set global asset file loader for UI atlas and other subsystems
// 设置全局资产文件加载器供 UI 图集和其他子系统使用
const assetFileLoader: IAssetFileLoader = {
loadImage: (assetPath: string) => this._assetReader!.loadImage(assetPath),
loadText: (assetPath: string) => this._assetReader!.readText(assetPath),
loadBinary: (assetPath: string) => this._assetReader!.readBinary(assetPath),
exists: (assetPath: string) => this._assetReader!.exists(assetPath)
};
setGlobalAssetFileLoader(assetFileLoader);
} }
// Disable editor mode (hides grid, gizmos, axis indicator) // Disable editor mode (hides grid, gizmos, axis indicator)

View File

@@ -262,6 +262,26 @@ export class UIInputFieldComponent extends Component {
*/ */
public scrollOffset: number = 0; public scrollOffset: number = 0;
// ===== IME 组合状态 IME Composition State =====
/**
* 是否正在进行 IME 组合输入
* Whether IME composition is in progress
*/
public isComposing: boolean = false;
/**
* IME 组合中的文本(如拼音 "zhong"
* Text being composed in IME (e.g., pinyin "zhong")
*/
public compositionText: string = '';
/**
* 组合开始时的光标位置
* Caret position when composition started
*/
public compositionStart: number = 0;
// ===== 回调 Callbacks ===== // ===== 回调 Callbacks =====
/** /**
@@ -496,6 +516,37 @@ export class UIInputFieldComponent extends Component {
return this.text; return this.text;
} }
/**
* 获取带 IME 组合文本的显示文本
* Get display text with IME composition text
*
* 组合文本会插入到光标位置,用于实时预览输入法输入。
* Composition text is inserted at caret position for real-time IME input preview.
*/
public getDisplayTextWithComposition(): string {
if (!this.isComposing || !this.compositionText) {
return this.getDisplayText();
}
const displayText = this.getDisplayText();
// 在组合开始位置插入组合文本
// Insert composition text at composition start position
const before = displayText.substring(0, this.compositionStart);
const after = displayText.substring(this.compositionStart);
return before + this.compositionText + after;
}
/**
* 获取组合文本的结束位置(用于光标定位)
* Get composition text end position (for caret positioning)
*/
public getCompositionEndPosition(): number {
if (!this.isComposing || !this.compositionText) {
return this.caretPosition;
}
return this.compositionStart + this.compositionText.length;
}
/** /**
* 验证单个字符是否可以输入 * 验证单个字符是否可以输入
* Validate if a single character can be input * Validate if a single character can be input

View File

@@ -8,6 +8,7 @@ import { UIScrollViewComponent } from '../components/widgets/UIScrollViewCompone
import { UIToggleComponent } from '../components/widgets/UIToggleComponent'; import { UIToggleComponent } from '../components/widgets/UIToggleComponent';
import { UIInputFieldComponent } from '../components/widgets/UIInputFieldComponent'; import { UIInputFieldComponent } from '../components/widgets/UIInputFieldComponent';
import { UIDropdownComponent } from '../components/widgets/UIDropdownComponent'; import { UIDropdownComponent } from '../components/widgets/UIDropdownComponent';
import { IMEHelper } from '../utils/IMEHelper';
import type { UILayoutSystem } from './UILayoutSystem'; import type { UILayoutSystem } from './UILayoutSystem';
// Re-export MouseButton for backward compatibility // Re-export MouseButton for backward compatibility
@@ -98,6 +99,9 @@ export class UIInputSystem extends EntitySystem {
// Used to get UI canvas size for coordinate conversion // Used to get UI canvas size for coordinate conversion
private layoutSystem: UILayoutSystem | null = null; private layoutSystem: UILayoutSystem | null = null;
// ===== IME 输入法支持 IME Support =====
private imeHelper: IMEHelper | null = null;
constructor() { constructor() {
super(Matcher.empty().all(UITransformComponent, UIInteractableComponent)); super(Matcher.empty().all(UITransformComponent, UIInteractableComponent));
@@ -142,6 +146,15 @@ export class UIInputSystem extends EntitySystem {
// 阻止右键菜单 // 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 初始化 IME 辅助服务
// Initialize IME helper service
this.imeHelper = new IMEHelper();
this.imeHelper.setCanvas(canvas);
this.imeHelper.onCompositionStart = () => this.handleCompositionStart();
this.imeHelper.onCompositionUpdate = (text) => this.handleCompositionUpdate(text);
this.imeHelper.onCompositionEnd = (text) => this.handleCompositionEnd(text);
this.imeHelper.onInput = (text) => this.handleIMEInput(text);
} }
/** /**
@@ -162,6 +175,13 @@ export class UIInputSystem extends EntitySystem {
document.removeEventListener('keydown', this.boundKeyDown); document.removeEventListener('keydown', this.boundKeyDown);
document.removeEventListener('keyup', this.boundKeyUp); document.removeEventListener('keyup', this.boundKeyUp);
document.removeEventListener('keypress', this.boundKeyPress); document.removeEventListener('keypress', this.boundKeyPress);
// 销毁 IME 辅助服务
// Dispose IME helper service
if (this.imeHelper) {
this.imeHelper.dispose();
this.imeHelper = null;
}
} }
/** /**
@@ -751,6 +771,14 @@ export class UIInputSystem extends EntitySystem {
oldInteractable.focused = false; oldInteractable.focused = false;
oldInteractable.onBlur?.(); oldInteractable.onBlur?.();
} }
// 清除旧 InputField 的 IME 组合状态
// Clear IME composition state for old InputField
const oldInputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (oldInputField) {
oldInputField.isComposing = false;
oldInputField.compositionText = '';
}
} }
this.focusedEntity = entity; this.focusedEntity = entity;
@@ -762,6 +790,18 @@ export class UIInputSystem extends EntitySystem {
interactable.focused = true; interactable.focused = true;
interactable.onFocus?.(); interactable.onFocus?.();
} }
// 如果是 InputField激活 IME
// If it's an InputField, activate IME
const inputField = entity.getComponent(UIInputFieldComponent);
if (inputField && this.imeHelper) {
this.imeHelper.focus();
this.updateIMEPosition(entity);
}
} else {
// 失去焦点时关闭 IME
// Blur IME when focus is lost
this.imeHelper?.blur();
} }
} }
@@ -1266,6 +1306,153 @@ export class UIInputSystem extends EntitySystem {
return -1; return -1;
} }
// ===== IME 事件处理 IME Event Handlers =====
/**
* 更新 IME 隐藏 input 的位置
* Update IME hidden input position
*
* 将 IME 候选窗口定位到光标附近
* Position IME candidate window near the caret
*/
private updateIMEPosition(entity: Entity): void {
if (!this.imeHelper || !this.canvas) return;
const transform = entity.getComponent(UITransformComponent);
const inputField = entity.getComponent(UIInputFieldComponent);
if (!transform || !inputField) return;
// 获取 UI 世界坐标
const worldX = transform.worldX ?? transform.x;
const worldY = transform.worldY ?? transform.y;
const width = transform.computedWidth ?? transform.width;
const height = transform.computedHeight ?? transform.height;
const pivotX = transform.pivotX;
const pivotY = transform.pivotY;
// 计算光标在 UI 世界坐标中的位置
const textAreaStartX = worldX - width * pivotX + inputField.padding;
const caretX = textAreaStartX + inputField.getCaretX() - inputField.scrollOffset;
const caretY = worldY - height * pivotY + height / 2;
// 转换为屏幕坐标
const canvasRect = this.canvas.getBoundingClientRect();
const uiCanvasSize = this.layoutSystem?.getCanvasSize() ?? { width: 1920, height: 1080 };
// UI 坐标 -> 归一化坐标 -> 屏幕坐标
const normalizedX = (caretX / uiCanvasSize.width) + 0.5;
const normalizedY = 0.5 - (caretY / uiCanvasSize.height);
const screenX = canvasRect.left + normalizedX * canvasRect.width;
const screenY = canvasRect.top + normalizedY * canvasRect.height;
this.imeHelper.updatePosition(screenX, screenY);
}
/**
* 处理 IME 组合开始
* Handle IME composition start
*/
private handleCompositionStart(): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || inputField.readOnly || inputField.disabled) return;
// 如果有选中文本,先删除
// Delete selection if any
if (inputField.hasSelection()) {
inputField.deleteSelection();
}
inputField.isComposing = true;
inputField.compositionStart = inputField.caretPosition;
inputField.compositionText = '';
}
/**
* 处理 IME 组合更新
* Handle IME composition update
*/
private handleCompositionUpdate(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || !inputField.isComposing) return;
inputField.compositionText = text;
// 更新 IME 位置(组合文本可能改变光标位置)
// Update IME position (composition text may change caret position)
this.updateIMEPosition(this.focusedEntity);
}
/**
* 处理 IME 组合结束
* Handle IME composition end
*/
private handleCompositionEnd(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField) return;
inputField.isComposing = false;
inputField.compositionText = '';
// 插入最终文本
// Insert final text
if (text && !inputField.readOnly && !inputField.disabled) {
inputField.insertText(text);
// 确保光标可见
// Ensure caret is visible
const transform = this.focusedEntity.getComponent(UITransformComponent);
if (transform) {
const width = transform.computedWidth ?? transform.width;
const textAreaWidth = width - inputField.padding * 2;
inputField.ensureCaretVisible(textAreaWidth);
}
}
}
/**
* 处理 IME 直接输入(非组合输入)
* Handle IME direct input (non-composition input)
*/
private handleIMEInput(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || inputField.readOnly || inputField.disabled) return;
// 组合过程中不处理直接输入
// Don't handle direct input during composition
if (inputField.isComposing) return;
// 验证并插入文本
// Validate and insert text
let validText = '';
for (const char of text) {
if (inputField.validateInput(char)) {
validText += char;
}
}
if (validText) {
inputField.insertText(validText);
// 确保光标可见
// Ensure caret is visible
const transform = this.focusedEntity.getComponent(UITransformComponent);
if (transform) {
const width = transform.computedWidth ?? transform.width;
const textAreaWidth = width - inputField.padding * 2;
inputField.ensureCaretVisible(textAreaWidth);
}
}
}
protected onDestroy(): void { protected onDestroy(): void {
this.unbind(); this.unbind();
} }

View File

@@ -12,6 +12,7 @@ import { UITransformComponent } from '../../components/UITransformComponent';
import { UIInputFieldComponent } from '../../components/widgets/UIInputFieldComponent'; import { UIInputFieldComponent } from '../../components/widgets/UIInputFieldComponent';
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector'; import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
import { getUIRenderTransform } from './UIRenderUtils'; import { getUIRenderTransform } from './UIRenderUtils';
import { getTextMeasureService } from '../../utils/TextMeasureService';
/** /**
* Text texture cache entry * Text texture cache entry
@@ -152,16 +153,16 @@ export class UIInputFieldRenderSystem extends EntitySystem {
// 2. Render text or placeholder (above background) // 2. Render text or placeholder (above background)
this.renderText(collector, input, rt, textX, textY, textWidth, textHeight, entityId); this.renderText(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
// 3. 渲染选中高亮
// 3. Render selection highlight // 3. Render selection highlight
// 3. 渲染选中高亮
if (input.focused && input.hasSelection()) { if (input.focused && input.hasSelection()) {
this.renderSelection(collector, input, rt, textX, textY, textHeight, entityId); this.renderSelection(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
} }
// 4. 渲染光标
// 4. Render caret // 4. Render caret
// 4. 渲染光标
if (input.focused && input.caretVisible && !input.hasSelection()) { if (input.focused && input.caretVisible && !input.hasSelection()) {
this.renderCaret(collector, input, rt, textX, textY, textHeight, entityId); this.renderCaret(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
} }
} }
} }
@@ -184,8 +185,13 @@ export class UIInputFieldRenderSystem extends EntitySystem {
// 确定要显示的文本和颜色 // 确定要显示的文本和颜色
// Determine text to display and color // Determine text to display and color
const isPlaceholder = input.text.length === 0; const isPlaceholder = input.text.length === 0 && !input.isComposing;
const displayText = isPlaceholder ? input.placeholder : input.getDisplayText();
// 使用带 IME 组合文本的显示文本
// Use display text with IME composition
const displayText = isPlaceholder
? input.placeholder
: input.getDisplayTextWithComposition();
// 如果没有文本可显示,跳过渲染 // 如果没有文本可显示,跳过渲染
// Skip rendering if no text to display // Skip rendering if no text to display
@@ -207,22 +213,99 @@ export class UIInputFieldRenderSystem extends EntitySystem {
if (textureId === null) return; if (textureId === null) return;
// 提交文本渲染原语(在背景之上) // Calculate clip rect for text viewport
// 计算文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
// Submit text render primitive (above background) // Submit text render primitive (above background)
// 提交文本渲染原语(在背景之上)
collector.addRect( collector.addRect(
textX + textWidth / 2, // 中心点 | Center point textX + textWidth / 2, // Center point | 中心点
textY + textHeight / 2, textY + textHeight / 2,
textWidth, textWidth,
textHeight, textHeight,
0xFFFFFF, // 白色着色(颜色已烘焙到纹理中) | White tint (color is baked into texture) 0xFFFFFF, // White tint (color is baked into texture) | 白色着色(颜色已烘焙到纹理中)
rt.alpha, rt.alpha,
rt.sortingLayer, rt.sortingLayer,
rt.orderInLayer + 1, // 在背景之上 | Above background rt.orderInLayer + 1, // Above background | 在背景之上
{ {
pivotX: 0.5, pivotX: 0.5,
pivotY: 0.5, pivotY: 0.5,
textureId, textureId,
entityId entityId,
clipRect
}
);
// Render IME composition text underline
// 渲染 IME 组合文本下划线
if (input.isComposing && input.compositionText) {
this.renderCompositionUnderline(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
}
}
/**
* Render IME composition text underline
* 渲染 IME 组合文本下划线
*/
private renderCompositionUnderline(
collector: ReturnType<typeof getUIRenderCollector>,
input: UIInputFieldComponent,
rt: ReturnType<typeof getUIRenderTransform>,
textX: number,
textY: number,
textWidth: number,
textHeight: number,
entityId: number
): void {
if (!rt) return;
const font = input.getFontConfig();
const displayText = input.getDisplayTextWithComposition();
// Calculate composition text start and end X position
// 计算组合文本的起始和结束 X 位置
const service = getTextMeasureService();
const compositionStartX = service.getXForCharIndex(displayText, font, input.compositionStart);
const compositionEndX = service.getXForCharIndex(displayText, font, input.compositionStart + input.compositionText.length);
const underlineWidth = compositionEndX - compositionStartX;
if (underlineWidth <= 0) return;
const underlineX = textX + compositionStartX - input.scrollOffset;
const underlineY = textY + textHeight - 2; // Bottom position | 底部位置
const underlineHeight = 1;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
// Render underline
// 渲染下划线
collector.addRect(
underlineX + underlineWidth / 2,
underlineY + underlineHeight / 2,
underlineWidth,
underlineHeight,
input.textColor,
rt.alpha,
rt.sortingLayer,
rt.orderInLayer + 2, // Above text | 在文本之上
{
pivotX: 0.5,
pivotY: 0.5,
entityId,
clipRect
} }
); );
} }
@@ -350,8 +433,8 @@ export class UIInputFieldRenderSystem extends EntitySystem {
} }
/** /**
* 渲染选中高亮
* Render selection highlight * Render selection highlight
* 渲染选中高亮
*/ */
private renderSelection( private renderSelection(
collector: ReturnType<typeof getUIRenderCollector>, collector: ReturnType<typeof getUIRenderCollector>,
@@ -359,6 +442,7 @@ export class UIInputFieldRenderSystem extends EntitySystem {
rt: ReturnType<typeof getUIRenderTransform>, rt: ReturnType<typeof getUIRenderTransform>,
textX: number, textX: number,
textY: number, textY: number,
textWidth: number,
textHeight: number, textHeight: number,
entityId: number entityId: number
): void { ): void {
@@ -370,8 +454,17 @@ export class UIInputFieldRenderSystem extends EntitySystem {
if (selWidth <= 0) return; if (selWidth <= 0) return;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
collector.addRect( collector.addRect(
selX + selWidth / 2, // 中心点 | Center point selX + selWidth / 2, // Center point | 中心点
textY + textHeight / 2, textY + textHeight / 2,
selWidth, selWidth,
textHeight, textHeight,
@@ -382,14 +475,15 @@ export class UIInputFieldRenderSystem extends EntitySystem {
{ {
pivotX: 0.5, pivotX: 0.5,
pivotY: 0.5, pivotY: 0.5,
entityId entityId,
clipRect
} }
); );
} }
/** /**
* 渲染光标
* Render caret * Render caret
* 渲染光标
*/ */
private renderCaret( private renderCaret(
collector: ReturnType<typeof getUIRenderCollector>, collector: ReturnType<typeof getUIRenderCollector>,
@@ -397,6 +491,7 @@ export class UIInputFieldRenderSystem extends EntitySystem {
rt: ReturnType<typeof getUIRenderTransform>, rt: ReturnType<typeof getUIRenderTransform>,
textX: number, textX: number,
textY: number, textY: number,
textWidth: number,
textHeight: number, textHeight: number,
entityId: number entityId: number
): void { ): void {
@@ -405,6 +500,15 @@ export class UIInputFieldRenderSystem extends EntitySystem {
const caretXOffset = input.getCaretX(); const caretXOffset = input.getCaretX();
const caretX = textX + caretXOffset - input.scrollOffset; const caretX = textX + caretXOffset - input.scrollOffset;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
collector.addRect( collector.addRect(
caretX + input.caretWidth / 2, caretX + input.caretWidth / 2,
textY + textHeight / 2, textY + textHeight / 2,
@@ -417,7 +521,8 @@ export class UIInputFieldRenderSystem extends EntitySystem {
{ {
pivotX: 0.5, pivotX: 0.5,
pivotY: 0.5, pivotY: 0.5,
entityId entityId,
clipRect
} }
); );
} }

View File

@@ -47,7 +47,8 @@ export type BatchBreakReason =
| 'first' // 第一个批次 | First batch | 'first' // 第一个批次 | First batch
| 'sortingLayer' // 排序层不同 | Different sorting layer | 'sortingLayer' // 排序层不同 | Different sorting layer
| 'texture' // 纹理不同 | Different texture | 'texture' // 纹理不同 | Different texture
| 'material'; // 材质不同 | Different material | 'material' // 材质不同 | Different material
| 'clipRect'; // 裁剪区域不同 | Different clip rect
/** /**
* 合批调试信息 * 合批调试信息
@@ -150,6 +151,13 @@ export interface UIRenderPrimitive {
materialOverrides?: UIMaterialOverrides; materialOverrides?: UIMaterialOverrides;
/** Source entity ID (for debugging). | 来源实体 ID用于调试。 */ /** Source entity ID (for debugging). | 来源实体 ID用于调试。 */
entityId?: number; entityId?: number;
/**
* Clip rectangle for scissor test (screen coordinates).
* Content outside this rect will be clipped.
* 裁剪矩形用于 scissor test屏幕坐标
* 此矩形外的内容将被裁剪。
*/
clipRect?: { x: number; y: number; width: number; height: number };
} }
/** /**
@@ -172,6 +180,13 @@ export interface ProviderRenderData {
materialIds?: Uint32Array; materialIds?: Uint32Array;
/** Material overrides (per-group). | 材质覆盖(按组)。 */ /** Material overrides (per-group). | 材质覆盖(按组)。 */
materialOverrides?: UIMaterialOverrides; materialOverrides?: UIMaterialOverrides;
/**
* Clip rectangle for scissor test (screen coordinates).
* All primitives in this batch will be clipped to this rect.
* 裁剪矩形用于 scissor test屏幕坐标
* 此批次中的所有原语将被裁剪到此矩形。
*/
clipRect?: { x: number; y: number; width: number; height: number };
} }
/** /**
@@ -234,6 +249,8 @@ export class UIRenderCollector {
materialOverrides?: UIMaterialOverrides; materialOverrides?: UIMaterialOverrides;
/** 来源实体 ID用于调试| Source entity ID (for debugging) */ /** 来源实体 ID用于调试| Source entity ID (for debugging) */
entityId?: number; entityId?: number;
/** 裁剪矩形(屏幕坐标)| Clip rectangle (screen coordinates) */
clipRect?: { x: number; y: number; width: number; height: number };
} }
): void { ): void {
// Pack color with alpha: 0xAABBGGRR // Pack color with alpha: 0xAABBGGRR
@@ -261,7 +278,8 @@ export class UIRenderCollector {
uv: options?.uv, uv: options?.uv,
materialId: options?.materialId, materialId: options?.materialId,
materialOverrides: options?.materialOverrides, materialOverrides: options?.materialOverrides,
entityId: options?.entityId entityId: options?.entityId,
clipRect: options?.clipRect
}; };
this.primitives.push(primitive); this.primitives.push(primitive);
@@ -537,14 +555,16 @@ export class UIRenderCollector {
// 每个批次的 entityId 集合 | Entity ID set per batch // 每个批次的 entityId 集合 | Entity ID set per batch
const batchEntityIds = new Map<string, Set<number>>(); const batchEntityIds = new Map<string, Set<number>>();
// 追踪上一个原语的属性以检测打断原因 | Track previous primitive's properties to detect break reason // Track previous primitive's properties to detect break reason
// 合批条件:连续的原语如果有相同的 sortingLayer + texture + material 就可以合批 // Batching condition: consecutive primitives with same sortingLayer + texture + material + clipRect can be batched
// orderInLayer 只决定渲染顺序,不影响能否合批
// Batching condition: consecutive primitives with same sortingLayer + texture + material can be batched
// orderInLayer only determines render order, doesn't affect batching // orderInLayer only determines render order, doesn't affect batching
// 追踪上一个原语的属性以检测打断原因
// 合批条件:连续的原语如果有相同的 sortingLayer + texture + material + clipRect 就可以合批
// orderInLayer 只决定渲染顺序,不影响能否合批
let prevSortingLayer: string | null = null; let prevSortingLayer: string | null = null;
let prevTextureKey: string | null = null; let prevTextureKey: string | null = null;
let prevMaterialKey: number | null = null; let prevMaterialKey: number | null = null;
let prevClipRectKey: string | null = null;
let batchIndex = 0; let batchIndex = 0;
let currentGroup: UIRenderPrimitive[] | null = null; let currentGroup: UIRenderPrimitive[] | null = null;
let currentBatchKey: string | null = null; let currentBatchKey: string | null = null;
@@ -572,9 +592,14 @@ export class UIRenderCollector {
} }
const materialKey = prim.materialId ?? 0; const materialKey = prim.materialId ?? 0;
// 合批 key 必须包含 orderInLayer否则不同深度的元素会被错误合并 // Generate clipRect key (null/undefined = no clipping)
// Batch key must include orderInLayer, otherwise elements at different depths will be incorrectly merged // 生成 clipRect keynull/undefined = 无裁剪)
const batchKey = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}:${materialKey}`; const clipRectKey = prim.clipRect
? `${prim.clipRect.x},${prim.clipRect.y},${prim.clipRect.width},${prim.clipRect.height}`
: 'none';
// Batch key must include orderInLayer and clipRect
// 合批 key 必须包含 orderInLayer 和 clipRect
const batchKey = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}:${materialKey}:${clipRectKey}`;
// 检查是否需要新批次sortingLayer、orderInLayer、texture 或 material 变化 // 检查是否需要新批次sortingLayer、orderInLayer、texture 或 material 变化
// Check if new batch needed: sortingLayer, orderInLayer, texture or material changed // Check if new batch needed: sortingLayer, orderInLayer, texture or material changed
@@ -595,6 +620,9 @@ export class UIRenderCollector {
} else if (materialKey !== prevMaterialKey) { } else if (materialKey !== prevMaterialKey) {
reason = 'material'; reason = 'material';
detail = `Material changed: ${prevMaterialKey}${materialKey}`; detail = `Material changed: ${prevMaterialKey}${materialKey}`;
} else if (clipRectKey !== prevClipRectKey) {
reason = 'clipRect';
detail = `ClipRect changed: ${prevClipRectKey}${clipRectKey}`;
} }
} }
@@ -634,6 +662,7 @@ export class UIRenderCollector {
prevSortingLayer = prim.sortingLayer; prevSortingLayer = prim.sortingLayer;
prevTextureKey = textureKey; prevTextureKey = textureKey;
prevMaterialKey = materialKey; prevMaterialKey = materialKey;
prevClipRectKey = clipRectKey;
} }
// 更新每个批次的原语数量和 entityIds | Update primitive count and entityIds for each batch // 更新每个批次的原语数量和 entityIds | Update primitive count and entityIds for each batch
@@ -769,6 +798,11 @@ export class UIRenderCollector {
if (firstPrim.materialOverrides && Object.keys(firstPrim.materialOverrides).length > 0) { if (firstPrim.materialOverrides && Object.keys(firstPrim.materialOverrides).length > 0) {
renderData.materialOverrides = firstPrim.materialOverrides; renderData.materialOverrides = firstPrim.materialOverrides;
} }
// Use the first primitive's clipRect (all in group share same clipRect)
// 使用第一个原语的 clipRect组内所有原语共享相同 clipRect
if (firstPrim.clipRect) {
renderData.clipRect = firstPrim.clipRect;
}
result.push({ data: renderData, addIndex: firstPrim.addIndex }); result.push({ data: renderData, addIndex: firstPrim.addIndex });
} }

View File

@@ -0,0 +1,248 @@
/**
* IME 输入法辅助服务
* IME (Input Method Editor) Helper Service
*
* 使用隐藏的 <input> 元素接收 IME 输入,支持中文/日文/韩文等需要输入法的语言。
* Uses a hidden <input> element to receive IME input, supporting Chinese/Japanese/Korean
* and other languages that require input methods.
*
* @example
* ```typescript
* const imeHelper = new IMEHelper();
* imeHelper.onCompositionEnd = (text) => {
* inputField.insertText(text);
* };
* imeHelper.focus();
* ```
*/
export class IMEHelper {
private hiddenInput: HTMLInputElement;
private canvas: HTMLCanvasElement | null = null;
// ===== 状态 State =====
/**
* 是否正在进行 IME 组合
* Whether IME composition is in progress
*/
public isComposing: boolean = false;
/**
* 当前组合中的文本
* Current composition text
*/
public compositionText: string = '';
// ===== 回调 Callbacks =====
/**
* 组合开始回调
* Composition start callback
*/
onCompositionStart?: () => void;
/**
* 组合更新回调(用户输入拼音等)
* Composition update callback (user typing pinyin, etc.)
*/
onCompositionUpdate?: (text: string) => void;
/**
* 组合结束回调(用户选择了候选字)
* Composition end callback (user selected a candidate)
*/
onCompositionEnd?: (text: string) => void;
/**
* 直接输入回调(非 IME 输入)
* Direct input callback (non-IME input)
*/
onInput?: (text: string) => void;
constructor() {
this.hiddenInput = this.createHiddenInput();
this.bindEvents();
}
/**
* 创建隐藏的 input 元素
* Create hidden input element
*/
private createHiddenInput(): HTMLInputElement {
const input = document.createElement('input');
input.type = 'text';
input.id = '__esengine_ime_input__';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
// 使用样式隐藏但保持可聚焦
// Hide but keep focusable
input.style.cssText = `
position: absolute;
left: 0px;
top: 0px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
z-index: -1;
border: none;
outline: none;
padding: 0;
margin: 0;
background: transparent;
font-size: 16px;
`;
document.body.appendChild(input);
return input;
}
/**
* 绑定事件
* Bind events
*/
private bindEvents(): void {
// 组合开始 | Composition start
this.hiddenInput.addEventListener('compositionstart', (_e) => {
this.isComposing = true;
this.compositionText = '';
this.onCompositionStart?.();
});
// 组合更新 | Composition update
this.hiddenInput.addEventListener('compositionupdate', (e) => {
this.compositionText = e.data || '';
this.onCompositionUpdate?.(this.compositionText);
});
// 组合结束 | Composition end
this.hiddenInput.addEventListener('compositionend', (e) => {
this.isComposing = false;
const text = e.data || '';
this.compositionText = '';
this.onCompositionEnd?.(text);
// 清空 input 值以便下次输入
// Clear input value for next input
this.hiddenInput.value = '';
});
// 直接输入(非 IME| Direct input (non-IME)
this.hiddenInput.addEventListener('input', (e) => {
// 组合过程中不处理 input 事件
// Don't handle input event during composition
if (this.isComposing) return;
const input = e.target as HTMLInputElement;
if (input.value) {
this.onInput?.(input.value);
// 清空以便下次输入
// Clear for next input
input.value = '';
}
});
// 阻止默认键盘行为(由 UIInputSystem 处理)
// Prevent default keyboard behavior (handled by UIInputSystem)
this.hiddenInput.addEventListener('keydown', (e) => {
// 允许 IME 相关的键
// Allow IME-related keys
if (this.isComposing) return;
// 阻止非 IME 的默认行为(如 Backspace、Enter 等)
// Prevent non-IME default behavior (like Backspace, Enter, etc.)
const specialKeys = ['Backspace', 'Delete', 'Enter', 'Tab', 'Escape', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
if (specialKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
// 这些键由 UIInputSystem 处理,不需要在这里处理
// These keys are handled by UIInputSystem, no need to handle here
return;
}
});
}
/**
* 设置关联的 Canvas 元素
* Set associated canvas element
*/
setCanvas(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
}
/**
* 更新隐藏 input 的位置(让 IME 候选窗口出现在正确位置)
* Update hidden input position (so IME candidate window appears in correct position)
*
* @param screenX - 屏幕 X 坐标 | Screen X coordinate
* @param screenY - 屏幕 Y 坐标 | Screen Y coordinate
*/
updatePosition(screenX: number, screenY: number): void {
this.hiddenInput.style.left = `${screenX}px`;
this.hiddenInput.style.top = `${screenY}px`;
}
/**
* 聚焦隐藏的 input 元素
* Focus the hidden input element
*/
focus(): void {
this.hiddenInput.value = '';
this.isComposing = false;
this.compositionText = '';
this.hiddenInput.focus();
}
/**
* 取消聚焦
* Blur the hidden input element
*/
blur(): void {
this.hiddenInput.blur();
this.isComposing = false;
this.compositionText = '';
}
/**
* 检查是否已聚焦
* Check if focused
*/
isFocused(): boolean {
return document.activeElement === this.hiddenInput;
}
/**
* 释放资源
* Dispose resources
*/
dispose(): void {
this.hiddenInput.remove();
this.onCompositionStart = undefined;
this.onCompositionUpdate = undefined;
this.onCompositionEnd = undefined;
this.onInput = undefined;
}
}
// ===== 全局单例 Global Singleton =====
let globalIMEHelper: IMEHelper | null = null;
/**
* 获取全局 IME 辅助服务实例
* Get global IME helper instance
*/
export function getIMEHelper(): IMEHelper {
if (!globalIMEHelper) {
globalIMEHelper = new IMEHelper();
}
return globalIMEHelper;
}
/**
* 销毁全局 IME 辅助服务实例
* Dispose global IME helper instance
*/
export function disposeIMEHelper(): void {
if (globalIMEHelper) {
globalIMEHelper.dispose();
globalIMEHelper = null;
}
}

View File

@@ -38,3 +38,10 @@ export {
type CharacterPosition, type CharacterPosition,
type LineInfo type LineInfo
} from './TextMeasureService'; } from './TextMeasureService';
export {
// IME utilities | IME 输入法工具
IMEHelper,
getIMEHelper,
disposeIMEHelper
} from './IMEHelper';