diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ee5db7..1e8520a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 + continue-on-error: true # 即使失败也继续 with: file: ./coverage/lcov.info flags: unittests diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index f6079dab..abb77579 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -30,12 +30,13 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 + continue-on-error: true # 即使失败也继续 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/core/coverage/coverage-final.json flags: core name: core-coverage - fail_ci_if_error: true + fail_ci_if_error: false # 不因为 Codecov 失败而失败 CI verbose: true - name: Upload coverage artifact diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index facae8ce..a73d37a0 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -13,7 +13,8 @@ import { SceneManagerService, FileActionRegistry, EditorPluginManager, - InspectorRegistry + InspectorRegistry, + PropertyRendererRegistry } from '@esengine/editor-core'; import { TauriFileAPI } from '../../adapters/TauriFileAPI'; import { DIContainer } from '../../core/di/DIContainer'; @@ -24,6 +25,18 @@ import type { EditorEventMap } from '../../core/events/EditorEventMap'; import { TauriFileSystemService } from '../../services/TauriFileSystemService'; import { TauriDialogService } from '../../services/TauriDialogService'; import { NotificationService } from '../../services/NotificationService'; +import { + StringRenderer, + NumberRenderer, + BooleanRenderer, + NullRenderer, + Vector2Renderer, + Vector3Renderer, + ColorRenderer, + ComponentRenderer, + ArrayRenderer, + FallbackRenderer +} from '../../infrastructure/property-renderers'; export interface EditorServices { uiRegistry: UIRegistry; @@ -47,6 +60,7 @@ export interface EditorServices { dialog: TauriDialogService; notification: NotificationService; inspectorRegistry: InspectorRegistry; + propertyRendererRegistry: PropertyRendererRegistry; } export class ServiceRegistry { @@ -95,6 +109,20 @@ export class ServiceRegistry { Core.services.registerInstance(InspectorRegistry, inspectorRegistry); + const propertyRendererRegistry = new PropertyRendererRegistry(); + Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry); + + propertyRendererRegistry.register(new StringRenderer()); + propertyRendererRegistry.register(new NumberRenderer()); + propertyRendererRegistry.register(new BooleanRenderer()); + propertyRendererRegistry.register(new NullRenderer()); + propertyRendererRegistry.register(new Vector2Renderer()); + propertyRendererRegistry.register(new Vector3Renderer()); + propertyRendererRegistry.register(new ColorRenderer()); + propertyRendererRegistry.register(new ComponentRenderer()); + propertyRendererRegistry.register(new ArrayRenderer()); + propertyRendererRegistry.register(new FallbackRenderer()); + return { uiRegistry, messageHub, @@ -116,7 +144,8 @@ export class ServiceRegistry { fileSystem, dialog, notification, - inspectorRegistry + inspectorRegistry, + propertyRendererRegistry }; } diff --git a/packages/editor-app/src/components/ErrorBoundary.tsx b/packages/editor-app/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..ea908b5b --- /dev/null +++ b/packages/editor-app/src/components/ErrorBoundary.tsx @@ -0,0 +1,74 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { DomainError } from '../domain/errors'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error) => ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('ErrorBoundary caught error:', error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback(this.state.error); + } + + return ; + } + + return this.props.children; + } +} + +function DefaultErrorFallback({ error }: { error: Error }): JSX.Element { + const message = error instanceof DomainError ? error.getUserMessage() : error.message; + + return ( +
+

出错了

+

{message}

