From ecdb8f202111094f74c563629f379c7e6d158c6f Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Fri, 19 Dec 2025 15:45:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UI=E8=BE=93=E5=85=A5=E6=A1=86IME?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=92=8C=E7=BC=96=E8=BE=91=E5=99=A8Inspector?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20(#310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI系统改进: - 添加 IMEHelper 支持中文/日文/韩文输入法 - UIInputFieldComponent 添加组合输入状态管理 - UIInputSystem 添加 IME 事件处理 - UIInputFieldRenderSystem 优化渲染逻辑 - UIRenderCollector 增强纹理处理 引擎改进: - EngineBridge 添加新的渲染接口 - EngineRenderSystem 优化渲染流程 - Rust 引擎添加新的渲染功能 编辑器改进: - 新增模块化 Inspector 组件架构 - EntityRefField 增强实体引用选择 - 优化 FlexLayoutDock 和 SceneHierarchy 样式 - 添加国际化文本 --- .../src/core/EngineBridge.ts | 26 + .../src/systems/EngineRenderSystem.ts | 113 +- .../ecs-engine-bindgen/src/types/index.ts | 7 + .../src/wasm/es_engine.d.ts | 21 + packages/editor-app/src/App.tsx | 42 + .../inspector/ComponentPropertyEditor.tsx | 501 ++++++++ .../inspector/EntityInspectorPanel.tsx | 695 +++++++++++ .../components/inspector/InspectorPanel.tsx | 339 ++++++ .../inspector/controls/ArrayInput.tsx | 228 ++++ .../inspector/controls/AssetInput.tsx | 380 ++++++ .../inspector/controls/BooleanInput.tsx | 43 + .../inspector/controls/ColorInput.tsx | 170 +++ .../inspector/controls/EntityRefInput.tsx | 251 ++++ .../inspector/controls/EnumInput.tsx | 85 ++ .../inspector/controls/NumberInput.tsx | 93 ++ .../inspector/controls/PropertyRow.tsx | 50 + .../inspector/controls/StringInput.tsx | 69 ++ .../inspector/controls/VectorInput.tsx | 109 ++ .../components/inspector/controls/index.ts | 20 + .../inspector/header/CategoryTabs.tsx | 40 + .../inspector/header/InspectorHeader.tsx | 44 + .../inspector/header/PropertySearch.tsx | 52 + .../src/components/inspector/header/index.ts | 8 + .../src/components/inspector/index.ts | 21 + .../inspector/sections/PropertySection.tsx | 51 + .../components/inspector/sections/index.ts | 6 + .../inspector/styles/inspector-variables.css | 56 + .../components/inspector/styles/inspector.css | 1028 +++++++++++++++++ .../src/components/inspector/types.ts | 177 +++ .../src/components/inspectors/Inspector.tsx | 4 +- .../inspectors/fields/EntityRefField.css | 81 +- .../inspectors/fields/EntityRefField.tsx | 83 +- packages/editor-app/src/locales/en.ts | 13 + packages/editor-app/src/locales/zh.ts | 13 + .../editor-app/src/styles/FlexLayoutDock.css | 354 +++--- .../editor-app/src/styles/SceneHierarchy.css | 24 +- packages/engine/src/core/engine.rs | 12 + packages/engine/src/lib.rs | 23 + packages/engine/src/renderer/renderer2d.rs | 56 + packages/platform-web/src/BrowserRuntime.ts | 12 +- .../widgets/UIInputFieldComponent.ts | 51 + packages/ui/src/systems/UIInputSystem.ts | 187 +++ .../render/UIInputFieldRenderSystem.ts | 137 ++- .../src/systems/render/UIRenderCollector.ts | 52 +- packages/ui/src/utils/IMEHelper.ts | 248 ++++ packages/ui/src/utils/index.ts | 7 + 46 files changed, 5825 insertions(+), 257 deletions(-) create mode 100644 packages/editor-app/src/components/inspector/ComponentPropertyEditor.tsx create mode 100644 packages/editor-app/src/components/inspector/EntityInspectorPanel.tsx create mode 100644 packages/editor-app/src/components/inspector/InspectorPanel.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/ArrayInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/AssetInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/BooleanInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/ColorInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/EntityRefInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/EnumInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/NumberInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/PropertyRow.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/StringInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/VectorInput.tsx create mode 100644 packages/editor-app/src/components/inspector/controls/index.ts create mode 100644 packages/editor-app/src/components/inspector/header/CategoryTabs.tsx create mode 100644 packages/editor-app/src/components/inspector/header/InspectorHeader.tsx create mode 100644 packages/editor-app/src/components/inspector/header/PropertySearch.tsx create mode 100644 packages/editor-app/src/components/inspector/header/index.ts create mode 100644 packages/editor-app/src/components/inspector/index.ts create mode 100644 packages/editor-app/src/components/inspector/sections/PropertySection.tsx create mode 100644 packages/editor-app/src/components/inspector/sections/index.ts create mode 100644 packages/editor-app/src/components/inspector/styles/inspector-variables.css create mode 100644 packages/editor-app/src/components/inspector/styles/inspector.css create mode 100644 packages/editor-app/src/components/inspector/types.ts create mode 100644 packages/ui/src/utils/IMEHelper.ts diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index 202cbecb..a3fd8316 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -293,6 +293,32 @@ export class EngineBridge implements ITextureEngineBridge { 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. * 加载纹理。 diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index 3afca9ef..79153c32 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -51,6 +51,13 @@ export interface ProviderRenderData { materialIds?: Uint32Array; /** Material overrides (per-group). | 材质覆盖(按组)。 */ 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); - // Clear batcher for screen space content - // 清空批处理器用于屏幕空间内容 - this.batcher.clear(); + // Group sprites by clipRect (in render order) + // 按 clipRect 分组 sprites(按渲染顺序) + type ClipGroup = { + clipRect: { x: number; y: number; width: number; height: number } | undefined; + sprites: SpriteRenderData[]; + }; - // Submit screen space sprites - // 提交屏幕空间 sprites + const clipGroups: ClipGroup[] = []; + 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 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) { - 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(); + // Don't forget the last group + // 别忘了最后一组 + if (currentGroup.length > 0) { + clipGroups.push({ clipRect: currentClipRect, sprites: currentGroup }); } + // Render each clip group + // 渲染每个裁剪组 + for (const group of clipGroups) { + // Set or clear scissor rect + // 设置或清除裁剪矩形 + if (group.clipRect) { + this.bridge.setScissorRect( + group.clipRect.x, + group.clipRect.y, + group.clipRect.width, + group.clipRect.height + ); + } else { + this.bridge.clearScissorRect(); + } + + // Clear batcher and add sprites + // 清空批处理器并添加 sprites + this.batcher.clear(); + for (const sprite of group.sprites) { + this.batcher.addSprite(sprite); + } + + if (!this.batcher.isEmpty) { + 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 // 恢复世界空间相机 this.bridge.popScreenSpaceMode(); @@ -802,6 +875,7 @@ export class EngineRenderSystem extends EntitySystem { // 检查材质数据 const hasMaterialIds = data.materialIds && data.materialIds.length > 0; const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0; + const hasClipRect = !!data.clipRect; const sprites: SpriteRenderData[] = []; for (let i = 0; i < data.tileCount; i++) { @@ -836,6 +910,11 @@ export class EngineRenderSystem extends EntitySystem { if (hasMaterialOverrides) { 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); } diff --git a/packages/ecs-engine-bindgen/src/types/index.ts b/packages/ecs-engine-bindgen/src/types/index.ts index 53a01c28..ef0856f0 100644 --- a/packages/ecs-engine-bindgen/src/types/index.ts +++ b/packages/ecs-engine-bindgen/src/types/index.ts @@ -52,6 +52,13 @@ export interface SpriteRenderData { * 材质属性覆盖(实例级别)。 */ 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 }; } /** diff --git a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts index db66b539..4bfc5891 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -217,6 +217,20 @@ export class GameEngine { * * `id` - Texture ID | 纹理ID */ 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. * 添加胶囊Gizmo边框。 @@ -269,6 +283,11 @@ export class GameEngine { * 请谨慎使用,因为所有纹理引用都将变得无效。 */ clearAllTextures(): void; + /** + * Clear scissor rect (disable clipping). + * 清除裁剪矩形(禁用裁剪)。 + */ + clearScissorRect(): void; /** * 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_clear: (a: number, b: number, c: number, d: number, e: number) => void; readonly gameengine_clearAllTextures: (a: number) => void; + readonly gameengine_clearScissorRect: (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_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_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_setScissorRect: (a: number, b: number, c: number, d: number, e: number) => void; readonly gameengine_setShowGizmos: (a: number, b: number) => void; readonly gameengine_setShowGrid: (a: number, b: number) => void; readonly gameengine_setTransformMode: (a: number, b: number) => void; diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 9d022c3e..66f6a1cd 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -51,6 +51,7 @@ import { ConfirmDialog } from './components/ConfirmDialog'; import { ExternalModificationDialog } from './components/ExternalModificationDialog'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { BuildSettingsWindow } from './components/BuildSettingsWindow'; +import { AssetPickerDialog } from './components/dialogs/AssetPickerDialog'; import { ForumPanel } from './components/forum'; import { ToastProvider, useToast } from './components/Toast'; import { TitleBar } from './components/TitleBar'; @@ -210,6 +211,13 @@ function App() { externalModificationDialog, setExternalModificationDialog } = 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 useEffect(() => { let broadcastInterval: ReturnType | null = null; @@ -490,6 +498,26 @@ function App() { return () => unsubscribe?.(); }, [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(() => { if (!initialized || !messageHubRef.current) return; const hub = messageHubRef.current; @@ -1427,6 +1455,20 @@ function App() { /> )} + {/* 资产选择器对话框 | Asset Picker Dialog */} + setAssetPickerState({ isOpen: false })} + onSelect={(path) => { + if (assetPickerState.onSelect) { + assetPickerState.onSelect(path); + } + setAssetPickerState({ isOpen: false }); + }} + title={t('asset.selectAsset')} + fileExtensions={assetPickerState.extensions} + /> + {/* 渲染调试面板 | Render Debug Panel */} ; + 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 = ({ + component, + entity, + version, + onChange, + onAction +}) => { + const [properties, setProperties] = useState>({}); + const [controlledFields, setControlledFields] = useState>(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); + }, [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(); + + for (const otherComponent of entity.components) { + if (otherComponent === component) continue; + + const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent) as Record; + 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 ? ( + + {label} + + + + + ) : label; + const labelTitle = label; + + switch (metadata.type) { + case 'number': + case 'integer': + return ( + + handleChange(propertyName, v)} + readonly={readonly} + min={metadata.min} + max={metadata.max} + step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)} + integer={metadata.type === 'integer'} + /> + + ); + + case 'string': + return ( + + handleChange(propertyName, v)} + readonly={readonly} + placeholder={metadata.placeholder} + /> + + ); + + case 'boolean': + return ( + + handleChange(propertyName, v)} + readonly={readonly} + /> + + ); + + case 'color': { + let colorValue = value ?? '#ffffff'; + const wasNumber = typeof colorValue === 'number'; + if (wasNumber) { + colorValue = '#' + colorValue.toString(16).padStart(6, '0'); + } + return ( + + { + if (wasNumber && typeof v === 'string') { + handleChange(propertyName, parseInt(v.slice(1), 16)); + } else { + handleChange(propertyName, v); + } + }} + readonly={readonly} + /> + + ); + } + + case 'vector2': + return ( + + handleChange(propertyName, v)} + readonly={readonly} + dimensions={2} + /> + + ); + + case 'vector3': + return ( + + handleChange(propertyName, v)} + readonly={readonly} + dimensions={3} + /> + + ); + + case 'enum': { + const options = (metadata.options || []).map(opt => + typeof opt === 'object' ? opt : { label: String(opt), value: opt } + ); + return ( + + handleChange(propertyName, v)} + readonly={readonly} + options={options} + /> + + ); + } + + 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 ( + + { + 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); + }} + /> + + ); + } + + case 'entityRef': + return ( + + { + 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) }); + } + }} + /> + + ); + + case 'array': { + return ( + + handleChange(propertyName, v)} + readonly={readonly} + minItems={metadata.minLength} + maxItems={metadata.maxLength} + sortable={metadata.reorderable ?? true} + /> + + ); + } + + default: + return null; + } + }; + + // ==================== 渲染 | Render ==================== + + return ( +
+ {Object.entries(properties).map(([propertyName, metadata]) => { + const overridden = isPropertyOverridden(propertyName); + return ( +
handleContextMenu(e, propertyName)} + style={overridden ? { borderLeft: '2px solid var(--inspector-accent)' } : undefined} + > + {renderProperty(propertyName, metadata)} +
+ ); + })} + + {/* Context Menu */} + {contextMenu && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/EntityInspectorPanel.tsx b/packages/editor-app/src/components/inspector/EntityInspectorPanel.tsx new file mode 100644 index 00000000..53f8b390 --- /dev/null +++ b/packages/editor-app/src/components/inspector/EntityInspectorPanel.tsx @@ -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 = { + '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 = { + '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 = ({ + entity, + messageHub, + commandManager, + componentVersion, + isLocked = false, + onLockChange +}) => { + // ==================== 状态 | State ==================== + + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [localVersion, setLocalVersion] = useState(0); + + // 折叠状态(持久化)| Collapsed state (persisted) + const [collapsedComponents, setCollapsedComponents] = useState>(() => { + 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>(new Set()); + const [selectedIndex, setSelectedIndex] = useState(-1); + const addButtonRef = useRef(null); + const searchInputRef = useRef(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(); + + 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(); + 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 ( +
+ {/* Header */} +
+
+ + + + + + {entity.name || `Entity #${entity.id}`} + +
+ + 1 object + +
+ + {/* Search */} + + + {/* Category Tabs - 只有多个分类时显示 | Only show when multiple categories */} + {availableCategories.length > 1 && ( + setCategoryFilter(cat as CategoryFilter)} + /> + )} + + {/* Content */} +
+ {/* Add Component Section Header */} +
+
+ 组件 + +
+
+ + {/* Component List */} + {filteredComponents.length === 0 ? ( +
+ {entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'} +
+ ) : ( + 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>)[iconName]; + + return ( +
+
toggleComponentExpanded(componentName)} + > + + + + + {IconComponent ? : } + + {componentName} + +
+ + {isExpanded && ( +
+ {componentInspectorRegistry?.hasInspector(component) ? ( + componentInspectorRegistry.render({ + component, + entity, + version: componentVersion + localVersion, + onChange: (propName: string, value: unknown) => + handlePropertyChange(component, propName, value), + onAction: handlePropertyAction + }) + ) : ( + + 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>)[action.icon] + : null; + return ( + + ); + })} +
+ )} +
+ ); + }) + )} +
+ + {/* Add Component Menu */} + {showAddMenu && ( + <> +
setShowAddMenu(false)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 99 + }} + /> +
+ {/* Search */} +
+ + setAddMenuSearch(e.target.value)} + onKeyDown={handleAddMenuKeyDown} + /> +
+ + {/* Component List */} +
+ {groupedComponents.size === 0 ? ( +
+ {addMenuSearch ? '未找到匹配的组件' : '没有可用组件'} +
+ ) : ( + (() => { + 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 ( +
+
toggleCategory(category)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '4px', + fontWeight: 500, + background: 'var(--inspector-bg-section)' + }} + > + {isCollapsed ? : } + {label} + + {components.length} + +
+ + {!isCollapsed && components.map((info, idx) => { + const IconComp = info.icon && (LucideIcons as any)[info.icon]; + const itemIndex = startIndex + idx; + const isSelected = itemIndex === selectedIndex; + + return ( +
info.type && handleAddComponent(info.type)} + onMouseEnter={() => setSelectedIndex(itemIndex)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '8px', + paddingLeft: '24px' + }} + > + {IconComp ? : } + {info.name} +
+ ); + })} +
+ ); + }); + })() + )} +
+
+ + )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/InspectorPanel.tsx b/packages/editor-app/src/components/inspector/InspectorPanel.tsx new file mode 100644 index 00000000..cbb25c4e --- /dev/null +++ b/packages/editor-app/src/components/inspector/InspectorPanel.tsx @@ -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 +): React.ReactNode => { + switch (type) { + case 'number': + return ( + + ); + + case 'string': + return ( + + ); + + case 'boolean': + return ( + + ); + + case 'vector2': + return ( + + ); + + case 'vector3': + return ( + + ); + + case 'vector4': + return ( + + ); + + case 'enum': + return ( + + ); + + case 'color': + return ( + + ); + + case 'asset': + return ( + + ); + + case 'entityRef': + return ( + + ); + + case 'array': + return ( + + ); + + // TODO: 后续实现 | To be implemented + case 'object': + return [{type}]; + + default: + return [unknown]; + } +}; + +/** + * 默认分类配置 + * Default category configuration + */ +const DEFAULT_CATEGORIES: CategoryConfig[] = [ + { id: 'all', label: 'All' } +]; + +export const InspectorPanel: React.FC = ({ + 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 ( + + {/* 属性列表 | Property list */} + {section.properties.map(prop => ( + + {renderControl( + prop.type, + getValue(prop.name), + (value) => onChange(prop.name, value), + readonly, + prop.metadata + )} + + ))} + + {/* 子 Section | Sub sections */} + {section.subsections?.map(sub => renderSection(sub, depth + 1))} + + ); + }, [getValue, onChange, readonly]); + + return ( +
+ {/* 头部 | Header */} + {targetName && ( + + )} + + {/* 搜索栏 | Search bar */} + + + {/* 分类标签 | Category tabs */} + {showCategoryTabs && ( + + )} + + {/* 属性内容 | Property content */} +
+ {filteredSections.length > 0 ? ( + filteredSections.map(section => renderSection(section)) + ) : ( +
+ {searchQuery ? 'No matching properties' : 'No properties'} +
+ )} +
+
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/ArrayInput.tsx b/packages/editor-app/src/components/inspector/controls/ArrayInput.tsx new file mode 100644 index 00000000..981c6942 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/ArrayInput.tsx @@ -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 extends PropertyControlProps { + /** 元素渲染器 | 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({ + value = [], + onChange, + readonly = false, + renderElement, + createNewElement, + minItems = 0, + maxItems, + sortable = false, + collapsedTitle +}: ArrayInputProps): React.ReactElement { + const [expanded, setExpanded] = useState(true); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(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) => ( +
+ {String(element)} +
+ ); + + return ( +
+ {/* 头部 | Header */} +
+ + {expanded ? : } + + {getTitle()} + + {/* 添加按钮 | Add button */} + {canAdd && !readonly && ( + + )} +
+ + {/* 元素列表 | Element list */} + {expanded && ( +
+ {items.map((element, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + > + {/* 拖拽手柄 | Drag handle */} + {sortable && !readonly && ( +
+ +
+ )} + + {/* 索引 | Index */} + {index} + + {/* 内容 | Content */} +
+ {renderElement + ? renderElement( + element, + index, + (val) => handleElementChange(index, val), + () => handleRemove(index) + ) + : defaultRenderElement(element, index) + } +
+ + {/* 删除按钮 | Remove button */} + {canRemove && !readonly && ( + + )} +
+ ))} + + {/* 空状态 | Empty state */} + {items.length === 0 && ( +
+ No items +
+ )} +
+ )} +
+ ); +} diff --git a/packages/editor-app/src/components/inspector/controls/AssetInput.tsx b/packages/editor-app/src/components/inspector/controls/AssetInput.tsx new file mode 100644 index 00000000..e3489f87 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/AssetInput.tsx @@ -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 { + /** 允许的资产类型 | 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 = ({ + 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(null); + const containerRef = useRef(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 ( +
+ {/* 缩略图 | Thumbnail */} + {showThumbnail && ( +
+ {thumbnailUrl ? ( + + ) : ( + + )} +
+ )} + + {/* 值显示和下拉按钮 | Value display and dropdown button */} +
+
+ {displayName || None} +
+ {!readonly && ( + + )} +
+ + {/* 操作按钮 | Action buttons */} +
+ {/* 定位按钮 | Locate button */} + {hasValue && onLocateAsset && ( + + )} + + {/* 复制按钮 | Copy button */} + {hasValue && ( + + )} + + {/* 浏览按钮 | Browse button */} + {onPickAsset && !readonly && ( + + )} + + {/* 清除按钮 | Clear button */} + {hasValue && !readonly && ( + + )} +
+ + {/* 下拉菜单 | Dropdown menu */} + {showDropdown && ( +
+ {/* 浏览选项 | Browse option */} + {onPickAsset && ( +
+ + Browse... +
+ )} + + {/* 清除选项 | Clear option */} + {hasValue && ( +
+ + Clear +
+ )} + + {/* 分割线 | Divider */} + {recentAssets.length > 0 && ( + <> +
+
Recent
+ + )} + + {/* 最近使用 | Recent assets */} + {recentAssets.map((asset, index) => ( +
handleSelectAsset(asset)} + > + {asset.thumbnail ? ( + + ) : ( + + )} + {getAssetDisplayName(asset)} +
+ ))} + + {/* 空状态 | Empty state */} + {!onPickAsset && !hasValue && recentAssets.length === 0 && ( +
No assets available
+ )} +
+ )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/BooleanInput.tsx b/packages/editor-app/src/components/inspector/controls/BooleanInput.tsx new file mode 100644 index 00000000..fdd935b6 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/BooleanInput.tsx @@ -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 {} + +export const BooleanInput: React.FC = ({ + 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 ( +
+ +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/ColorInput.tsx b/packages/editor-app/src/components/inspector/controls/ColorInput.tsx new file mode 100644 index 00000000..49753d95 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/ColorInput.tsx @@ -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 { + /** 是否显示 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 = ({ + value, + onChange, + readonly = false, + showAlpha = false +}) => { + const inputRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* 颜色预览块 | Color swatch */} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/EntityRefInput.tsx b/packages/editor-app/src/components/inspector/controls/EntityRefInput.tsx new file mode 100644 index 00000000..bf7439b7 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/EntityRefInput.tsx @@ -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 { + /** 实体名称解析器 | 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 = ({ + value, + onChange, + readonly = false, + resolveEntityName, + onSelectEntity, + onLocateEntity +}) => { + const [isDragOver, setIsDragOver] = useState(false); + const dropZoneRef = useRef(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 ( +
+ {/* 图标 | Icon */} + + + {/* 值显示 | Value display */} +
+ {displayName || None} +
+ + {/* 操作按钮 | Action buttons */} +
+ {/* 定位按钮 | Locate button */} + {hasValue && onLocateEntity && ( + + )} + + {/* 选择按钮 | Select button */} + {onSelectEntity && !readonly && ( + + )} + + {/* 清除按钮 | Clear button */} + {hasValue && !readonly && ( + + )} +
+ + {/* 拖放提示 | Drop hint */} + {isDragOver && ( +
+ Drop to assign +
+ )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/EnumInput.tsx b/packages/editor-app/src/components/inspector/controls/EnumInput.tsx new file mode 100644 index 00000000..63358ab7 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/EnumInput.tsx @@ -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 { + /** 选项列表 | Options list */ + options: EnumOption[]; + /** 占位文本 | Placeholder text */ + placeholder?: string; +} + +export const EnumInput: React.FC = ({ + value, + onChange, + readonly = false, + options = [], + placeholder = '选择...' +}) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(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 ( +
+
+ {displayValue} + +
+ + {isOpen && ( +
+ {options.map(option => ( +
handleSelect(option.value)} + > + {option.label} +
+ ))} +
+ )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/NumberInput.tsx b/packages/editor-app/src/components/inspector/controls/NumberInput.tsx new file mode 100644 index 00000000..0654ab44 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/NumberInput.tsx @@ -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 { + /** 最小值 | Minimum value */ + min?: number; + /** 最大值 | Maximum value */ + max?: number; + /** 步进值 | Step value */ + step?: number; + /** 是否为整数 | Integer only */ + integer?: boolean; +} + +export const NumberInput: React.FC = ({ + 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) => { + 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) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setLocalValue(String(value ?? 0)); + e.currentTarget.blur(); + } + }, [value]); + + return ( + + ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/PropertyRow.tsx b/packages/editor-app/src/components/inspector/controls/PropertyRow.tsx new file mode 100644 index 00000000..71ef7462 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/PropertyRow.tsx @@ -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 = ({ + 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 ( +
+ + {label} + +
+ {children} +
+
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/StringInput.tsx b/packages/editor-app/src/components/inspector/controls/StringInput.tsx new file mode 100644 index 00000000..89057d9d --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/StringInput.tsx @@ -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 { + /** 占位文本 | Placeholder text */ + placeholder?: string; + /** 是否多行 | Multiline mode */ + multiline?: boolean; +} + +export const StringInput: React.FC = ({ + 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) => { + 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) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setLocalValue(value ?? ''); + e.currentTarget.blur(); + } + }, [value]); + + return ( + + ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/VectorInput.tsx b/packages/editor-app/src/components/inspector/controls/VectorInput.tsx new file mode 100644 index 00000000..113c5dcc --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/VectorInput.tsx @@ -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 { + /** 向量维度 | Vector dimensions */ + dimensions?: 2 | 3 | 4; +} + +interface AxisInputProps { + axis: AxisKey; + value: number; + onChange: (value: number) => void; + readonly?: boolean; +} + +const AxisInput: React.FC = ({ 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) => { + 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) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setLocalValue(String(value ?? 0)); + e.currentTarget.blur(); + } + }, [value]); + + return ( +
+ + +
+ ); +}; + +export const VectorInput: React.FC = ({ + value, + onChange, + readonly = false, + dimensions = 3 +}) => { + const axes = useMemo(() => { + 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 ( +
+ {axes.map(axis => ( + handleAxisChange(axis, v)} + readonly={readonly} + /> + ))} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/controls/index.ts b/packages/editor-app/src/components/inspector/controls/index.ts new file mode 100644 index 00000000..4bcc29e4 --- /dev/null +++ b/packages/editor-app/src/components/inspector/controls/index.ts @@ -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'; diff --git a/packages/editor-app/src/components/inspector/header/CategoryTabs.tsx b/packages/editor-app/src/components/inspector/header/CategoryTabs.tsx new file mode 100644 index 00000000..e7c8723b --- /dev/null +++ b/packages/editor-app/src/components/inspector/header/CategoryTabs.tsx @@ -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 = ({ + categories, + current, + onChange +}) => { + const handleClick = useCallback((categoryId: string) => { + onChange(categoryId); + }, [onChange]); + + return ( +
+ {categories.map(cat => ( + + ))} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/header/InspectorHeader.tsx b/packages/editor-app/src/components/inspector/header/InspectorHeader.tsx new file mode 100644 index 00000000..0cb8e231 --- /dev/null +++ b/packages/editor-app/src/components/inspector/header/InspectorHeader.tsx @@ -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 = ({ + name, + icon, + onAdd, + showAddButton = true +}) => { + return ( +
+
+ {icon && {icon}} + {name} +
+ {showAddButton && onAdd && ( + + )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/header/PropertySearch.tsx b/packages/editor-app/src/components/inspector/header/PropertySearch.tsx new file mode 100644 index 00000000..b1336cd5 --- /dev/null +++ b/packages/editor-app/src/components/inspector/header/PropertySearch.tsx @@ -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 = ({ + value, + onChange, + placeholder = 'Search...' +}) => { + const handleChange = useCallback((e: React.ChangeEvent) => { + onChange(e.target.value); + }, [onChange]); + + const handleClear = useCallback(() => { + onChange(''); + }, [onChange]); + + return ( +
+ + + {value && ( + + )} +
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/header/index.ts b/packages/editor-app/src/components/inspector/header/index.ts new file mode 100644 index 00000000..a9f265e7 --- /dev/null +++ b/packages/editor-app/src/components/inspector/header/index.ts @@ -0,0 +1,8 @@ +/** + * Inspector Header Components + * Inspector 头部组件导出 + */ + +export * from './InspectorHeader'; +export * from './PropertySearch'; +export * from './CategoryTabs'; diff --git a/packages/editor-app/src/components/inspector/index.ts b/packages/editor-app/src/components/inspector/index.ts new file mode 100644 index 00000000..62767c42 --- /dev/null +++ b/packages/editor-app/src/components/inspector/index.ts @@ -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'; diff --git a/packages/editor-app/src/components/inspector/sections/PropertySection.tsx b/packages/editor-app/src/components/inspector/sections/PropertySection.tsx new file mode 100644 index 00000000..233ec77d --- /dev/null +++ b/packages/editor-app/src/components/inspector/sections/PropertySection.tsx @@ -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 = ({ + title, + defaultExpanded = true, + children, + depth = 0 +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + const handleToggle = useCallback(() => { + setExpanded(prev => !prev); + }, []); + + const paddingLeft = depth * 16; + + return ( +
+
+ + + + {title} +
+
+ {children} +
+
+ ); +}; diff --git a/packages/editor-app/src/components/inspector/sections/index.ts b/packages/editor-app/src/components/inspector/sections/index.ts new file mode 100644 index 00000000..3698f9f3 --- /dev/null +++ b/packages/editor-app/src/components/inspector/sections/index.ts @@ -0,0 +1,6 @@ +/** + * Inspector Sections + * Inspector 分组导出 + */ + +export * from './PropertySection'; diff --git a/packages/editor-app/src/components/inspector/styles/inspector-variables.css b/packages/editor-app/src/components/inspector/styles/inspector-variables.css new file mode 100644 index 00000000..920fd240 --- /dev/null +++ b/packages/editor-app/src/components/inspector/styles/inspector-variables.css @@ -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; +} diff --git a/packages/editor-app/src/components/inspector/styles/inspector.css b/packages/editor-app/src/components/inspector/styles/inspector.css new file mode 100644 index 00000000..4a5f5d17 --- /dev/null +++ b/packages/editor-app/src/components/inspector/styles/inspector.css @@ -0,0 +1,1028 @@ +/** + * Inspector Panel Styles + * Inspector 面板样式 + */ + +@import './inspector-variables.css'; + +/* ==================== 主容器 | Main Container ==================== */ +.inspector-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--inspector-bg-base); + color: var(--inspector-text-primary); + font-family: var(--inspector-font-family); + font-size: var(--inspector-font-size); + overflow: hidden; +} + +.inspector-panel-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* ==================== Section 分组 | Section Groups ==================== */ +.inspector-section { + border-bottom: 1px solid var(--inspector-border); +} + +.inspector-section-header { + display: flex; + align-items: center; + height: var(--inspector-row-height); + padding: var(--inspector-section-padding); + background: var(--inspector-bg-section); + cursor: pointer; + user-select: none; + gap: 4px; +} + +.inspector-section-header:hover { + background: var(--inspector-bg-hover); +} + +.inspector-section-arrow { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--inspector-text-secondary); + transition: transform var(--inspector-transition-normal); + flex-shrink: 0; +} + +.inspector-section-arrow.expanded { + transform: rotate(90deg); +} + +.inspector-section-title { + flex: 1; + font-weight: 400; + color: var(--inspector-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-section-content { + display: none; +} + +.inspector-section-content.expanded { + display: block; +} + +/* ==================== 属性行 | Property Row ==================== */ +.inspector-property-row { + display: flex; + align-items: center; + min-height: var(--inspector-row-height); + padding: 2px 8px 2px calc(var(--inspector-indent) + 8px); + border-bottom: 1px solid var(--inspector-border); +} + +.inspector-property-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.inspector-property-row:last-child { + border-bottom: none; +} + +/* 嵌套层级缩进 | Nested Level Indentation */ +.inspector-property-row[data-depth="1"] { + padding-left: calc(var(--inspector-indent) * 2 + 8px); +} + +.inspector-property-row[data-depth="2"] { + padding-left: calc(var(--inspector-indent) * 3 + 8px); +} + +/* ==================== 属性标签 | Property Label ==================== */ +.inspector-property-label { + flex: 0 0 var(--inspector-label-width); + min-width: 60px; + max-width: 140px; + font-size: var(--inspector-font-size); + color: var(--inspector-text-label); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; + padding-right: 8px; +} + +.inspector-property-label.draggable { + cursor: ew-resize; +} + +.inspector-property-label.draggable:hover { + color: var(--inspector-text-primary); +} + +/* ==================== 属性控件容器 | Property Control Container ==================== */ +.inspector-property-control { + flex: 1; + min-width: 0; + display: flex; + align-items: center; +} + +/* ==================== 通用输入框 | Common Input ==================== */ +.inspector-input { + width: 100%; + height: var(--inspector-input-height); + padding: 0 6px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-primary); + font-family: var(--inspector-font-mono); + font-size: var(--inspector-font-size); + outline: none; + transition: border-color var(--inspector-transition-fast); +} + +.inspector-input:hover { + border-color: var(--inspector-border-light); +} + +.inspector-input:focus { + border-color: var(--inspector-border-focus); +} + +.inspector-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 隐藏数字输入的上下箭头 | Hide number input spinners */ +.inspector-input[type="number"]::-webkit-inner-spin-button, +.inspector-input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.inspector-input[type="number"] { + -moz-appearance: textfield; +} + +/* ==================== 向量输入 | Vector Input ==================== */ +.inspector-vector-input { + display: flex; + gap: 2px; + width: 100%; +} + +.inspector-vector-axis { + flex: 1; + display: flex; + align-items: center; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + overflow: hidden; + min-width: 0; +} + +.inspector-vector-axis:hover { + border-color: var(--inspector-border-light); +} + +.inspector-vector-axis:focus-within { + border-color: var(--inspector-border-focus); +} + +.inspector-vector-axis-bar { + width: 3px; + height: 100%; + flex-shrink: 0; +} + +.inspector-vector-axis-bar.x { background: var(--inspector-axis-x); } +.inspector-vector-axis-bar.y { background: var(--inspector-axis-y); } +.inspector-vector-axis-bar.z { background: var(--inspector-axis-z); } +.inspector-vector-axis-bar.w { background: var(--inspector-axis-w); } + +.inspector-vector-axis input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--inspector-text-primary); + font-family: var(--inspector-font-mono); + font-size: var(--inspector-font-size); + padding: 0 6px; + height: var(--inspector-input-height); + outline: none; +} + +/* ==================== 复选框 | Checkbox ==================== */ +.inspector-checkbox { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + cursor: pointer; + transition: all var(--inspector-transition-fast); +} + +.inspector-checkbox:hover { + border-color: var(--inspector-border-light); +} + +.inspector-checkbox.checked { + background: var(--inspector-checkbox-checked); + border-color: var(--inspector-checkbox-checked); +} + +.inspector-checkbox-icon { + color: white; + opacity: 0; + transition: opacity var(--inspector-transition-fast); +} + +.inspector-checkbox.checked .inspector-checkbox-icon { + opacity: 1; +} + +/* ==================== 下拉选择 | Dropdown ==================== */ +.inspector-dropdown { + position: relative; + width: 100%; +} + +.inspector-dropdown-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: var(--inspector-input-height); + padding: 0 6px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-primary); + font-size: var(--inspector-font-size); + cursor: pointer; + transition: border-color var(--inspector-transition-fast); +} + +.inspector-dropdown-trigger:hover { + border-color: var(--inspector-border-light); +} + +.inspector-dropdown-trigger.open { + border-color: var(--inspector-border-focus); +} + +.inspector-dropdown-value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-dropdown-arrow { + color: var(--inspector-text-secondary); + flex-shrink: 0; + margin-left: 4px; +} + +.inspector-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 2px; + background: var(--inspector-bg-section); + border: 1px solid var(--inspector-border-light); + border-radius: var(--inspector-radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.inspector-dropdown-item { + padding: 6px 8px; + cursor: pointer; + transition: background var(--inspector-transition-fast); +} + +.inspector-dropdown-item:hover { + background: var(--inspector-bg-hover); +} + +.inspector-dropdown-item.selected { + background: var(--inspector-bg-active); + color: var(--inspector-accent); +} + +/* ==================== Header | 头部 ==================== */ +.inspector-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + min-height: 32px; + padding: 0 8px; + background: var(--inspector-bg-section); + border-bottom: 1px solid var(--inspector-border); + flex-shrink: 0; +} + +.inspector-header-info { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} + +.inspector-lock-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-secondary); + cursor: pointer; + transition: all var(--inspector-transition-fast); +} + +.inspector-lock-btn:hover { + background: var(--inspector-bg-hover); + color: var(--inspector-text-primary); +} + +.inspector-lock-btn.locked { + color: var(--inspector-accent); +} + +.inspector-header-icon { + display: flex; + align-items: center; + color: var(--inspector-text-secondary); +} + +.inspector-header-name { + font-weight: 500; + color: var(--inspector-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-header-add-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--inspector-accent); + border: none; + border-radius: var(--inspector-radius-md); + color: white; + font-size: var(--inspector-font-size); + cursor: pointer; + transition: background var(--inspector-transition-fast); +} + +.inspector-header-add-btn:hover { + background: var(--inspector-accent-hover); +} + +/* ==================== 搜索栏 | Search Bar ==================== */ +.inspector-search { + display: flex; + align-items: center; + height: 28px; + min-height: 28px; + padding: 0 8px; + background: var(--inspector-bg-base); + border-bottom: 1px solid var(--inspector-border); + flex-shrink: 0; +} + +.inspector-search-icon { + color: var(--inspector-text-secondary); + flex-shrink: 0; +} + +.inspector-search-input { + flex: 1; + height: 100%; + padding: 0 8px; + background: transparent; + border: none; + color: var(--inspector-text-primary); + font-size: var(--inspector-font-size); + outline: none; +} + +.inspector-search-input::placeholder { + color: var(--inspector-text-placeholder); +} + +.inspector-search-clear { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: var(--inspector-bg-hover); + border: none; + border-radius: 50%; + color: var(--inspector-text-secondary); + cursor: pointer; + transition: all var(--inspector-transition-fast); +} + +.inspector-search-clear:hover { + background: var(--inspector-bg-active); + color: var(--inspector-text-primary); +} + +/* ==================== 分类标签 | Category Tabs ==================== */ +.inspector-category-tabs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + min-height: 28px; + padding: 4px 8px; + background: var(--inspector-bg-base); + border-bottom: 1px solid var(--inspector-border); + flex-shrink: 0; +} + +.inspector-category-tab { + padding: 4px 10px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--inspector-radius-md); + color: var(--inspector-text-secondary); + font-size: var(--inspector-font-size); + cursor: pointer; + transition: all var(--inspector-transition-fast); + flex-shrink: 0; + white-space: nowrap; +} + +.inspector-category-tab:hover { + color: var(--inspector-text-primary); + background: var(--inspector-bg-hover); +} + +.inspector-category-tab.active { + color: white; + background: var(--inspector-accent); + border-color: var(--inspector-accent); +} + +/* ==================== 颜色选择器 | Color Picker ==================== */ +.inspector-color-input { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.inspector-color-swatch { + width: 24px; + height: 20px; + border: 1px solid var(--inspector-border-light); + border-radius: var(--inspector-radius-sm); + cursor: pointer; + flex-shrink: 0; + padding: 0; + background-clip: padding-box; +} + +.inspector-color-swatch:hover { + border-color: var(--inspector-border-focus); +} + +.inspector-color-swatch:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.inspector-color-native { + position: absolute; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} + +.inspector-color-hex { + flex: 1; + min-width: 0; + height: var(--inspector-input-height); + padding: 0 6px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-primary); + font-family: var(--inspector-font-mono); + font-size: var(--inspector-font-size); + outline: none; + transition: border-color var(--inspector-transition-fast); +} + +.inspector-color-hex:hover { + border-color: var(--inspector-border-light); +} + +.inspector-color-hex:focus { + border-color: var(--inspector-border-focus); +} + +.inspector-color-hex:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.inspector-color-alpha { + width: 50px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(to right, transparent, var(--inspector-text-primary)); + border-radius: 2px; + cursor: pointer; +} + +.inspector-color-alpha::-webkit-slider-thumb { + -webkit-appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: white; + border: 1px solid var(--inspector-border); + cursor: pointer; +} + +/* ==================== 资产引用 | Asset Reference ==================== */ +.inspector-asset-input { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-height: 28px; + padding: 2px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + transition: border-color var(--inspector-transition-fast); + position: relative; +} + +.inspector-asset-input:hover { + border-color: var(--inspector-border-light); +} + +.inspector-asset-input.drag-over { + border-color: var(--inspector-border-focus); + background: rgba(74, 144, 217, 0.1); +} + +/* 缩略图 | Thumbnail */ +.inspector-asset-thumbnail { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + background: var(--inspector-bg-section); + border-radius: var(--inspector-radius-sm); + overflow: hidden; + color: var(--inspector-text-secondary); +} + +.inspector-asset-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 主体区域(名称+下拉箭头)| Main area (name + dropdown arrow) */ +.inspector-asset-main { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + height: 100%; + padding: 0 4px; + cursor: pointer; + border-radius: var(--inspector-radius-sm); +} + +.inspector-asset-main:hover { + background: var(--inspector-bg-hover); +} + +.inspector-asset-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--inspector-text-primary); + font-size: var(--inspector-font-size); +} + +.inspector-asset-placeholder { + color: var(--inspector-text-placeholder); + font-style: italic; +} + +.inspector-asset-arrow { + flex-shrink: 0; + margin-left: 4px; + color: var(--inspector-text-secondary); + transition: transform var(--inspector-transition-fast); +} + +.inspector-asset-arrow.open { + transform: rotate(180deg); +} + +/* 操作按钮 | Action buttons */ +.inspector-asset-actions { + display: flex; + align-items: center; + gap: 1px; + flex-shrink: 0; +} + +.inspector-asset-btn { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-secondary); + cursor: pointer; + transition: all var(--inspector-transition-fast); +} + +.inspector-asset-btn:hover { + background: var(--inspector-bg-hover); + color: var(--inspector-text-primary); +} + +.inspector-asset-clear:hover { + color: #e57373; +} + +/* 下拉菜单 | Dropdown menu */ +.inspector-asset-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 2px; + background: var(--inspector-bg-section); + border: 1px solid var(--inspector-border-light); + border-radius: var(--inspector-radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.inspector-asset-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + color: var(--inspector-text-primary); + font-size: var(--inspector-font-size); + cursor: pointer; + transition: background var(--inspector-transition-fast); +} + +.inspector-asset-dropdown-item:hover { + background: var(--inspector-bg-hover); +} + +.inspector-asset-dropdown-item span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-asset-dropdown-thumb { + width: 20px; + height: 20px; + object-fit: cover; + border-radius: var(--inspector-radius-sm); +} + +.inspector-asset-dropdown-divider { + height: 1px; + margin: 4px 0; + background: var(--inspector-border); +} + +.inspector-asset-dropdown-label { + padding: 4px 10px; + color: var(--inspector-text-secondary); + font-size: var(--inspector-font-size-small); + text-transform: uppercase; +} + +.inspector-asset-dropdown-empty { + padding: 12px 10px; + color: var(--inspector-text-secondary); + font-size: var(--inspector-font-size); + text-align: center; + font-style: italic; +} + +/* ==================== 实体引用 | Entity Reference ==================== */ +.inspector-entity-input { + display: flex; + align-items: center; + width: 100%; + height: var(--inspector-input-height); + padding: 0 4px 0 6px; + background: var(--inspector-bg-input); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + transition: border-color var(--inspector-transition-fast); + position: relative; +} + +.inspector-entity-input:hover { + border-color: var(--inspector-border-light); +} + +.inspector-entity-input.drag-over { + border-color: var(--inspector-border-focus); + background: rgba(74, 144, 217, 0.1); +} + +.inspector-entity-icon { + flex-shrink: 0; + color: var(--inspector-text-secondary); + margin-right: 6px; +} + +.inspector-entity-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--inspector-text-primary); + font-size: var(--inspector-font-size); + cursor: pointer; +} + +.inspector-entity-value:hover { + color: var(--inspector-accent); +} + +.inspector-entity-placeholder { + color: var(--inspector-text-placeholder); + font-style: italic; +} + +.inspector-entity-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + margin-left: 4px; +} + +.inspector-entity-btn { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-secondary); + cursor: pointer; + transition: all var(--inspector-transition-fast); +} + +.inspector-entity-btn:hover { + background: var(--inspector-bg-hover); + color: var(--inspector-text-primary); +} + +.inspector-entity-clear:hover { + color: #e57373; +} + +.inspector-entity-drop-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(74, 144, 217, 0.2); + border-radius: var(--inspector-radius-sm); + color: var(--inspector-accent); + font-size: 10px; + font-weight: 500; + pointer-events: none; +} + +/* ==================== 数组编辑 | Array Editor ==================== */ +.inspector-array-input { + width: 100%; +} + +.inspector-array-header { + display: flex; + align-items: center; + height: var(--inspector-row-height); + padding: 0 4px; + background: var(--inspector-bg-section); + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + cursor: pointer; + user-select: none; +} + +.inspector-array-header:hover { + background: var(--inspector-bg-hover); +} + +.inspector-array-arrow { + display: flex; + align-items: center; + color: var(--inspector-text-secondary); + margin-right: 4px; +} + +.inspector-array-title { + flex: 1; + font-size: var(--inspector-font-size); + color: var(--inspector-text-secondary); +} + +.inspector-array-add { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: var(--inspector-accent); + border: none; + border-radius: var(--inspector-radius-sm); + color: white; + cursor: pointer; + transition: background var(--inspector-transition-fast); +} + +.inspector-array-add:hover { + background: var(--inspector-accent-hover); +} + +.inspector-array-elements { + margin-top: 2px; + border: 1px solid var(--inspector-border); + border-radius: var(--inspector-radius-sm); + overflow: hidden; +} + +.inspector-array-element { + display: flex; + align-items: center; + min-height: var(--inspector-row-height); + padding: 2px 4px; + background: var(--inspector-bg-input); + border-bottom: 1px solid var(--inspector-border); + gap: 4px; +} + +.inspector-array-element:last-child { + border-bottom: none; +} + +.inspector-array-element.drag-over { + background: rgba(74, 144, 217, 0.1); + border-top: 2px solid var(--inspector-accent); +} + +.inspector-array-element.dragging { + opacity: 0.5; +} + +.inspector-array-handle { + display: flex; + align-items: center; + color: var(--inspector-text-secondary); + cursor: grab; + padding: 0 2px; +} + +.inspector-array-handle:active { + cursor: grabbing; +} + +.inspector-array-index { + flex-shrink: 0; + width: 16px; + font-size: 10px; + color: var(--inspector-text-secondary); + text-align: center; +} + +.inspector-array-content { + flex: 1; + min-width: 0; +} + +.inspector-array-remove { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--inspector-radius-sm); + color: var(--inspector-text-secondary); + cursor: pointer; + transition: all var(--inspector-transition-fast); + flex-shrink: 0; +} + +.inspector-array-remove:hover { + background: rgba(229, 115, 115, 0.2); + color: #e57373; +} + +.inspector-array-empty { + padding: 12px; + text-align: center; + color: var(--inspector-text-secondary); + font-size: var(--inspector-font-size); + font-style: italic; +} + +.inspector-array-element-default { + font-size: var(--inspector-font-size); + color: var(--inspector-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ==================== 空状态 | Empty State ==================== */ +.inspector-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + color: var(--inspector-text-secondary); + font-size: var(--inspector-font-size); + text-align: center; +} + +/* ==================== 动画 | Animations ==================== */ +@media (prefers-reduced-motion: reduce) { + .inspector-section-arrow, + .inspector-input, + .inspector-checkbox, + .inspector-checkbox-icon, + .inspector-dropdown-trigger, + .inspector-dropdown-item, + .inspector-header-add-btn, + .inspector-search-clear, + .inspector-category-tab { + transition: none; + } +} diff --git a/packages/editor-app/src/components/inspector/types.ts b/packages/editor-app/src/components/inspector/types.ts new file mode 100644 index 00000000..f738be64 --- /dev/null +++ b/packages/editor-app/src/components/inspector/types.ts @@ -0,0 +1,177 @@ +/** + * Inspector Type Definitions + * Inspector 类型定义 + */ + +import { ReactElement } from 'react'; + +/** + * 属性控件 Props + * Property Control Props + */ +export interface PropertyControlProps { + /** 当前值 | 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 { + /** 控件类型 | 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): 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; +} diff --git a/packages/editor-app/src/components/inspectors/Inspector.tsx b/packages/editor-app/src/components/inspectors/Inspector.tsx index 8cd8db5e..04b9efea 100644 --- a/packages/editor-app/src/components/inspectors/Inspector.tsx +++ b/packages/editor-app/src/components/inspectors/Inspector.tsx @@ -15,9 +15,9 @@ import { ExtensionInspector, AssetFileInspector, RemoteEntityInspector, - EntityInspector, PrefabInspector } from './views'; +import { EntityInspectorPanel } from '../inspector'; export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) { // ===== 从 InspectorStore 获取状态 | Get state from InspectorStore ===== @@ -101,7 +101,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi if (target.type === 'entity') { return ( -