Files
esengine/packages/editor-app/src/components/Inspector.tsx
YHH a3f7cc38b1 Feature/render pipeline (#232)
* refactor(engine): 重构2D渲染管线坐标系统

* feat(engine): 完善2D渲染管线和编辑器视口功能

* feat(editor): 实现Viewport变换工具系统

* feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示

* feat(editor): 实现Run on Device移动预览功能

* feat(editor): 添加组件属性控制和依赖关系系统

* feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器

* feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(ci): 迁移项目到pnpm并修复CI构建问题

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 移除 network 相关包

* chore: 移除 network 相关包
2025-11-23 14:49:37 +08:00

1111 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, InspectorRegistry, InspectorContext } from '@esengine/editor-core';
import { PropertyInspector } from './PropertyInspector';
import { FileSearch, ChevronDown, ChevronRight, X, Settings, File as FileIcon, Folder, Clock, HardDrive, Tag, Layers, ArrowUpDown, GitBranch, Activity, AlertTriangle, RefreshCw, Image as ImageIcon } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
import { useToast } from './Toast';
import { SettingsService } from '../services/SettingsService';
import { convertFileSrc } from '@tauri-apps/api/core';
import '../styles/EntityInspector.css';
interface InspectorProps {
entityStore: EntityStoreService;
messageHub: MessageHub;
inspectorRegistry: InspectorRegistry;
projectPath?: string | null;
}
function getProfilerService(): any {
return (window as any).__PROFILER_SERVICE__;
}
function formatNumber(value: number, decimalPlaces: number): string {
if (decimalPlaces < 0) {
return String(value);
}
if (Number.isInteger(value)) {
return String(value);
}
return value.toFixed(decimalPlaces);
}
interface AssetFileInfo {
name: string;
path: string;
extension?: string;
size?: number;
modified?: number;
isDirectory: boolean;
}
type InspectorTarget =
| { type: 'entity'; data: Entity }
| { type: 'remote-entity'; data: any; details?: any }
| { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean }
| { type: 'extension'; data: unknown }
| null;
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) {
const [target, setTarget] = useState<InspectorTarget>(null);
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
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 { showToast } = useToast();
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: any }) => {
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 });
};
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]);
const handleRemoveComponent = (index: number) => {
if (target?.type !== 'entity') return;
const entity = target.data;
const component = entity.components[index];
if (component) {
entity.removeComponent(component);
messageHub.publish('component:removed', { entity, component });
}
};
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 handlePropertyChange = (component: any, propertyName: string, value: any) => {
if (target?.type !== 'entity') return;
const entity = target.data;
messageHub.publish('component:property:changed', {
entity,
component,
propertyName,
value
});
};
const renderRemoteProperty = (key: string, value: any) => {
if (value === null || value === undefined) {
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<span className="property-value-text">null</span>
</div>
);
}
if (Array.isArray(value)) {
const getItemDisplay = (item: any): string => {
if (typeof item !== 'object' || item === null) {
return String(item);
}
if (item.typeName) return String(item.typeName);
const constructorName = item.constructor?.name;
if (constructorName && constructorName !== 'Object') {
return constructorName;
}
if (item.type) return String(item.type);
if (item.name) return String(item.name);
if (item.className) return String(item.className);
if (item._type) return String(item._type);
const keys = Object.keys(item);
if (keys.length <= 3) {
return `{${keys.join(', ')}}`;
}
return `{${keys.slice(0, 3).join(', ')}...}`;
};
// 检查是否是组件数组有typeName和properties
const isComponentArray = value.length > 0 && value[0]?.typeName && value[0]?.properties;
if (isComponentArray) {
return (
<div key={key}>
{value.map((item, index) => (
<ComponentItem key={index} component={item} decimalPlaces={decimalPlaces} />
))}
</div>
);
}
// 检查是否是字符串数组如componentTypes
const isStringArray = value.length > 0 && value.every((item: any) => typeof item === 'string');
if (isStringArray) {
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
marginTop: '4px'
}}>
{value.map((item: string, index: number) => (
<span
key={index}
style={{
padding: '2px 8px',
backgroundColor: '#2d4a3e',
color: '#8fbc8f',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'monospace'
}}
>
{item}
</span>
))}
</div>
</div>
);
}
return (
<div key={key} className="property-field">
<label className="property-label">{key} ({value.length})</label>
<div className="property-value-object">
{value.length === 0 ? (
<span className="property-value-text" style={{ color: '#666' }}>Empty</span>
) : (
value.map((item, index) => (
<div key={index} className="property-subfield">
<span className="property-sublabel">[{index}]:</span>
<span className="property-value-text">
{getItemDisplay(item)}
</span>
</div>
))
)}
</div>
</div>
);
}
if (typeof value === 'object' && !Array.isArray(value)) {
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<div className="property-value-object">
{Object.entries(value).map(([subKey, subValue]) => (
<div key={subKey} className="property-subfield">
<span className="property-sublabel">{subKey}:</span>
<span className="property-value-text">
{typeof subValue === 'object' && subValue !== null
? Array.isArray(subValue)
? `Array(${subValue.length})`
: subValue.constructor?.name || JSON.stringify(subValue)
: String(subValue)}
</span>
</div>
))}
</div>
</div>
);
}
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<span className="property-value-text">{String(value)}</span>
</div>
);
};
const 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]}`;
};
const 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'
});
};
const renderAssetFile = (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => {
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>
);
};
if (!target) {
return (
<div className="entity-inspector">
<div className="empty-inspector">
<FileSearch size={48} style={{ color: '#555', marginBottom: '16px' }} />
<div style={{ color: '#999', fontSize: '14px' }}></div>
<div style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
</div>
</div>
</div>
);
}
if (target.type === 'extension') {
const context: InspectorContext = {
target: target.data,
projectPath,
readonly: false
};
const extensionContent = inspectorRegistry.render(target.data, context);
if (extensionContent) {
return extensionContent;
}
return (
<div className="entity-inspector">
<div className="empty-inspector">
<FileSearch size={48} style={{ color: '#555', marginBottom: '16px' }} />
<div style={{ color: '#999', fontSize: '14px' }}></div>
<div style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
</div>
</div>
</div>
);
}
if (target.type === 'asset-file') {
return renderAssetFile(target.data, target.content, target.isImage);
}
if (target.type === 'remote-entity') {
const entity = target.data;
const details = (target as any).details;
const handleManualRefresh = () => {
const profilerService = getProfilerService();
if (profilerService && entity?.id !== undefined) {
profilerService.requestEntityDetails(entity.id);
}
};
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={() => setAutoRefresh(!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: any, index: number) => (
<ComponentItem key={index} component={comp} decimalPlaces={decimalPlaces} />
))}
</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>
);
}
if (target.type === 'entity') {
const entity = target.data;
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: any, 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: any) => handlePropertyChange(component, propName, value)}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
return null;
}
interface ComponentItemProps {
component: {
typeName: string;
properties: Record<string, any>;
};
decimalPlaces?: number;
}
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]) => (
<PropertyValueRenderer key={propName} name={propName} value={propValue} depth={0} decimalPlaces={decimalPlaces} />
))}
</div>
)}
</div>
);
}
interface PropertyValueRendererProps {
name: string;
value: any;
depth: number;
decimalPlaces?: number;
}
function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: PropertyValueRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isExpandable = value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0;
const isArray = Array.isArray(value);
const renderSimpleValue = (val: any): string => {
if (val === null || val === undefined) return 'null';
if (typeof val === 'boolean') return val ? 'true' : 'false';
if (typeof val === 'number') return formatNumber(val, decimalPlaces);
if (typeof val === 'string') return val.length > 50 ? val.substring(0, 50) + '...' : val;
if (Array.isArray(val)) return `Array(${val.length})`;
if (typeof val === 'object') {
const keys = Object.keys(val);
if (keys.length === 0) return '{}';
if (keys.length <= 2) {
const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
return `{${preview}}`;
}
return `{${keys.slice(0, 2).join(', ')}...}`;
}
return String(val);
};
if (isExpandable) {
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' }}>{name}</span>
{!isExpanded && (
<span style={{
color: '#666',
fontFamily: 'monospace',
marginLeft: '8px',
fontSize: '10px'
}}>
{renderSimpleValue(value)}
</span>
)}
</div>
{isExpanded && (
<div style={{ marginLeft: '8px', borderLeft: '1px solid #444', paddingLeft: '8px' }}>
{Object.entries(value).map(([key, val]) => (
<PropertyValueRenderer key={key} name={key} value={val} depth={depth + 1} decimalPlaces={decimalPlaces} />
))}
</div>
)}
</div>
);
}
if (isArray && value.length > 0) {
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' }}>{name}</span>
<span style={{
color: '#666',
fontFamily: 'monospace',
marginLeft: '8px',
fontSize: '10px'
}}>
Array({value.length})
</span>
</div>
{isExpanded && (
<div style={{ marginLeft: '8px', borderLeft: '1px solid #444', paddingLeft: '8px' }}>
{value.map((item: any, index: number) => (
<PropertyValueRenderer key={index} name={`[${index}]`} value={item} depth={depth + 1} decimalPlaces={decimalPlaces} />
))}
</div>
)}
</div>
);
}
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '3px 0',
fontSize: '11px',
borderBottom: '1px solid #333',
marginLeft: depth > 0 ? '12px' : 0
}}
>
<span style={{ color: '#9cdcfe' }}>{name}</span>
<span style={{
color: typeof value === 'boolean' ? (value ? '#4ade80' : '#f87171') :
typeof value === 'number' ? '#b5cea8' : '#ce9178',
fontFamily: 'monospace',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{renderSimpleValue(value)}
</span>
</div>
);
}
interface ImagePreviewProps {
src: string;
alt: string;
}
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>
);
}