+
+ 技术详情 +
+                    {error.stack}
+                
+
+
+ ); +} diff --git a/packages/editor-app/src/components/PluginErrorBoundary.tsx b/packages/editor-app/src/components/PluginErrorBoundary.tsx new file mode 100644 index 00000000..83a2ea88 --- /dev/null +++ b/packages/editor-app/src/components/PluginErrorBoundary.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { ErrorBoundary } from './ErrorBoundary'; +import { PluginError } from '../domain/errors'; + +interface PluginErrorBoundaryProps { + pluginId: string; + pluginName: string; + children: React.ReactNode; + onPluginError?: (pluginId: string, error: Error) => void; +} + +export function PluginErrorBoundary({ + pluginId, + pluginName, + children, + onPluginError +}: PluginErrorBoundaryProps): JSX.Element { + const handleError = (error: Error) => { + onPluginError?.(pluginId, error); + }; + + const renderFallback = (error: Error) => { + const pluginError = + error instanceof PluginError ? error : new PluginError(error.message, pluginId, pluginName, 'execute', error); + + return ( +
+

插件错误

+

{pluginError.getUserMessage()}

+ 插件ID: {pluginId} +
+ ); + }; + + return ( + + {children} + + ); +} diff --git a/packages/editor-app/src/components/inspectors/Inspector.tsx b/packages/editor-app/src/components/inspectors/Inspector.tsx new file mode 100644 index 00000000..4ad4ea7c --- /dev/null +++ b/packages/editor-app/src/components/inspectors/Inspector.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect, useRef } from 'react'; +import { Entity } from '@esengine/ecs-framework'; +import { TauriAPI } from '../../api/tauri'; +import { SettingsService } from '../../services/SettingsService'; +import { InspectorProps, InspectorTarget, AssetFileInfo, RemoteEntity } from './types'; +import { getProfilerService } from './utils'; +import { + EmptyInspector, + ExtensionInspector, + AssetFileInspector, + RemoteEntityInspector, + EntityInspector +} from './views'; + +export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) { + const [target, setTarget] = useState(null); + const [componentVersion, setComponentVersion] = useState(0); + const [autoRefresh, setAutoRefresh] = useState(true); + const [decimalPlaces, setDecimalPlaces] = useState(() => { + const settings = SettingsService.getInstance(); + return settings.get('inspector.decimalPlaces', 4); + }); + const targetRef = useRef(null); + + useEffect(() => { + targetRef.current = target; + }, [target]); + + useEffect(() => { + const handleSettingsChanged = (event: Event) => { + const customEvent = event as CustomEvent; + const changedSettings = customEvent.detail; + if ('inspector.decimalPlaces' in changedSettings) { + setDecimalPlaces(changedSettings['inspector.decimalPlaces']); + } + }; + + window.addEventListener('settings:changed', handleSettingsChanged); + return () => { + window.removeEventListener('settings:changed', handleSettingsChanged); + }; + }, []); + + useEffect(() => { + const handleEntitySelection = (data: { entity: Entity | null }) => { + if (data.entity) { + setTarget({ type: 'entity', data: data.entity }); + } else { + setTarget(null); + } + setComponentVersion(0); + }; + + const handleRemoteEntitySelection = (data: { entity: RemoteEntity }) => { + setTarget({ type: 'remote-entity', data: data.entity }); + const profilerService = getProfilerService(); + if (profilerService && data.entity?.id !== undefined) { + profilerService.requestEntityDetails(data.entity.id); + } + }; + + const handleEntityDetails = (event: Event) => { + const customEvent = event as CustomEvent; + const details = customEvent.detail; + const currentTarget = targetRef.current; + if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) { + setTarget({ ...currentTarget, details }); + } + }; + + const handleExtensionSelection = (data: { data: unknown }) => { + setTarget({ type: 'extension', data: data.data as Record }); + }; + + const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => { + const fileInfo = data.fileInfo; + + if (fileInfo.isDirectory) { + setTarget({ type: 'asset-file', data: fileInfo }); + return; + } + + const textExtensions = [ + 'txt', + 'json', + 'md', + 'ts', + 'tsx', + 'js', + 'jsx', + 'css', + 'html', + 'xml', + 'yaml', + 'yml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'log', + 'btree', + 'ecs' + ]; + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif']; + const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase()); + const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase()); + + if (isTextFile) { + try { + const content = await TauriAPI.readFileContent(fileInfo.path); + setTarget({ type: 'asset-file', data: fileInfo, content }); + } catch (error) { + console.error('Failed to read file content:', error); + setTarget({ type: 'asset-file', data: fileInfo }); + } + } else if (isImageFile) { + setTarget({ type: 'asset-file', data: fileInfo, isImage: true }); + } else { + setTarget({ type: 'asset-file', data: fileInfo }); + } + }; + + const handleComponentChange = () => { + setComponentVersion((prev) => prev + 1); + }; + + const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection); + const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection); + const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection); + const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection); + const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange); + const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange); + + window.addEventListener('profiler:entity-details', handleEntityDetails); + + return () => { + unsubEntitySelect(); + unsubRemoteSelect(); + unsubNodeSelect(); + unsubAssetFileSelect(); + unsubComponentAdded(); + unsubComponentRemoved(); + window.removeEventListener('profiler:entity-details', handleEntityDetails); + }; + }, [messageHub]); + + useEffect(() => { + if (!autoRefresh || target?.type !== 'remote-entity') { + return; + } + + const profilerService = getProfilerService(); + if (!profilerService) { + return; + } + + const handleProfilerData = () => { + const currentTarget = targetRef.current; + if (currentTarget?.type === 'remote-entity' && currentTarget.data?.id !== undefined) { + profilerService.requestEntityDetails(currentTarget.data.id); + } + }; + + const unsubscribe = profilerService.subscribe(handleProfilerData); + + return () => { + unsubscribe(); + }; + }, [autoRefresh, target?.type]); + + if (!target) { + return ; + } + + if (target.type === 'extension') { + return ; + } + + if (target.type === 'asset-file') { + return ; + } + + if (target.type === 'remote-entity') { + const entity = target.data; + const details = target.details; + + return ( + + ); + } + + if (target.type === 'entity') { + return ; + } + + return null; +} diff --git a/packages/editor-app/src/components/inspectors/common/ComponentItem.tsx b/packages/editor-app/src/components/inspectors/common/ComponentItem.tsx new file mode 100644 index 00000000..fc4b24e7 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/common/ComponentItem.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { PropertyContext, PropertyRendererRegistry } from '@esengine/editor-core'; +import { Core } from '@esengine/ecs-framework'; + +interface ComponentData { + typeName: string; + properties: Record; +} + +export interface ComponentItemProps { + component: ComponentData; + decimalPlaces?: number; +} + +export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
setIsExpanded(!isExpanded)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '6px 8px', + backgroundColor: '#3a3a3a', + cursor: 'pointer', + userSelect: 'none', + borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' + }} + > + {isExpanded ? : } + + {component.typeName} + +
+ + {isExpanded && ( +
+ {Object.entries(component.properties).map(([propName, propValue]) => { + const registry = Core.services.resolve(PropertyRendererRegistry); + const context: PropertyContext = { + name: propName, + decimalPlaces, + readonly: true, + depth: 1 + }; + const rendered = registry.render(propValue, context); + return rendered ?
{rendered}
: null; + })} +
+ )} +
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/common/ImagePreview.tsx b/packages/editor-app/src/components/inspectors/common/ImagePreview.tsx new file mode 100644 index 00000000..4f7814f4 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/common/ImagePreview.tsx @@ -0,0 +1,134 @@ +import { useState, useRef } from 'react'; + +export interface ImagePreviewProps { + src: string; + alt: string; +} + +export function ImagePreview({ src, alt }: ImagePreviewProps) { + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [imageError, setImageError] = useState(false); + const containerRef = useRef(null); + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10)); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + setIsDragging(true); + setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y + }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleReset = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + if (imageError) { + return ( +
+ 图片加载失败 +
+ ); + } + + return ( +
+
+ {alt} setImageError(true)} + /> +
+
+ 缩放: {(scale * 100).toFixed(0)}% + +
+
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/common/PropertyField.tsx b/packages/editor-app/src/components/inspectors/common/PropertyField.tsx new file mode 100644 index 00000000..515ca547 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/common/PropertyField.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { PropertyContext, PropertyRendererRegistry } from '@esengine/editor-core'; +import { Core } from '@esengine/ecs-framework'; + +interface PropertyFieldProps { + name: string; + value: any; + readonly?: boolean; + decimalPlaces?: number; + path?: string[]; + onChange?: (value: any) => void; +} + +export function PropertyField({ + name, + value, + readonly = false, + decimalPlaces = 4, + path = [], + onChange +}: PropertyFieldProps) { + const registry = Core.services.resolve(PropertyRendererRegistry); + + const context: PropertyContext = { + name, + path, + readonly, + decimalPlaces, + depth: 0, + expandByDefault: false + }; + + const rendered = registry.render(value, context); + + if (rendered) { + return <>{rendered}; + } + + return ( +
+ + + No renderer available + +
+ ); +} \ No newline at end of file diff --git a/packages/editor-app/src/components/inspectors/common/index.ts b/packages/editor-app/src/components/inspectors/common/index.ts new file mode 100644 index 00000000..ac58e047 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/common/index.ts @@ -0,0 +1,5 @@ +export { ComponentItem } from './ComponentItem'; +export { ImagePreview } from './ImagePreview'; +export { PropertyField } from './PropertyField'; +export type { ComponentItemProps } from './ComponentItem'; +export type { ImagePreviewProps } from './ImagePreview'; diff --git a/packages/editor-app/src/components/inspectors/index.ts b/packages/editor-app/src/components/inspectors/index.ts new file mode 100644 index 00000000..a59fa529 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/index.ts @@ -0,0 +1,2 @@ +export { Inspector } from './Inspector'; +export type { InspectorProps, InspectorTarget, AssetFileInfo } from './types'; diff --git a/packages/editor-app/src/components/inspectors/types.ts b/packages/editor-app/src/components/inspectors/types.ts new file mode 100644 index 00000000..fbda20f5 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/types.ts @@ -0,0 +1,54 @@ +import { Entity } from '@esengine/ecs-framework'; +import { EntityStoreService, MessageHub, InspectorRegistry } from '@esengine/editor-core'; + +export interface InspectorProps { + entityStore: EntityStoreService; + messageHub: MessageHub; + inspectorRegistry: InspectorRegistry; + projectPath?: string | null; +} + +export interface AssetFileInfo { + name: string; + path: string; + extension?: string; + size?: number; + modified?: number; + isDirectory: boolean; +} + +type ExtensionData = Record; + +export type InspectorTarget = + | { type: 'entity'; data: Entity } + | { type: 'remote-entity'; data: RemoteEntity; details?: EntityDetails } + | { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean } + | { type: 'extension'; data: ExtensionData } + | null; + +export interface RemoteEntity { + id: number; + destroyed?: boolean; + componentTypes?: string[]; + name?: string; + enabled?: boolean; + tag?: number; + depth?: number; + updateOrder?: number; + parentId?: number | null; + childCount?: number; + activeInHierarchy?: boolean; + componentMask?: string; +} + +export interface ComponentData { + typeName: string; + properties: Record; +} + +export interface EntityDetails { + id: number; + components?: ComponentData[]; + componentTypes?: string[]; + [key: string]: any; +} diff --git a/packages/editor-app/src/components/inspectors/utils.ts b/packages/editor-app/src/components/inspectors/utils.ts new file mode 100644 index 00000000..17b53a0c --- /dev/null +++ b/packages/editor-app/src/components/inspectors/utils.ts @@ -0,0 +1,32 @@ +import { ComponentData } from './types'; + +export function formatNumber(value: number, decimalPlaces: number): string { + if (decimalPlaces < 0) { + return String(value); + } + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(decimalPlaces); +} + +export interface ProfilerService { + requestEntityDetails(entityId: number): void; + subscribe(callback: () => void): () => void; +} + +export function getProfilerService(): ProfilerService | undefined { + return (window as any).__PROFILER_SERVICE__; +} + +export function isComponentData(value: unknown): value is ComponentData { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'typeName' in value && + typeof (value as Record).typeName === 'string' && + 'properties' in value && + typeof (value as Record).properties === 'object' + ); +} diff --git a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx new file mode 100644 index 00000000..1875181f --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx @@ -0,0 +1,126 @@ +import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive } from 'lucide-react'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { AssetFileInfo } from '../types'; +import { ImagePreview } from '../common'; +import '../../../styles/EntityInspector.css'; + +interface AssetFileInspectorProps { + fileInfo: AssetFileInfo; + content?: string; + isImage?: boolean; +} + +function formatFileSize(bytes?: number): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +function formatDate(timestamp?: number): string { + if (!timestamp) return '未知'; + const date = new Date(timestamp * 1000); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) { + const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon; + const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9'; + + return ( +
+
+ + {fileInfo.name} +
+ +
+
+
文件信息
+
+ + + {fileInfo.isDirectory + ? '文件夹' + : fileInfo.extension + ? `.${fileInfo.extension}` + : '文件'} + +
+ {fileInfo.size !== undefined && !fileInfo.isDirectory && ( +
+ + {formatFileSize(fileInfo.size)} +
+ )} + {fileInfo.modified !== undefined && ( +
+ + {formatDate(fileInfo.modified)} +
+ )} +
+ + + {fileInfo.path} + +
+
+ + {isImage && ( +
+
图片预览
+ +
+ )} + + {content && ( +
+
文件预览
+
{content}
+
+ )} + + {!content && !isImage && !fileInfo.isDirectory && ( +
+
+ 此文件类型不支持预览 +
+
+ )} +
+
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/views/EmptyInspector.tsx b/packages/editor-app/src/components/inspectors/views/EmptyInspector.tsx new file mode 100644 index 00000000..284305f5 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/EmptyInspector.tsx @@ -0,0 +1,23 @@ +import { FileSearch } from 'lucide-react'; + +interface EmptyInspectorProps { + message?: string; + description?: string; +} + +export function EmptyInspector({ + message = '未选择对象', + description = '选择实体或节点以查看详细信息' +}: EmptyInspectorProps) { + return ( +
+
+ +
{message}
+
+ {description} +
+
+
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx new file mode 100644 index 00000000..a418b234 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { Settings, ChevronDown, ChevronRight, X } from 'lucide-react'; +import { Entity, Component } from '@esengine/ecs-framework'; +import { MessageHub } from '@esengine/editor-core'; +import { PropertyInspector } from '../../PropertyInspector'; +import '../../../styles/EntityInspector.css'; + +interface EntityInspectorProps { + entity: Entity; + messageHub: MessageHub; + componentVersion: number; +} + +export function EntityInspector({ entity, messageHub, componentVersion }: EntityInspectorProps) { + const [expandedComponents, setExpandedComponents] = useState>(new Set()); + + const toggleComponentExpanded = (index: number) => { + setExpandedComponents((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const handleRemoveComponent = (index: number) => { + const component = entity.components[index]; + if (component) { + entity.removeComponent(component); + messageHub.publish('component:removed', { entity, component }); + } + }; + + const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => { + messageHub.publish('component:property:changed', { + entity, + component, + propertyName, + value + }); + }; + + return ( +
+
+ + {entity.name || `Entity #${entity.id}`} +
+ +
+
+
基本信息
+
+ + {entity.id} +
+
+ + {entity.enabled ? 'true' : 'false'} +
+
+ + {entity.components.length > 0 && ( +
+
组件
+ {entity.components.map((component: Component, index: number) => { + const isExpanded = expandedComponents.has(index); + const componentName = component.constructor?.name || 'Component'; + + return ( +
+
toggleComponentExpanded(index)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '6px 8px', + backgroundColor: '#3a3a3a', + cursor: 'pointer', + userSelect: 'none', + borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none' + }} + > + {isExpanded ? : } + + {componentName} + + +
+ + {isExpanded && ( +
+ + handlePropertyChange(component, propName, value) + } + /> +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/views/ExtensionInspector.tsx b/packages/editor-app/src/components/inspectors/views/ExtensionInspector.tsx new file mode 100644 index 00000000..dd714a6d --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/ExtensionInspector.tsx @@ -0,0 +1,28 @@ +import { InspectorRegistry, InspectorContext } from '@esengine/editor-core'; +import { EmptyInspector } from './EmptyInspector'; + +interface ExtensionInspectorProps { + data: unknown; + inspectorRegistry: InspectorRegistry; + projectPath?: string | null; +} + +export function ExtensionInspector({ data, inspectorRegistry, projectPath }: ExtensionInspectorProps) { + const context: InspectorContext = { + target: data, + projectPath, + readonly: false + }; + + const extensionContent = inspectorRegistry.render(data, context); + if (extensionContent) { + return extensionContent; + } + + return ( + + ); +} diff --git a/packages/editor-app/src/components/inspectors/views/RemoteEntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/RemoteEntityInspector.tsx new file mode 100644 index 00000000..1c9f29a8 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/RemoteEntityInspector.tsx @@ -0,0 +1,275 @@ +import { + Settings, + RefreshCw, + Activity, + Tag, + Layers, + ArrowUpDown, + GitBranch +} from 'lucide-react'; +import { RemoteEntity, EntityDetails } from '../types'; +import { getProfilerService } from '../utils'; +import { PropertyRendererRegistry, PropertyContext } from '@esengine/editor-core'; +import { Core } from '@esengine/ecs-framework'; +import '../../../styles/EntityInspector.css'; + +interface RemoteEntityInspectorProps { + entity: RemoteEntity; + details?: EntityDetails; + autoRefresh: boolean; + onAutoRefreshChange: (value: boolean) => void; + decimalPlaces: number; +} + +export function RemoteEntityInspector({ + entity, + details, + autoRefresh, + onAutoRefreshChange, + decimalPlaces +}: RemoteEntityInspectorProps) { + const handleManualRefresh = () => { + const profilerService = getProfilerService(); + if (profilerService && entity?.id !== undefined) { + profilerService.requestEntityDetails(entity.id); + } + }; + + const renderRemoteProperty = (key: string, value: any) => { + const registry = Core.services.resolve(PropertyRendererRegistry); + const context: PropertyContext = { + name: key, + decimalPlaces, + readonly: true, + depth: 0 + }; + + const rendered = registry.render(value, context); + if (rendered) { + return
{rendered}
; + } + + return null; + }; + + return ( +
+
+ + 运行时实体 #{entity.id} + {entity.destroyed && ( + + 已销毁 + + )} +
+ + +
+
+ +
+
+
基本信息
+
+ + {entity.id} +
+ {entity.name && ( +
+ + {entity.name} +
+ )} +
+ + + {entity.enabled ? 'true' : 'false'} + +
+ {entity.tag !== undefined && entity.tag !== 0 && ( +
+ + + {entity.tag} + +
+ )} +
+ + {(entity.depth !== undefined || + entity.updateOrder !== undefined || + entity.parentId !== undefined || + entity.childCount !== undefined) && ( +
+
层级信息
+ {entity.depth !== undefined && ( +
+ + {entity.depth} +
+ )} + {entity.updateOrder !== undefined && ( +
+ + {entity.updateOrder} +
+ )} + {entity.parentId !== undefined && ( +
+ + + {entity.parentId === null ? '无' : entity.parentId} + +
+ )} + {entity.childCount !== undefined && ( +
+ + {entity.childCount} +
+ )} + {entity.activeInHierarchy !== undefined && ( +
+ + + {entity.activeInHierarchy ? 'true' : 'false'} + +
+ )} +
+ )} + + {entity.componentMask !== undefined && ( +
+
调试信息
+
+ + + {entity.componentMask} + +
+
+ )} + + {details && + details.components && + Array.isArray(details.components) && + details.components.length > 0 && ( +
+
组件 ({details.components.length})
+ {details.components.map((comp, index) => { + const registry = Core.services.resolve(PropertyRendererRegistry); + const context: PropertyContext = { + name: comp.typeName || `Component ${index}`, + decimalPlaces, + readonly: true, + expandByDefault: true, + depth: 0 + }; + const rendered = registry.render(comp, context); + return rendered ?
{rendered}
: null; + })} +
+ )} + + {details && + Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes') + .length > 0 && ( +
+
其他信息
+ {Object.entries(details) + .filter(([key]) => key !== 'components' && key !== 'componentTypes') + .map(([key, value]) => renderRemoteProperty(key, value))} +
+ )} +
+
+ ); +} diff --git a/packages/editor-app/src/components/inspectors/views/index.ts b/packages/editor-app/src/components/inspectors/views/index.ts new file mode 100644 index 00000000..5f09a05f --- /dev/null +++ b/packages/editor-app/src/components/inspectors/views/index.ts @@ -0,0 +1,5 @@ +export { EmptyInspector } from './EmptyInspector'; +export { ExtensionInspector } from './ExtensionInspector'; +export { AssetFileInspector } from './AssetFileInspector'; +export { RemoteEntityInspector } from './RemoteEntityInspector'; +export { EntityInspector } from './EntityInspector'; diff --git a/packages/editor-app/src/domain/errors/DomainError.ts b/packages/editor-app/src/domain/errors/DomainError.ts new file mode 100644 index 00000000..cf95fefe --- /dev/null +++ b/packages/editor-app/src/domain/errors/DomainError.ts @@ -0,0 +1,9 @@ +export abstract class DomainError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } + + abstract getUserMessage(): string; +} diff --git a/packages/editor-app/src/domain/errors/FileOperationError.ts b/packages/editor-app/src/domain/errors/FileOperationError.ts new file mode 100644 index 00000000..8f1bf306 --- /dev/null +++ b/packages/editor-app/src/domain/errors/FileOperationError.ts @@ -0,0 +1,54 @@ +import { DomainError } from './DomainError'; + +export class FileOperationError extends DomainError { + constructor( + message: string, + public readonly filePath?: string, + public readonly operation?: 'read' | 'write' | 'delete' | 'parse' | 'create', + public readonly originalError?: Error + ) { + super(message); + } + + getUserMessage(): string { + const operationMap = { + read: '读取', + write: '写入', + delete: '删除', + parse: '解析', + create: '创建' + }; + + const operationText = this.operation ? operationMap[this.operation] : '操作'; + const fileText = this.filePath ? ` ${this.filePath}` : ''; + + return `文件${operationText}失败${fileText}: ${this.message}`; + } + + static readFailed(filePath: string, error?: Error): FileOperationError { + return new FileOperationError( + error?.message || '无法读取文件', + filePath, + 'read', + error + ); + } + + static writeFailed(filePath: string, error?: Error): FileOperationError { + return new FileOperationError( + error?.message || '无法写入文件', + filePath, + 'write', + error + ); + } + + static parseFailed(filePath: string, error?: Error): FileOperationError { + return new FileOperationError( + error?.message || '文件格式不正确', + filePath, + 'parse', + error + ); + } +} diff --git a/packages/editor-app/src/domain/errors/NetworkError.ts b/packages/editor-app/src/domain/errors/NetworkError.ts new file mode 100644 index 00000000..fe4feefe --- /dev/null +++ b/packages/editor-app/src/domain/errors/NetworkError.ts @@ -0,0 +1,44 @@ +import { DomainError } from './DomainError'; + +export class NetworkError extends DomainError { + constructor( + message: string, + public readonly url?: string, + public readonly statusCode?: number, + public readonly method?: string, + public readonly originalError?: Error + ) { + super(message); + } + + getUserMessage(): string { + if (this.statusCode) { + return `网络请求失败 (${this.statusCode}): ${this.message}`; + } + return `网络请求失败: ${this.message}`; + } + + static requestFailed(url: string, error?: Error): NetworkError { + return new NetworkError(error?.message || '请求失败', url, undefined, undefined, error); + } + + static timeout(url: string): NetworkError { + return new NetworkError('请求超时', url); + } + + static unauthorized(): NetworkError { + return new NetworkError('未授权,请先登录', undefined, 401); + } + + static forbidden(): NetworkError { + return new NetworkError('没有权限访问此资源', undefined, 403); + } + + static notFound(url: string): NetworkError { + return new NetworkError('资源不存在', url, 404); + } + + static serverError(): NetworkError { + return new NetworkError('服务器错误', undefined, 500); + } +} diff --git a/packages/editor-app/src/domain/errors/PluginError.ts b/packages/editor-app/src/domain/errors/PluginError.ts new file mode 100644 index 00000000..33c8c497 --- /dev/null +++ b/packages/editor-app/src/domain/errors/PluginError.ts @@ -0,0 +1,57 @@ +import { DomainError } from './DomainError'; + +export class PluginError extends DomainError { + constructor( + message: string, + public readonly pluginId?: string, + public readonly pluginName?: string, + public readonly operation?: 'load' | 'activate' | 'deactivate' | 'execute', + public readonly originalError?: Error + ) { + super(message); + } + + getUserMessage(): string { + const operationMap = { + load: '加载', + activate: '激活', + deactivate: '停用', + execute: '执行' + }; + + const operationText = this.operation ? operationMap[this.operation] : '操作'; + const pluginText = this.pluginName || this.pluginId || '插件'; + + return `${pluginText}${operationText}失败: ${this.message}`; + } + + static loadFailed(pluginId: string, error?: Error): PluginError { + return new PluginError( + error?.message || '插件加载失败', + pluginId, + undefined, + 'load', + error + ); + } + + static activateFailed(pluginId: string, pluginName: string, error?: Error): PluginError { + return new PluginError( + error?.message || '插件激活失败', + pluginId, + pluginName, + 'activate', + error + ); + } + + static executeFailed(pluginId: string, error?: Error): PluginError { + return new PluginError( + error?.message || '插件执行失败', + pluginId, + undefined, + 'execute', + error + ); + } +} diff --git a/packages/editor-app/src/domain/errors/ValidationError.ts b/packages/editor-app/src/domain/errors/ValidationError.ts new file mode 100644 index 00000000..b20b55b3 --- /dev/null +++ b/packages/editor-app/src/domain/errors/ValidationError.ts @@ -0,0 +1,36 @@ +import { DomainError } from './DomainError'; + +export class ValidationError extends DomainError { + constructor( + message: string, + public readonly field?: string, + public readonly value?: unknown + ) { + super(message); + } + + getUserMessage(): string { + if (this.field) { + return `验证失败: ${this.field} - ${this.message}`; + } + return `验证失败: ${this.message}`; + } + + static requiredField(field: string): ValidationError { + return new ValidationError(`字段 ${field} 是必需的`, field); + } + + static invalidValue(field: string, value: unknown, reason?: string): ValidationError { + const message = reason + ? `字段 ${field} 的值无效: ${reason}` + : `字段 ${field} 的值无效`; + return new ValidationError(message, field, value); + } + + static invalidFormat(field: string, expectedFormat: string): ValidationError { + return new ValidationError( + `字段 ${field} 格式不正确,期望格式: ${expectedFormat}`, + field + ); + } +} diff --git a/packages/editor-app/src/domain/errors/index.ts b/packages/editor-app/src/domain/errors/index.ts new file mode 100644 index 00000000..d24b2c59 --- /dev/null +++ b/packages/editor-app/src/domain/errors/index.ts @@ -0,0 +1,5 @@ +export { DomainError } from './DomainError'; +export { ValidationError } from './ValidationError'; +export { FileOperationError } from './FileOperationError'; +export { PluginError } from './PluginError'; +export { NetworkError } from './NetworkError'; diff --git a/packages/editor-app/src/domain/index.ts b/packages/editor-app/src/domain/index.ts new file mode 100644 index 00000000..df4ecbae --- /dev/null +++ b/packages/editor-app/src/domain/index.ts @@ -0,0 +1,5 @@ +export * from './errors'; +export * from './models'; +export * from './value-objects'; +export * from './interfaces'; +export * from './services'; diff --git a/packages/editor-app/src/domain/interfaces/index.ts b/packages/editor-app/src/domain/interfaces/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/domain/interfaces/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/domain/models/index.ts b/packages/editor-app/src/domain/models/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/domain/models/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/domain/services/index.ts b/packages/editor-app/src/domain/services/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/domain/services/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/domain/value-objects/index.ts b/packages/editor-app/src/domain/value-objects/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/domain/value-objects/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/hooks/useDynamicIcon.ts b/packages/editor-app/src/hooks/useDynamicIcon.ts new file mode 100644 index 00000000..6c86e689 --- /dev/null +++ b/packages/editor-app/src/hooks/useDynamicIcon.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import * as LucideIcons from 'lucide-react'; + +type LucideIconName = keyof typeof LucideIcons; + +export function useDynamicIcon(iconName?: string, fallback?: React.ComponentType) { + return useMemo(() => { + if (!iconName) { + return fallback || LucideIcons.Package; + } + + const IconComponent = (LucideIcons as any)[iconName]; + return IconComponent || fallback || LucideIcons.Package; + }, [iconName, fallback]); +} diff --git a/packages/editor-app/src/hooks/useProfilerService.ts b/packages/editor-app/src/hooks/useProfilerService.ts new file mode 100644 index 00000000..bca466bf --- /dev/null +++ b/packages/editor-app/src/hooks/useProfilerService.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +export interface ProfilerService { + connect(port: number): void; + disconnect(): void; + isConnected(): boolean; + requestEntityList(): void; + requestEntityDetails(entityId: number): void; +} + +export function useProfilerService(): ProfilerService | undefined { + const [service, setService] = useState(() => { + return (window as any).__PROFILER_SERVICE__; + }); + + useEffect(() => { + const checkService = () => { + const newService = (window as any).__PROFILER_SERVICE__; + if (newService !== service) { + setService(newService); + } + }; + + const interval = setInterval(checkService, 1000); + return () => clearInterval(interval); + }, [service]); + + return service; +} diff --git a/packages/editor-app/src/infrastructure/events/index.ts b/packages/editor-app/src/infrastructure/events/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/infrastructure/events/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/infrastructure/github/index.ts b/packages/editor-app/src/infrastructure/github/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/infrastructure/github/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/infrastructure/index.ts b/packages/editor-app/src/infrastructure/index.ts new file mode 100644 index 00000000..e2a904de --- /dev/null +++ b/packages/editor-app/src/infrastructure/index.ts @@ -0,0 +1,5 @@ +export * from './tauri'; +export * from './github'; +export * from './plugins'; +export * from './serialization'; +export * from './events'; diff --git a/packages/editor-app/src/infrastructure/plugins/index.ts b/packages/editor-app/src/infrastructure/plugins/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/infrastructure/plugins/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/infrastructure/property-renderers/ComponentRenderer.tsx b/packages/editor-app/src/infrastructure/property-renderers/ComponentRenderer.tsx new file mode 100644 index 00000000..19a1942b --- /dev/null +++ b/packages/editor-app/src/infrastructure/property-renderers/ComponentRenderer.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { IPropertyRenderer, PropertyContext, PropertyRendererRegistry } from '@esengine/editor-core'; +import { Core } from '@esengine/ecs-framework'; + +interface ComponentData { + typeName: string; + properties: Record; +} + +export class ComponentRenderer implements IPropertyRenderer { + readonly id = 'app.component'; + readonly name = 'Component Renderer'; + readonly priority = 75; + + canHandle(value: any, _context: PropertyContext): value is ComponentData { + return ( + typeof value === 'object' && + value !== null && + typeof value.typeName === 'string' && + typeof value.properties === 'object' && + value.properties !== null + ); + } + + render(value: ComponentData, context: PropertyContext): React.ReactElement { + const [isExpanded, setIsExpanded] = useState(context.expandByDefault ?? false); + const depth = context.depth ?? 0; + + return ( +
0 ? '12px' : 0 }}> +
setIsExpanded(!isExpanded)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '6px 8px', + backgroundColor: '#3a3a3a', + cursor: 'pointer', + userSelect: 'none', + borderRadius: '4px', + marginBottom: '2px' + }} + > + {isExpanded ? : } + + {value.typeName} + +
+ + {isExpanded && ( +
+ {Object.entries(value.properties).map(([key, propValue]) => { + const registry = Core.services.resolve(PropertyRendererRegistry); + const propContext: PropertyContext = { + ...context, + name: key, + depth: depth + 1, + path: [...(context.path || []), key] + }; + + const rendered = registry.render(propValue, propContext); + if (rendered) { + return
{rendered}
; + } + + return ( +
+ + + [No Renderer] + +
+ ); + })} +
+ )} +
+ ); + } +} \ No newline at end of file diff --git a/packages/editor-app/src/infrastructure/property-renderers/FallbackRenderer.tsx b/packages/editor-app/src/infrastructure/property-renderers/FallbackRenderer.tsx new file mode 100644 index 00000000..cf3da33d --- /dev/null +++ b/packages/editor-app/src/infrastructure/property-renderers/FallbackRenderer.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core'; + +export class FallbackRenderer implements IPropertyRenderer { + readonly id = 'app.fallback'; + readonly name = 'Fallback Renderer'; + readonly priority = -1000; + + canHandle(_value: any, _context: PropertyContext): _value is any { + return true; + } + + render(value: any, context: PropertyContext): React.ReactElement { + const typeInfo = this.getTypeInfo(value); + + return ( +
+ + + {typeInfo} + +
+ ); + } + + private getTypeInfo(value: any): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + const type = typeof value; + + if (type === 'object') { + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + + const constructor = value.constructor?.name; + if (constructor && constructor !== 'Object') { + return `[${constructor}]`; + } + + const keys = Object.keys(value); + if (keys.length === 0) return '{}'; + if (keys.length <= 3) { + return `{${keys.join(', ')}}`; + } + return `{${keys.slice(0, 3).join(', ')}...}`; + } + + return `[${type}]`; + } +} + +export class ArrayRenderer implements IPropertyRenderer { + readonly id = 'app.array'; + readonly name = 'Array Renderer'; + readonly priority = 50; + + canHandle(value: any, _context: PropertyContext): value is any[] { + return Array.isArray(value); + } + + render(value: any[], context: PropertyContext): React.ReactElement { + const [isExpanded, setIsExpanded] = React.useState(false); + const depth = context.depth ?? 0; + + if (value.length === 0) { + return ( +
+ + + [] + +
+ ); + } + + const isStringArray = value.every(item => typeof item === 'string'); + if (isStringArray && value.length <= 5) { + return ( +
+ +
+ {(value as string[]).map((item, index) => ( + + {item} + + ))} +
+
+ ); + } + + return ( +
0 ? '12px' : 0 }}> +
setIsExpanded(!isExpanded)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '3px 0', + fontSize: '11px', + borderBottom: '1px solid #333', + cursor: 'pointer', + userSelect: 'none' + }} + > + {isExpanded ? : } + {context.name} + + Array({value.length}) + +
+
+ ); + } +} \ No newline at end of file diff --git a/packages/editor-app/src/infrastructure/property-renderers/PrimitiveRenderers.tsx b/packages/editor-app/src/infrastructure/property-renderers/PrimitiveRenderers.tsx new file mode 100644 index 00000000..827f2f0e --- /dev/null +++ b/packages/editor-app/src/infrastructure/property-renderers/PrimitiveRenderers.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core'; +import { formatNumber } from '../../components/inspectors/utils'; + +export class StringRenderer implements IPropertyRenderer { + readonly id = 'app.string'; + readonly name = 'String Renderer'; + readonly priority = 100; + + canHandle(value: any, _context: PropertyContext): value is string { + return typeof value === 'string'; + } + + render(value: string, context: PropertyContext): React.ReactElement { + const displayValue = value.length > 50 ? `${value.substring(0, 50)}...` : value; + return ( +
+ + + {displayValue} + +
+ ); + } +} + +export class NumberRenderer implements IPropertyRenderer { + readonly id = 'app.number'; + readonly name = 'Number Renderer'; + readonly priority = 100; + + canHandle(value: any, _context: PropertyContext): value is number { + return typeof value === 'number'; + } + + render(value: number, context: PropertyContext): React.ReactElement { + const decimalPlaces = context.decimalPlaces ?? 4; + const displayValue = formatNumber(value, decimalPlaces); + + return ( +
+ + + {displayValue} + +
+ ); + } +} + +export class BooleanRenderer implements IPropertyRenderer { + readonly id = 'app.boolean'; + readonly name = 'Boolean Renderer'; + readonly priority = 100; + + canHandle(value: any, _context: PropertyContext): value is boolean { + return typeof value === 'boolean'; + } + + render(value: boolean, context: PropertyContext): React.ReactElement { + return ( +
+ + + {value ? 'true' : 'false'} + +
+ ); + } +} + +export class NullRenderer implements IPropertyRenderer { + readonly id = 'app.null'; + readonly name = 'Null Renderer'; + readonly priority = 100; + + canHandle(value: any, _context: PropertyContext): value is null { + return value === null || value === undefined; + } + + render(_value: null, context: PropertyContext): React.ReactElement { + return ( +
+ + + null + +
+ ); + } +} \ No newline at end of file diff --git a/packages/editor-app/src/infrastructure/property-renderers/VectorRenderers.tsx b/packages/editor-app/src/infrastructure/property-renderers/VectorRenderers.tsx new file mode 100644 index 00000000..1241d569 --- /dev/null +++ b/packages/editor-app/src/infrastructure/property-renderers/VectorRenderers.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core'; +import { formatNumber } from '../../components/inspectors/utils'; + +interface Vector2 { + x: number; + y: number; +} + +interface Vector3 extends Vector2 { + z: number; +} + +interface Vector4 extends Vector3 { + w: number; +} + +interface Color { + r: number; + g: number; + b: number; + a: number; +} + +export class Vector2Renderer implements IPropertyRenderer { + readonly id = 'app.vector2'; + readonly name = 'Vector2 Renderer'; + readonly priority = 80; + + canHandle(value: any, _context: PropertyContext): value is Vector2 { + return ( + typeof value === 'object' && + value !== null && + typeof value.x === 'number' && + typeof value.y === 'number' && + !('z' in value) && + Object.keys(value).length === 2 + ); + } + + render(value: Vector2, context: PropertyContext): React.ReactElement { + const decimals = context.decimalPlaces ?? 2; + return ( +
+ + + ({formatNumber(value.x, decimals)}, {formatNumber(value.y, decimals)}) + +
+ ); + } +} + +export class Vector3Renderer implements IPropertyRenderer { + readonly id = 'app.vector3'; + readonly name = 'Vector3 Renderer'; + readonly priority = 80; + + canHandle(value: any, _context: PropertyContext): value is Vector3 { + return ( + typeof value === 'object' && + value !== null && + typeof value.x === 'number' && + typeof value.y === 'number' && + typeof value.z === 'number' && + !('w' in value) && + Object.keys(value).length === 3 + ); + } + + render(value: Vector3, context: PropertyContext): React.ReactElement { + const decimals = context.decimalPlaces ?? 2; + return ( +
+ + + ({formatNumber(value.x, decimals)}, {formatNumber(value.y, decimals)}, {formatNumber(value.z, decimals)}) + +
+ ); + } +} + +export class ColorRenderer implements IPropertyRenderer { + readonly id = 'app.color'; + readonly name = 'Color Renderer'; + readonly priority = 85; + + canHandle(value: any, _context: PropertyContext): value is Color { + return ( + typeof value === 'object' && + value !== null && + typeof value.r === 'number' && + typeof value.g === 'number' && + typeof value.b === 'number' && + typeof value.a === 'number' && + Object.keys(value).length === 4 + ); + } + + render(value: Color, context: PropertyContext): React.ReactElement { + const r = Math.round(value.r * 255); + const g = Math.round(value.g * 255); + const b = Math.round(value.b * 255); + const colorHex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + + return ( +
+ +
+
+ + rgba({r}, {g}, {b}, {value.a.toFixed(2)}) + +
+
+ ); + } +} \ No newline at end of file diff --git a/packages/editor-app/src/infrastructure/property-renderers/index.ts b/packages/editor-app/src/infrastructure/property-renderers/index.ts new file mode 100644 index 00000000..b48749dd --- /dev/null +++ b/packages/editor-app/src/infrastructure/property-renderers/index.ts @@ -0,0 +1,4 @@ +export * from './PrimitiveRenderers'; +export * from './VectorRenderers'; +export * from './ComponentRenderer'; +export * from './FallbackRenderer'; \ No newline at end of file diff --git a/packages/editor-app/src/infrastructure/serialization/index.ts b/packages/editor-app/src/infrastructure/serialization/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/infrastructure/serialization/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/infrastructure/tauri/index.ts b/packages/editor-app/src/infrastructure/tauri/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/editor-app/src/infrastructure/tauri/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/editor-app/src/shared/layout/FlexLayoutTypes.ts b/packages/editor-app/src/shared/layout/FlexLayoutTypes.ts new file mode 100644 index 00000000..d45bb673 --- /dev/null +++ b/packages/editor-app/src/shared/layout/FlexLayoutTypes.ts @@ -0,0 +1,36 @@ +import type { IJsonModel, IJsonTabNode, IJsonBorderNode as FlexBorderNode } from 'flexlayout-react'; + +export interface IJsonRowNode { + type: 'row'; + id?: string; + weight?: number; + children: IJsonLayoutNode[]; +} + +export interface IJsonTabsetNode { + type: 'tabset'; + id?: string; + weight?: number; + selected?: number; + children: IJsonTabNode[]; +} + +export type IJsonLayoutNode = IJsonRowNode | IJsonTabsetNode | IJsonTabNode; + +export type { FlexBorderNode as IJsonBorderNode }; + +export function isTabsetNode(node: IJsonLayoutNode): node is IJsonTabsetNode { + return node.type === 'tabset'; +} + +export function isRowNode(node: IJsonLayoutNode): node is IJsonRowNode { + return node.type === 'row'; +} + +export function isTabNode(node: IJsonLayoutNode): node is IJsonTabNode { + return node.type === 'tab'; +} + +export function hasChildren(node: IJsonLayoutNode): node is IJsonRowNode | IJsonTabsetNode { + return node.type === 'row' || node.type === 'tabset'; +} diff --git a/packages/editor-app/src/shared/layout/LayoutMerger.ts b/packages/editor-app/src/shared/layout/LayoutMerger.ts index 3900818c..49335045 100644 --- a/packages/editor-app/src/shared/layout/LayoutMerger.ts +++ b/packages/editor-app/src/shared/layout/LayoutMerger.ts @@ -1,5 +1,7 @@ import type { IJsonModel, IJsonTabNode } from 'flexlayout-react'; import type { FlexDockPanel } from './types'; +import type { IJsonLayoutNode, IJsonBorderNode, IJsonTabsetNode } from './FlexLayoutTypes'; +import { hasChildren, isTabNode, isTabsetNode } from './FlexLayoutTypes'; export class LayoutMerger { static merge(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel { @@ -31,21 +33,23 @@ export class LayoutMerger { private static collectPanelIds(layout: IJsonModel): Set { const panelIds = new Set(); - const collect = (node: any) => { - if (node.type === 'tab' && node.id) { + const collect = (node: IJsonLayoutNode) => { + if (isTabNode(node) && node.id) { panelIds.add(node.id); } - if (node.children) { - node.children.forEach((child: any) => collect(child)); + if (hasChildren(node)) { + node.children.forEach((child) => collect(child as IJsonLayoutNode)); } }; - collect(layout.layout); + if (layout.layout) { + collect(layout.layout as IJsonLayoutNode); + } if (layout.borders) { - layout.borders.forEach((border: any) => { + layout.borders.forEach((border: IJsonBorderNode) => { if (border.children) { - collect({ children: border.children }); + border.children.forEach((child) => collect(child as IJsonLayoutNode)); } }); } @@ -55,61 +59,59 @@ export class LayoutMerger { private static clearBorders(layout: IJsonModel): void { if (layout.borders) { - layout.borders = layout.borders.map((border: any) => ({ + layout.borders = layout.borders.map((border: IJsonBorderNode) => ({ ...border, children: [] })); } } - private static removePanels(node: any, removedPanelIds: string[]): boolean { - if (!node.children) return false; + private static removePanels(node: IJsonLayoutNode, removedPanelIds: string[]): boolean { + if (!hasChildren(node)) return false; - if (node.type === 'tabset' || node.type === 'row') { - const originalLength = node.children.length; - node.children = node.children.filter((child: any) => { - if (child.type === 'tab') { - return !removedPanelIds.includes(child.id); - } - return true; - }); - - if (node.type === 'tabset' && node.children.length < originalLength) { - if (node.selected >= node.children.length) { - node.selected = Math.max(0, node.children.length - 1); - } + const originalLength = node.children.length; + node.children = node.children.filter((child) => { + if (isTabNode(child)) { + return !removedPanelIds.includes(child.id || ''); } + return true; + }) as any; - node.children.forEach((child: any) => this.removePanels(child, removedPanelIds)); - - return node.children.length < originalLength; + if (isTabsetNode(node) && node.children.length < originalLength) { + if (node.selected !== undefined && node.selected >= node.children.length) { + node.selected = Math.max(0, node.children.length - 1); + } } - return false; + node.children.forEach((child) => this.removePanels(child as IJsonLayoutNode, removedPanelIds)); + + return node.children.length < originalLength; } - private static findNewPanels(node: any, newPanelIds: string[]): IJsonTabNode[] { + private static findNewPanels(node: IJsonLayoutNode, newPanelIds: string[]): IJsonTabNode[] { const newPanelTabs: IJsonTabNode[] = []; - const find = (n: any) => { - if (n.type === 'tab' && n.id && newPanelIds.includes(n.id)) { + const find = (n: IJsonLayoutNode) => { + if (isTabNode(n) && n.id && newPanelIds.includes(n.id)) { newPanelTabs.push(n); } - if (n.children) { - n.children.forEach((child: any) => find(child)); + if (hasChildren(n)) { + n.children.forEach((child) => find(child as IJsonLayoutNode)); } }; find(node); return newPanelTabs; } - private static addNewPanelsToCenter(node: any, newPanelTabs: IJsonTabNode[]): boolean { - if (node.type === 'tabset') { - const hasNonSidePanel = node.children?.some((child: any) => { + private static addNewPanelsToCenter(node: IJsonLayoutNode, newPanelTabs: IJsonTabNode[]): boolean { + if (isTabsetNode(node)) { + const hasNonSidePanel = node.children?.some((child) => { const id = child.id || ''; - return !id.includes('hierarchy') && - !id.includes('asset') && - !id.includes('inspector') && - !id.includes('console'); + return ( + !id.includes('hierarchy') && + !id.includes('asset') && + !id.includes('inspector') && + !id.includes('console') + ); }); if (hasNonSidePanel && node.children) { @@ -119,9 +121,9 @@ export class LayoutMerger { } } - if (node.children) { + if (hasChildren(node)) { for (const child of node.children) { - if (this.addNewPanelsToCenter(child, newPanelTabs)) { + if (this.addNewPanelsToCenter(child as IJsonLayoutNode, newPanelTabs)) { return true; } } diff --git a/packages/editor-core/src/Services/IPropertyRenderer.ts b/packages/editor-core/src/Services/IPropertyRenderer.ts new file mode 100644 index 00000000..b5489ded --- /dev/null +++ b/packages/editor-core/src/Services/IPropertyRenderer.ts @@ -0,0 +1,30 @@ +import { ReactElement } from 'react'; + +export interface PropertyContext { + readonly name: string; + readonly path?: string[]; + readonly depth?: number; + readonly readonly?: boolean; + readonly decimalPlaces?: number; + readonly expandByDefault?: boolean; + readonly parentObject?: any; + readonly metadata?: Record; +} + +export interface IPropertyRenderer { + readonly id: string; + readonly name: string; + readonly priority?: number; + + canHandle(value: any, context: PropertyContext): value is T; + render(value: T, context: PropertyContext): ReactElement; +} + +export interface IPropertyRendererRegistry { + register(renderer: IPropertyRenderer): void; + unregister(rendererId: string): void; + findRenderer(value: any, context: PropertyContext): IPropertyRenderer | undefined; + render(value: any, context: PropertyContext): ReactElement | null; + getAllRenderers(): IPropertyRenderer[]; + hasRenderer(value: any, context: PropertyContext): boolean; +} \ No newline at end of file diff --git a/packages/editor-core/src/Services/PropertyRendererRegistry.ts b/packages/editor-core/src/Services/PropertyRendererRegistry.ts new file mode 100644 index 00000000..91e73835 --- /dev/null +++ b/packages/editor-core/src/Services/PropertyRendererRegistry.ts @@ -0,0 +1,74 @@ +import React from 'react'; +import { IService, createLogger } from '@esengine/ecs-framework'; +import { IPropertyRenderer, IPropertyRendererRegistry, PropertyContext } from './IPropertyRenderer'; + +const logger = createLogger('PropertyRendererRegistry'); + +export class PropertyRendererRegistry implements IPropertyRendererRegistry, IService { + private renderers: Map = new Map(); + + register(renderer: IPropertyRenderer): void { + if (this.renderers.has(renderer.id)) { + logger.warn(`Overwriting existing property renderer: ${renderer.id}`); + } + + this.renderers.set(renderer.id, renderer); + logger.debug(`Registered property renderer: ${renderer.name} (${renderer.id})`); + } + + unregister(rendererId: string): void { + if (this.renderers.delete(rendererId)) { + logger.debug(`Unregistered property renderer: ${rendererId}`); + } + } + + findRenderer(value: any, context: PropertyContext): IPropertyRenderer | undefined { + const renderers = Array.from(this.renderers.values()) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + for (const renderer of renderers) { + try { + if (renderer.canHandle(value, context)) { + return renderer; + } + } catch (error) { + logger.error(`Error in canHandle for renderer ${renderer.id}:`, error); + } + } + + return undefined; + } + + render(value: any, context: PropertyContext): React.ReactElement | null { + const renderer = this.findRenderer(value, context); + + if (!renderer) { + logger.debug(`No renderer found for value type: ${typeof value}`); + return null; + } + + try { + return renderer.render(value, context); + } catch (error) { + logger.error(`Error rendering with ${renderer.id}:`, error); + return React.createElement( + 'span', + { style: { color: '#f87171', fontStyle: 'italic' } }, + '[Render Error]' + ); + } + } + + getAllRenderers(): IPropertyRenderer[] { + return Array.from(this.renderers.values()); + } + + hasRenderer(value: any, context: PropertyContext): boolean { + return this.findRenderer(value, context) !== undefined; + } + + dispose(): void { + this.renderers.clear(); + logger.debug('PropertyRendererRegistry disposed'); + } +} \ No newline at end of file diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index ff9cb3f7..8129e5e1 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -31,6 +31,8 @@ export * from './Services/IDialog'; export * from './Services/INotification'; export * from './Services/IInspectorProvider'; export * from './Services/InspectorRegistry'; +export * from './Services/IPropertyRenderer'; +export * from './Services/PropertyRendererRegistry'; export * from './Module/IEventBus'; export * from './Module/ICommandRegistry';