refactor(editor-app): 改进架构和类型安全 (#226)
* refactor(editor-app): 改进架构和类型安全 * refactor(editor-app): 开始拆分 Inspector.tsx - 创建基础架构 * refactor(editor-app): 完成 Inspector.tsx 拆分 * refactor(editor-app): 优化 Inspector 类型定义,消除所有 any 使用 * refactor(editor): 实现可扩展的属性渲染器系统 * Potential fix for code scanning alert no. 231: Unused variable, import, function or class Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(ci): 防止 Codecov 服务故障阻塞 CI 流程 --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -51,6 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
|
continue-on-error: true # 即使失败也继续
|
||||||
with:
|
with:
|
||||||
file: ./coverage/lcov.info
|
file: ./coverage/lcov.info
|
||||||
flags: unittests
|
flags: unittests
|
||||||
|
|||||||
3
.github/workflows/codecov.yml
vendored
3
.github/workflows/codecov.yml
vendored
@@ -30,12 +30,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
|
continue-on-error: true # 即使失败也继续
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
files: ./packages/core/coverage/coverage-final.json
|
files: ./packages/core/coverage/coverage-final.json
|
||||||
flags: core
|
flags: core
|
||||||
name: core-coverage
|
name: core-coverage
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: false # 不因为 Codecov 失败而失败 CI
|
||||||
verbose: true
|
verbose: true
|
||||||
|
|
||||||
- name: Upload coverage artifact
|
- name: Upload coverage artifact
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
SceneManagerService,
|
SceneManagerService,
|
||||||
FileActionRegistry,
|
FileActionRegistry,
|
||||||
EditorPluginManager,
|
EditorPluginManager,
|
||||||
InspectorRegistry
|
InspectorRegistry,
|
||||||
|
PropertyRendererRegistry
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||||
import { DIContainer } from '../../core/di/DIContainer';
|
import { DIContainer } from '../../core/di/DIContainer';
|
||||||
@@ -24,6 +25,18 @@ import type { EditorEventMap } from '../../core/events/EditorEventMap';
|
|||||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||||
import { TauriDialogService } from '../../services/TauriDialogService';
|
import { TauriDialogService } from '../../services/TauriDialogService';
|
||||||
import { NotificationService } from '../../services/NotificationService';
|
import { NotificationService } from '../../services/NotificationService';
|
||||||
|
import {
|
||||||
|
StringRenderer,
|
||||||
|
NumberRenderer,
|
||||||
|
BooleanRenderer,
|
||||||
|
NullRenderer,
|
||||||
|
Vector2Renderer,
|
||||||
|
Vector3Renderer,
|
||||||
|
ColorRenderer,
|
||||||
|
ComponentRenderer,
|
||||||
|
ArrayRenderer,
|
||||||
|
FallbackRenderer
|
||||||
|
} from '../../infrastructure/property-renderers';
|
||||||
|
|
||||||
export interface EditorServices {
|
export interface EditorServices {
|
||||||
uiRegistry: UIRegistry;
|
uiRegistry: UIRegistry;
|
||||||
@@ -47,6 +60,7 @@ export interface EditorServices {
|
|||||||
dialog: TauriDialogService;
|
dialog: TauriDialogService;
|
||||||
notification: NotificationService;
|
notification: NotificationService;
|
||||||
inspectorRegistry: InspectorRegistry;
|
inspectorRegistry: InspectorRegistry;
|
||||||
|
propertyRendererRegistry: PropertyRendererRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServiceRegistry {
|
export class ServiceRegistry {
|
||||||
@@ -95,6 +109,20 @@ export class ServiceRegistry {
|
|||||||
|
|
||||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
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 {
|
return {
|
||||||
uiRegistry,
|
uiRegistry,
|
||||||
messageHub,
|
messageHub,
|
||||||
@@ -116,7 +144,8 @@ export class ServiceRegistry {
|
|||||||
fileSystem,
|
fileSystem,
|
||||||
dialog,
|
dialog,
|
||||||
notification,
|
notification,
|
||||||
inspectorRegistry
|
inspectorRegistry,
|
||||||
|
propertyRendererRegistry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
packages/editor-app/src/components/ErrorBoundary.tsx
Normal file
74
packages/editor-app/src/components/ErrorBoundary.tsx
Normal file
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 <DefaultErrorFallback error={this.state.error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultErrorFallback({ error }: { error: Error }): JSX.Element {
|
||||||
|
const message = error instanceof DomainError ? error.getUserMessage() : error.message;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#fee',
|
||||||
|
border: '1px solid #fcc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
margin: '20px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ color: '#c00', marginTop: 0 }}>出错了</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
<details style={{ marginTop: '10px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer' }}>技术详情</summary>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
packages/editor-app/src/components/PluginErrorBoundary.tsx
Normal file
48
packages/editor-app/src/components/PluginErrorBoundary.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#fff3cd',
|
||||||
|
border: '1px solid #ffc107',
|
||||||
|
borderRadius: '4px',
|
||||||
|
margin: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#856404' }}>插件错误</h3>
|
||||||
|
<p style={{ margin: '0 0 8px 0' }}>{pluginError.getUserMessage()}</p>
|
||||||
|
<small style={{ color: '#666' }}>插件ID: {pluginId}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={renderFallback} onError={handleError}>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
packages/editor-app/src/components/inspectors/Inspector.tsx
Normal file
203
packages/editor-app/src/components/inspectors/Inspector.tsx
Normal file
@@ -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<InspectorTarget>(null);
|
||||||
|
const [componentVersion, setComponentVersion] = useState(0);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [decimalPlaces, setDecimalPlaces] = useState(() => {
|
||||||
|
const settings = SettingsService.getInstance();
|
||||||
|
return settings.get<number>('inspector.decimalPlaces', 4);
|
||||||
|
});
|
||||||
|
const targetRef = useRef<InspectorTarget>(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<string, any> });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <EmptyInspector />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'extension') {
|
||||||
|
return <ExtensionInspector data={target.data} inspectorRegistry={inspectorRegistry} projectPath={projectPath} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'asset-file') {
|
||||||
|
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'remote-entity') {
|
||||||
|
const entity = target.data;
|
||||||
|
const details = target.details;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteEntityInspector
|
||||||
|
entity={entity}
|
||||||
|
details={details}
|
||||||
|
autoRefresh={autoRefresh}
|
||||||
|
onAutoRefreshChange={setAutoRefresh}
|
||||||
|
decimalPlaces={decimalPlaces}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'entity') {
|
||||||
|
return <EntityInspector entity={target.data} messageHub={messageHub} componentVersion={componentVersion} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentItemProps {
|
||||||
|
component: ComponentData;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '2px',
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3a3a3a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e0e0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{component.typeName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ padding: '6px 8px' }}>
|
||||||
|
{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 ? <div key={propName}>{rendered}</div> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
图片加载失败
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '4px',
|
||||||
|
minHeight: '200px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #404040 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #404040 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #404040 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #404040 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '16px 16px',
|
||||||
|
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
||||||
|
backgroundColor: '#2a2a2a'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${scale})`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#888'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>缩放: {(scale * 100).toFixed(0)}%</span>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{
|
||||||
|
background: '#3a3a3a',
|
||||||
|
border: 'none',
|
||||||
|
color: '#ccc',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{name}</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: '0.9em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No renderer available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
2
packages/editor-app/src/components/inspectors/index.ts
Normal file
2
packages/editor-app/src/components/inspectors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Inspector } from './Inspector';
|
||||||
|
export type { InspectorProps, InspectorTarget, AssetFileInfo } from './types';
|
||||||
54
packages/editor-app/src/components/inspectors/types.ts
Normal file
54
packages/editor-app/src/components/inspectors/types.ts
Normal file
@@ -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<string, any>;
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityDetails {
|
||||||
|
id: number;
|
||||||
|
components?: ComponentData[];
|
||||||
|
componentTypes?: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
32
packages/editor-app/src/components/inspectors/utils.ts
Normal file
32
packages/editor-app/src/components/inspectors/utils.ts
Normal file
@@ -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<string, unknown>).typeName === 'string' &&
|
||||||
|
'properties' in value &&
|
||||||
|
typeof (value as Record<string, unknown>).properties === 'object'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<IconComponent size={16} style={{ color: iconColor }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">文件信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">类型</label>
|
||||||
|
<span className="property-value-text">
|
||||||
|
{fileInfo.isDirectory
|
||||||
|
? '文件夹'
|
||||||
|
: fileInfo.extension
|
||||||
|
? `.${fileInfo.extension}`
|
||||||
|
: '文件'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
大小
|
||||||
|
</label>
|
||||||
|
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileInfo.modified !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
修改时间
|
||||||
|
</label>
|
||||||
|
<span className="property-value-text">{formatDate(fileInfo.modified)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">路径</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Consolas, Monaco, monospace',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
wordBreak: 'break-all'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileInfo.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isImage && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">图片预览</div>
|
||||||
|
<ImagePreview src={convertFileSrc(fileInfo.path)} alt={fileInfo.name} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">文件预览</div>
|
||||||
|
<div className="file-preview-content">{content}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!content && !isImage && !fileInfo.isDirectory && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
此文件类型不支持预览
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { FileSearch } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyInspectorProps {
|
||||||
|
message?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyInspector({
|
||||||
|
message = '未选择对象',
|
||||||
|
description = '选择实体或节点以查看详细信息'
|
||||||
|
}: EmptyInspectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="empty-inspector">
|
||||||
|
<FileSearch size={48} style={{ color: '#555', marginBottom: '16px' }} />
|
||||||
|
<div style={{ color: '#999', fontSize: '14px' }}>{message}</div>
|
||||||
|
<div style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Set<number>>(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 (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">基本信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Entity ID</label>
|
||||||
|
<span className="property-value-text">{entity.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Enabled</label>
|
||||||
|
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entity.components.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">组件</div>
|
||||||
|
{entity.components.map((component: Component, index: number) => {
|
||||||
|
const isExpanded = expandedComponents.has(index);
|
||||||
|
const componentName = component.constructor?.name || 'Component';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${componentName}-${index}-${componentVersion}`}
|
||||||
|
style={{
|
||||||
|
marginBottom: '2px',
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleComponentExpanded(index)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3a3a3a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e0e0',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{componentName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveComponent(index);
|
||||||
|
}}
|
||||||
|
title="移除组件"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#888',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = '#dc2626')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ padding: '6px 8px' }}>
|
||||||
|
<PropertyInspector
|
||||||
|
component={component}
|
||||||
|
onChange={(propName: string, value: unknown) =>
|
||||||
|
handlePropertyChange(component, propName, value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<EmptyInspector
|
||||||
|
message="未找到合适的检视器"
|
||||||
|
description="此对象类型未注册检视器扩展"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <div key={key}>{rendered}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span className="entity-name">运行时实体 #{entity.id}</span>
|
||||||
|
{entity.destroyed && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: '#dc2626',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已销毁
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
title="刷新"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#888',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = '#4ade80')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={autoRefresh ? 'spin-slow' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAutoRefreshChange(!autoRefresh)}
|
||||||
|
title={autoRefresh ? '关闭自动刷新' : '开启自动刷新'}
|
||||||
|
style={{
|
||||||
|
background: autoRefresh ? '#2d4a3e' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: autoRefresh ? '#4ade80' : '#888',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{autoRefresh ? '自动' : '手动'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">基本信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Entity ID</label>
|
||||||
|
<span className="property-value-text">{entity.id}</span>
|
||||||
|
</div>
|
||||||
|
{entity.name && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Name</label>
|
||||||
|
<span className="property-value-text">{entity.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<Activity size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
color: entity.enabled ? '#4ade80' : '#f87171'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.enabled ? 'true' : 'false'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entity.tag !== undefined && entity.tag !== 0 && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<Tag size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
Tag
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#fbbf24'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(entity.depth !== undefined ||
|
||||||
|
entity.updateOrder !== undefined ||
|
||||||
|
entity.parentId !== undefined ||
|
||||||
|
entity.childCount !== undefined) && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">层级信息</div>
|
||||||
|
{entity.depth !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<Layers size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
深度
|
||||||
|
</label>
|
||||||
|
<span className="property-value-text">{entity.depth}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entity.updateOrder !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<ArrowUpDown size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
更新顺序
|
||||||
|
</label>
|
||||||
|
<span className="property-value-text">{entity.updateOrder}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entity.parentId !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">
|
||||||
|
<GitBranch size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
父实体 ID
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
color: entity.parentId === null ? '#666' : '#90caf9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.parentId === null ? '无' : entity.parentId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entity.childCount !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">子实体数量</label>
|
||||||
|
<span className="property-value-text">{entity.childCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entity.activeInHierarchy !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">层级中激活</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
color: entity.activeInHierarchy ? '#4ade80' : '#f87171'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.activeInHierarchy ? 'true' : 'false'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entity.componentMask !== undefined && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">调试信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Component Mask</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#a78bfa',
|
||||||
|
wordBreak: 'break-all'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.componentMask}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details &&
|
||||||
|
details.components &&
|
||||||
|
Array.isArray(details.components) &&
|
||||||
|
details.components.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">组件 ({details.components.length})</div>
|
||||||
|
{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 ? <div key={index}>{rendered}</div> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details &&
|
||||||
|
Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||||
|
.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">其他信息</div>
|
||||||
|
{Object.entries(details)
|
||||||
|
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||||
|
.map(([key, value]) => renderRemoteProperty(key, value))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { EmptyInspector } from './EmptyInspector';
|
||||||
|
export { ExtensionInspector } from './ExtensionInspector';
|
||||||
|
export { AssetFileInspector } from './AssetFileInspector';
|
||||||
|
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||||
|
export { EntityInspector } from './EntityInspector';
|
||||||
9
packages/editor-app/src/domain/errors/DomainError.ts
Normal file
9
packages/editor-app/src/domain/errors/DomainError.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
54
packages/editor-app/src/domain/errors/FileOperationError.ts
Normal file
54
packages/editor-app/src/domain/errors/FileOperationError.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/editor-app/src/domain/errors/NetworkError.ts
Normal file
44
packages/editor-app/src/domain/errors/NetworkError.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/editor-app/src/domain/errors/PluginError.ts
Normal file
57
packages/editor-app/src/domain/errors/PluginError.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/editor-app/src/domain/errors/ValidationError.ts
Normal file
36
packages/editor-app/src/domain/errors/ValidationError.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/editor-app/src/domain/errors/index.ts
Normal file
5
packages/editor-app/src/domain/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { DomainError } from './DomainError';
|
||||||
|
export { ValidationError } from './ValidationError';
|
||||||
|
export { FileOperationError } from './FileOperationError';
|
||||||
|
export { PluginError } from './PluginError';
|
||||||
|
export { NetworkError } from './NetworkError';
|
||||||
5
packages/editor-app/src/domain/index.ts
Normal file
5
packages/editor-app/src/domain/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './errors';
|
||||||
|
export * from './models';
|
||||||
|
export * from './value-objects';
|
||||||
|
export * from './interfaces';
|
||||||
|
export * from './services';
|
||||||
1
packages/editor-app/src/domain/interfaces/index.ts
Normal file
1
packages/editor-app/src/domain/interfaces/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/editor-app/src/domain/models/index.ts
Normal file
1
packages/editor-app/src/domain/models/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/editor-app/src/domain/services/index.ts
Normal file
1
packages/editor-app/src/domain/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/editor-app/src/domain/value-objects/index.ts
Normal file
1
packages/editor-app/src/domain/value-objects/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
15
packages/editor-app/src/hooks/useDynamicIcon.ts
Normal file
15
packages/editor-app/src/hooks/useDynamicIcon.ts
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
29
packages/editor-app/src/hooks/useProfilerService.ts
Normal file
29
packages/editor-app/src/hooks/useProfilerService.ts
Normal file
@@ -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<ProfilerService | undefined>(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
1
packages/editor-app/src/infrastructure/events/index.ts
Normal file
1
packages/editor-app/src/infrastructure/events/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/editor-app/src/infrastructure/github/index.ts
Normal file
1
packages/editor-app/src/infrastructure/github/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
5
packages/editor-app/src/infrastructure/index.ts
Normal file
5
packages/editor-app/src/infrastructure/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './tauri';
|
||||||
|
export * from './github';
|
||||||
|
export * from './plugins';
|
||||||
|
export * from './serialization';
|
||||||
|
export * from './events';
|
||||||
1
packages/editor-app/src/infrastructure/plugins/index.ts
Normal file
1
packages/editor-app/src/infrastructure/plugins/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
|
||||||
|
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 (
|
||||||
|
<div style={{ marginLeft: depth > 0 ? '12px' : 0 }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3a3a3a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '2px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#e0e0e0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.typeName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ marginLeft: '8px', borderLeft: '1px solid #444', paddingLeft: '8px' }}>
|
||||||
|
{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 <div key={key}>{rendered}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="property-field">
|
||||||
|
<label className="property-label">{key}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#666', fontStyle: 'italic' }}>
|
||||||
|
[No Renderer]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<any> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field" style={{ opacity: 0.6 }}>
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{ color: '#888', fontStyle: 'italic', fontSize: '0.9em' }}
|
||||||
|
title="No renderer registered for this type"
|
||||||
|
>
|
||||||
|
{typeInfo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<any[]> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#666' }}>
|
||||||
|
[]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStringArray = value.every(item => typeof item === 'string');
|
||||||
|
if (isStringArray && value.length <= 5) {
|
||||||
|
return (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
|
||||||
|
{(value as string[]).map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
backgroundColor: '#2d4a3e',
|
||||||
|
color: '#8fbc8f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: depth > 0 ? '12px' : 0 }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '3px 0',
|
||||||
|
fontSize: '11px',
|
||||||
|
borderBottom: '1px solid #333',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||||
|
<span style={{ color: '#9cdcfe', marginLeft: '4px' }}>{context.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Array({value.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" title={value}>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NumberRenderer implements IPropertyRenderer<number> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#b5cea8' }}>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BooleanRenderer implements IPropertyRenderer<boolean> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span
|
||||||
|
className="property-value-text"
|
||||||
|
style={{ color: value ? '#4ade80' : '#f87171' }}
|
||||||
|
>
|
||||||
|
{value ? 'true' : 'false'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NullRenderer implements IPropertyRenderer<null> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#666' }}>
|
||||||
|
null
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vector2> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#9cdcfe', fontFamily: 'monospace' }}>
|
||||||
|
({formatNumber(value.x, decimals)}, {formatNumber(value.y, decimals)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vector3Renderer implements IPropertyRenderer<Vector3> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#9cdcfe', fontFamily: 'monospace' }}>
|
||||||
|
({formatNumber(value.x, decimals)}, {formatNumber(value.y, decimals)}, {formatNumber(value.z, decimals)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ColorRenderer implements IPropertyRenderer<Color> {
|
||||||
|
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 (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">{context.name}</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
backgroundColor: colorHex,
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '2px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="property-value-text" style={{ fontFamily: 'monospace' }}>
|
||||||
|
rgba({r}, {g}, {b}, {value.a.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './PrimitiveRenderers';
|
||||||
|
export * from './VectorRenderers';
|
||||||
|
export * from './ComponentRenderer';
|
||||||
|
export * from './FallbackRenderer';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/editor-app/src/infrastructure/tauri/index.ts
Normal file
1
packages/editor-app/src/infrastructure/tauri/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
36
packages/editor-app/src/shared/layout/FlexLayoutTypes.ts
Normal file
36
packages/editor-app/src/shared/layout/FlexLayoutTypes.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { IJsonModel, IJsonTabNode } from 'flexlayout-react';
|
import type { IJsonModel, IJsonTabNode } from 'flexlayout-react';
|
||||||
import type { FlexDockPanel } from './types';
|
import type { FlexDockPanel } from './types';
|
||||||
|
import type { IJsonLayoutNode, IJsonBorderNode, IJsonTabsetNode } from './FlexLayoutTypes';
|
||||||
|
import { hasChildren, isTabNode, isTabsetNode } from './FlexLayoutTypes';
|
||||||
|
|
||||||
export class LayoutMerger {
|
export class LayoutMerger {
|
||||||
static merge(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
|
static merge(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
|
||||||
@@ -31,21 +33,23 @@ export class LayoutMerger {
|
|||||||
|
|
||||||
private static collectPanelIds(layout: IJsonModel): Set<string> {
|
private static collectPanelIds(layout: IJsonModel): Set<string> {
|
||||||
const panelIds = new Set<string>();
|
const panelIds = new Set<string>();
|
||||||
const collect = (node: any) => {
|
const collect = (node: IJsonLayoutNode) => {
|
||||||
if (node.type === 'tab' && node.id) {
|
if (isTabNode(node) && node.id) {
|
||||||
panelIds.add(node.id);
|
panelIds.add(node.id);
|
||||||
}
|
}
|
||||||
if (node.children) {
|
if (hasChildren(node)) {
|
||||||
node.children.forEach((child: any) => collect(child));
|
node.children.forEach((child) => collect(child as IJsonLayoutNode));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
collect(layout.layout);
|
if (layout.layout) {
|
||||||
|
collect(layout.layout as IJsonLayoutNode);
|
||||||
|
}
|
||||||
|
|
||||||
if (layout.borders) {
|
if (layout.borders) {
|
||||||
layout.borders.forEach((border: any) => {
|
layout.borders.forEach((border: IJsonBorderNode) => {
|
||||||
if (border.children) {
|
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 {
|
private static clearBorders(layout: IJsonModel): void {
|
||||||
if (layout.borders) {
|
if (layout.borders) {
|
||||||
layout.borders = layout.borders.map((border: any) => ({
|
layout.borders = layout.borders.map((border: IJsonBorderNode) => ({
|
||||||
...border,
|
...border,
|
||||||
children: []
|
children: []
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static removePanels(node: any, removedPanelIds: string[]): boolean {
|
private static removePanels(node: IJsonLayoutNode, removedPanelIds: string[]): boolean {
|
||||||
if (!node.children) return false;
|
if (!hasChildren(node)) return false;
|
||||||
|
|
||||||
if (node.type === 'tabset' || node.type === 'row') {
|
|
||||||
const originalLength = node.children.length;
|
const originalLength = node.children.length;
|
||||||
node.children = node.children.filter((child: any) => {
|
node.children = node.children.filter((child) => {
|
||||||
if (child.type === 'tab') {
|
if (isTabNode(child)) {
|
||||||
return !removedPanelIds.includes(child.id);
|
return !removedPanelIds.includes(child.id || '');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
}) as any;
|
||||||
|
|
||||||
if (node.type === 'tabset' && node.children.length < originalLength) {
|
if (isTabsetNode(node) && node.children.length < originalLength) {
|
||||||
if (node.selected >= node.children.length) {
|
if (node.selected !== undefined && node.selected >= node.children.length) {
|
||||||
node.selected = Math.max(0, node.children.length - 1);
|
node.selected = Math.max(0, node.children.length - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node.children.forEach((child: any) => this.removePanels(child, removedPanelIds));
|
node.children.forEach((child) => this.removePanels(child as IJsonLayoutNode, removedPanelIds));
|
||||||
|
|
||||||
return node.children.length < originalLength;
|
return node.children.length < originalLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
private static findNewPanels(node: IJsonLayoutNode, newPanelIds: string[]): IJsonTabNode[] {
|
||||||
}
|
|
||||||
|
|
||||||
private static findNewPanels(node: any, newPanelIds: string[]): IJsonTabNode[] {
|
|
||||||
const newPanelTabs: IJsonTabNode[] = [];
|
const newPanelTabs: IJsonTabNode[] = [];
|
||||||
const find = (n: any) => {
|
const find = (n: IJsonLayoutNode) => {
|
||||||
if (n.type === 'tab' && n.id && newPanelIds.includes(n.id)) {
|
if (isTabNode(n) && n.id && newPanelIds.includes(n.id)) {
|
||||||
newPanelTabs.push(n);
|
newPanelTabs.push(n);
|
||||||
}
|
}
|
||||||
if (n.children) {
|
if (hasChildren(n)) {
|
||||||
n.children.forEach((child: any) => find(child));
|
n.children.forEach((child) => find(child as IJsonLayoutNode));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
find(node);
|
find(node);
|
||||||
return newPanelTabs;
|
return newPanelTabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static addNewPanelsToCenter(node: any, newPanelTabs: IJsonTabNode[]): boolean {
|
private static addNewPanelsToCenter(node: IJsonLayoutNode, newPanelTabs: IJsonTabNode[]): boolean {
|
||||||
if (node.type === 'tabset') {
|
if (isTabsetNode(node)) {
|
||||||
const hasNonSidePanel = node.children?.some((child: any) => {
|
const hasNonSidePanel = node.children?.some((child) => {
|
||||||
const id = child.id || '';
|
const id = child.id || '';
|
||||||
return !id.includes('hierarchy') &&
|
return (
|
||||||
|
!id.includes('hierarchy') &&
|
||||||
!id.includes('asset') &&
|
!id.includes('asset') &&
|
||||||
!id.includes('inspector') &&
|
!id.includes('inspector') &&
|
||||||
!id.includes('console');
|
!id.includes('console')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNonSidePanel && node.children) {
|
if (hasNonSidePanel && node.children) {
|
||||||
@@ -119,9 +121,9 @@ export class LayoutMerger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children) {
|
if (hasChildren(node)) {
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (this.addNewPanelsToCenter(child, newPanelTabs)) {
|
if (this.addNewPanelsToCenter(child as IJsonLayoutNode, newPanelTabs)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
packages/editor-core/src/Services/IPropertyRenderer.ts
Normal file
30
packages/editor-core/src/Services/IPropertyRenderer.ts
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPropertyRenderer<T = any> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, IPropertyRenderer> = 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ export * from './Services/IDialog';
|
|||||||
export * from './Services/INotification';
|
export * from './Services/INotification';
|
||||||
export * from './Services/IInspectorProvider';
|
export * from './Services/IInspectorProvider';
|
||||||
export * from './Services/InspectorRegistry';
|
export * from './Services/InspectorRegistry';
|
||||||
|
export * from './Services/IPropertyRenderer';
|
||||||
|
export * from './Services/PropertyRendererRegistry';
|
||||||
|
|
||||||
export * from './Module/IEventBus';
|
export * from './Module/IEventBus';
|
||||||
export * from './Module/ICommandRegistry';
|
export * from './Module/ICommandRegistry';
|
||||||
|
|||||||
Reference in New Issue
Block a user