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