refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
|
||||
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
import { EngineService } from '../../services/EngineService';
|
||||
|
||||
interface DraggableNumberProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function DraggableNumber({ value, min = 0, max = 10, step = 0.1, onChange, disabled, label }: DraggableNumberProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.1 : 1;
|
||||
let newValue = dragStartValue + delta * step * sensitivity;
|
||||
|
||||
newValue = Math.max(min, Math.min(max, newValue));
|
||||
newValue = parseFloat(newValue.toFixed(2));
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<label className="clip-draggable-number">
|
||||
<span
|
||||
className="clip-draggable-label"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: disabled ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export class AnimationClipsFieldEditor implements IFieldEditor<AnimationClip[]> {
|
||||
readonly type = 'animationClips';
|
||||
readonly name = 'Animation Clips Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'animationClips';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<AnimationClip[]>): React.ReactElement {
|
||||
return (
|
||||
<AnimationClipsEditor
|
||||
label={label}
|
||||
clips={value || []}
|
||||
onChange={onChange}
|
||||
readonly={context.readonly}
|
||||
component={context.metadata?.component as SpriteAnimatorComponent}
|
||||
onDefaultAnimationChange={context.metadata?.onDefaultAnimationChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AnimationClipsEditorProps {
|
||||
label: string;
|
||||
clips: AnimationClip[];
|
||||
onChange: (clips: AnimationClip[]) => void;
|
||||
readonly?: boolean;
|
||||
component?: SpriteAnimatorComponent;
|
||||
onDefaultAnimationChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function AnimationClipsEditor({ label, clips, onChange, readonly, component, onDefaultAnimationChange }: AnimationClipsEditorProps) {
|
||||
const [expandedClips, setExpandedClips] = useState<Set<number>>(new Set());
|
||||
const [playingClip, setPlayingClip] = useState<string | null>(null);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleClip = (index: number) => {
|
||||
const newExpanded = new Set(expandedClips);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedClips(newExpanded);
|
||||
};
|
||||
|
||||
const addClip = () => {
|
||||
const newName = `Animation ${clips.length + 1}`;
|
||||
const newClip: AnimationClip = {
|
||||
name: newName,
|
||||
frames: [],
|
||||
loop: true,
|
||||
speed: 1
|
||||
};
|
||||
onChange([...clips, newClip]);
|
||||
setExpandedClips(new Set([...expandedClips, clips.length]));
|
||||
|
||||
// Auto-set first clip as default animation
|
||||
if (clips.length === 0 && component && !component.defaultAnimation) {
|
||||
component.defaultAnimation = newName;
|
||||
setDefaultAnimation(newName);
|
||||
if (onDefaultAnimationChange) {
|
||||
onDefaultAnimationChange(newName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeClip = (index: number) => {
|
||||
const newClips = clips.filter((_, i) => i !== index);
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const updateClip = (index: number, updates: Partial<AnimationClip>) => {
|
||||
const newClips = [...clips];
|
||||
const existingClip = newClips[index];
|
||||
if (!existingClip) return;
|
||||
newClips[index] = { ...existingClip, ...updates } as AnimationClip;
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const addFrame = (clipIndex: number) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = [...clip.frames, { textureGuid: '', duration: 0.1 }];
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const removeFrame = (clipIndex: number, frameIndex: number) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = clip.frames.filter((_, i) => i !== frameIndex);
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const updateFrame = (clipIndex: number, frameIndex: number, updates: Partial<AnimationFrame>) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
clip.frames = [...clip.frames];
|
||||
const existingFrame = clip.frames[frameIndex];
|
||||
if (!existingFrame) return;
|
||||
clip.frames[frameIndex] = { ...existingFrame, ...updates } as AnimationFrame;
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const addFramesBatch = (clipIndex: number, texturePaths: string[]) => {
|
||||
const newClips = [...clips];
|
||||
const clip = newClips[clipIndex];
|
||||
if (!clip) return;
|
||||
const newFrames = texturePaths.map((textureGuid) => ({
|
||||
textureGuid,
|
||||
duration: 0.1
|
||||
}));
|
||||
clip.frames = [...clip.frames, ...newFrames];
|
||||
onChange(newClips);
|
||||
};
|
||||
|
||||
const handleFramesDrop = useCallback((clipIndex: number, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const data = e.dataTransfer.getData('application/json');
|
||||
if (data) {
|
||||
try {
|
||||
const items = JSON.parse(data);
|
||||
if (Array.isArray(items)) {
|
||||
const textures = items
|
||||
.filter((item: { type: string; path: string }) =>
|
||||
item.type === 'file' && /\.(png|jpg|jpeg|webp|gif)$/i.test(item.path))
|
||||
.map((item: { path: string }) => item.path)
|
||||
.sort();
|
||||
if (textures.length > 0) {
|
||||
addFramesBatch(clipIndex, textures);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try text data for single file
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && /\.(png|jpg|jpeg|webp|gif)$/i.test(text)) {
|
||||
addFramesBatch(clipIndex, [text]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [clips, onChange]);
|
||||
|
||||
const handleFramesDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const [defaultAnimation, setDefaultAnimation] = useState(component?.defaultAnimation || '');
|
||||
|
||||
// Sync with component changes
|
||||
useEffect(() => {
|
||||
if (component) {
|
||||
setDefaultAnimation(component.defaultAnimation || '');
|
||||
}
|
||||
}, [component?.defaultAnimation]);
|
||||
|
||||
const setAsDefaultAnimationHandler = (clipName: string) => {
|
||||
if (component) {
|
||||
component.defaultAnimation = clipName;
|
||||
setDefaultAnimation(clipName);
|
||||
|
||||
// Notify parent to update the defaultAnimation field
|
||||
if (onDefaultAnimationChange) {
|
||||
onDefaultAnimationChange(clipName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultAnimation = (clipName: string) => {
|
||||
return defaultAnimation === clipName;
|
||||
};
|
||||
|
||||
const handlePlayPreview = (clipName: string) => {
|
||||
if (component) {
|
||||
const engineService = EngineService.getInstance();
|
||||
|
||||
// Get the actual component from scene entity (not the one passed as prop)
|
||||
const scene = engineService.getScene();
|
||||
const entityId = component.entityId;
|
||||
let actualComponent = component;
|
||||
|
||||
if (scene && entityId !== undefined && entityId !== null) {
|
||||
const sceneEntity = scene.findEntityById(entityId);
|
||||
if (sceneEntity) {
|
||||
const sceneAnimator = sceneEntity.getComponent(component.constructor as any);
|
||||
if (sceneAnimator) {
|
||||
actualComponent = sceneAnimator as SpriteAnimatorComponent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playingClip === clipName) {
|
||||
// Stop playing
|
||||
actualComponent.stop();
|
||||
setPlayingClip(null);
|
||||
engineService.disableAnimationPreview();
|
||||
} else {
|
||||
// Stop previous animation if any
|
||||
actualComponent.stop();
|
||||
|
||||
// Sync clips data to component before playing
|
||||
actualComponent.clips = clips;
|
||||
|
||||
// Enable animation preview if not already enabled
|
||||
if (!engineService.isAnimationPreviewEnabled()) {
|
||||
engineService.enableAnimationPreview();
|
||||
}
|
||||
|
||||
// Play this clip
|
||||
actualComponent.play(clipName);
|
||||
setPlayingClip(clipName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync playingClip state with actual component state
|
||||
useEffect(() => {
|
||||
if (component && playingClip) {
|
||||
// Check if component is still playing
|
||||
if (!component.isPlaying()) {
|
||||
setPlayingClip(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stop preview when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (component) {
|
||||
component.stop();
|
||||
const engineService = EngineService.getInstance();
|
||||
engineService.disableAnimationPreview();
|
||||
}
|
||||
};
|
||||
}, [component]);
|
||||
|
||||
return (
|
||||
<div className="animation-clips-editor">
|
||||
<div className="clips-header">
|
||||
<span className="clips-label">{label}</span>
|
||||
{!readonly && (
|
||||
<button className="add-clip-btn" onClick={addClip} title="Add Animation Clip">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<div className="clips-empty">
|
||||
<Film size={24} strokeWidth={1} />
|
||||
<span>No animation clips</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="clips-list">
|
||||
{clips.map((clip, clipIndex) => (
|
||||
<div key={clipIndex} className="clip-item">
|
||||
<div className="clip-header" onClick={() => toggleClip(clipIndex)}>
|
||||
{expandedClips.has(clipIndex) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<Film size={14} />
|
||||
<input
|
||||
className="clip-name-input"
|
||||
value={clip.name}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
updateClip(clipIndex, { name: e.target.value });
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<span className="frame-count">{clip.frames.length} frames</span>
|
||||
{component && clip.frames.length > 0 && (
|
||||
<button
|
||||
className={`preview-clip-btn ${playingClip === clip.name ? 'is-playing' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePlayPreview(clip.name);
|
||||
}}
|
||||
title={playingClip === clip.name ? 'Stop Preview' : 'Preview Animation'}
|
||||
>
|
||||
{playingClip === clip.name ? <Square size={10} /> : <Play size={10} />}
|
||||
</button>
|
||||
)}
|
||||
{component && !readonly && (
|
||||
<button
|
||||
className={`set-default-btn ${isDefaultAnimation(clip.name) ? 'is-default' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAsDefaultAnimationHandler(clip.name);
|
||||
}}
|
||||
title={isDefaultAnimation(clip.name) ? 'Current Default Animation' : 'Set as Default Animation'}
|
||||
>
|
||||
<Star size={12} fill={isDefaultAnimation(clip.name) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{!readonly && (
|
||||
<button
|
||||
className="remove-clip-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clipIndex);
|
||||
}}
|
||||
title="Remove Clip"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedClips.has(clipIndex) && (
|
||||
<div className="clip-content">
|
||||
<div className="clip-settings">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clip.loop}
|
||||
onChange={(e) => updateClip(clipIndex, { loop: e.target.checked })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
<DraggableNumber
|
||||
label="Speed:"
|
||||
value={clip.speed}
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={(val) => updateClip(clipIndex, { speed: val })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="frames-section"
|
||||
onDrop={(e) => handleFramesDrop(clipIndex, e)}
|
||||
onDragOver={handleFramesDragOver}
|
||||
>
|
||||
<div className="frames-header">
|
||||
<span>Frames</span>
|
||||
{!readonly && (
|
||||
<button onClick={() => addFrame(clipIndex)} title="Add Frame">
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clip.frames.length === 0 ? (
|
||||
<div className="frames-empty frames-drop-zone">
|
||||
<Upload size={16} />
|
||||
<span>Drop images here or click + to add</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="frames-list">
|
||||
{clip.frames.map((frame, frameIndex) => (
|
||||
<div key={frameIndex} className="frame-item">
|
||||
<span className="frame-index">{frameIndex + 1}</span>
|
||||
<div className="frame-texture-field">
|
||||
<AssetField
|
||||
value={frame.textureGuid}
|
||||
onChange={(val) => updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })}
|
||||
fileExtension=".png"
|
||||
placeholder="Texture..."
|
||||
readonly={readonly}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="frame-duration"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={frame.duration}
|
||||
onChange={(e) => updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })}
|
||||
disabled={readonly}
|
||||
title="Duration (seconds)"
|
||||
/>
|
||||
{!readonly && (
|
||||
<button
|
||||
onClick={() => removeFrame(clipIndex, frameIndex)}
|
||||
title="Remove Frame"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
|
||||
export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
readonly type = 'asset';
|
||||
readonly name = 'Asset Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'asset' || fieldType === 'assetReference' || fieldType === 'resourcePath';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<string | null>): React.ReactElement {
|
||||
const fileExtension = context.metadata?.fileExtension || '';
|
||||
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
// 从 FileActionRegistry 获取资产创建消息映射
|
||||
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
|
||||
const creationMapping = fileActionRegistry?.getAssetCreationMapping(fileExtension);
|
||||
|
||||
const handleCreate = () => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub && creationMapping) {
|
||||
messageHub.publish(creationMapping.createMessage, {
|
||||
entityId: context.metadata?.entityId,
|
||||
onChange
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = creationMapping !== null && creationMapping !== undefined;
|
||||
|
||||
return (
|
||||
<AssetField
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fileExtension={fileExtension}
|
||||
placeholder={placeholder}
|
||||
readonly={context.readonly}
|
||||
onNavigate={handleNavigate}
|
||||
onCreate={canCreate ? handleCreate : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
|
||||
interface Color {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
function rgbaToHex(color: Color): string {
|
||||
const toHex = (c: number) => Math.round(c * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string): Color {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result && result[1] && result[2] && result[3]) {
|
||||
return {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255,
|
||||
a: 1
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 1 };
|
||||
}
|
||||
|
||||
export class ColorFieldEditor implements IFieldEditor<Color> {
|
||||
readonly type = 'color';
|
||||
readonly name = 'Color Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'color' || fieldType === 'rgba' || fieldType === 'rgb';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Color>): React.ReactElement {
|
||||
const color = value || { r: 1, g: 1, b: 1, a: 1 };
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||
setShowPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showPicker]);
|
||||
|
||||
const hexColor = rgbaToHex(color);
|
||||
const rgbDisplay = `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${color.a.toFixed(2)})`;
|
||||
|
||||
return (
|
||||
<div className="property-field" style={{ position: 'relative' }}>
|
||||
<label className="property-label">{label}</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => !context.readonly && setShowPicker(!showPicker)}
|
||||
disabled={context.readonly}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '24px',
|
||||
backgroundColor: hexColor,
|
||||
border: '2px solid #444',
|
||||
borderRadius: '3px',
|
||||
cursor: context.readonly ? 'default' : 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{color.a < 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%)',
|
||||
backgroundPosition: '0 0, 8px 8px',
|
||||
backgroundSize: '16px 16px',
|
||||
opacity: 1 - color.a
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span style={{ fontSize: '11px', color: '#888', fontFamily: 'monospace' }}>
|
||||
{rgbDisplay}
|
||||
</span>
|
||||
|
||||
{showPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
zIndex: 1000,
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ fontSize: '10px', color: '#888' }}>Hex: </label>
|
||||
<input
|
||||
type="color"
|
||||
value={hexColor}
|
||||
onChange={(e) => {
|
||||
const newColor = hexToRgba(e.target.value);
|
||||
onChange({ ...newColor, a: color.a });
|
||||
}}
|
||||
style={{ marginLeft: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(color.r * 255)}
|
||||
onChange={(e) => onChange({ ...color, r: (parseInt(e.target.value) || 0) / 255 })}
|
||||
min={0}
|
||||
max={255}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '2px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(color.g * 255)}
|
||||
onChange={(e) => onChange({ ...color, g: (parseInt(e.target.value) || 0) / 255 })}
|
||||
min={0}
|
||||
max={255}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '2px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(color.b * 255)}
|
||||
onChange={(e) => onChange({ ...color, b: (parseInt(e.target.value) || 0) / 255 })}
|
||||
min={0}
|
||||
max={255}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '2px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<label style={{ fontSize: '10px', color: '#888' }}>Alpha:</label>
|
||||
<input
|
||||
type="range"
|
||||
value={color.a}
|
||||
onChange={(e) => onChange({ ...color, a: parseFloat(e.target.value) })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#888', minWidth: '30px' }}>
|
||||
{(color.a * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Entity Reference Field Editor
|
||||
* 实体引用字段编辑器
|
||||
*
|
||||
* Handles editing of entity reference fields with drag-and-drop support.
|
||||
* 处理实体引用字段的编辑,支持拖放操作。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
import { EntityRefField } from '../../components/inspectors/fields/EntityRefField';
|
||||
|
||||
/**
|
||||
* Field editor for entity references (entity IDs)
|
||||
* 实体引用(实体 ID)的字段编辑器
|
||||
*
|
||||
* Supports:
|
||||
* - Drag-and-drop entities from SceneHierarchy
|
||||
* - Click to navigate to referenced entity
|
||||
* - Clear button to remove reference
|
||||
*
|
||||
* 支持:
|
||||
* - 从场景层级面板拖放实体
|
||||
* - 点击导航到引用的实体
|
||||
* - 清除按钮移除引用
|
||||
*/
|
||||
export class EntityRefFieldEditor implements IFieldEditor<number> {
|
||||
readonly type = 'entityRef';
|
||||
readonly name = 'Entity Reference Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
/**
|
||||
* Check if this editor can handle the given field type
|
||||
* 检查此编辑器是否可以处理给定的字段类型
|
||||
*/
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'entityRef' ||
|
||||
fieldType === 'entityReference' ||
|
||||
fieldType === 'EntityRef' ||
|
||||
fieldType.endsWith('EntityId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entity reference field
|
||||
* 渲染实体引用字段
|
||||
*/
|
||||
render({ label, value, onChange, context }: FieldEditorProps<number>): React.ReactElement {
|
||||
const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here';
|
||||
|
||||
return (
|
||||
<EntityRefField
|
||||
label={label}
|
||||
value={value ?? 0}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
readonly={context.readonly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
|
||||
interface Vector2 { x: number; y: number; }
|
||||
interface Vector3 extends Vector2 { z: number; }
|
||||
interface Vector4 extends Vector3 { w: number; }
|
||||
|
||||
const VectorInput: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
readonly?: boolean;
|
||||
axis: 'x' | 'y' | 'z' | 'w';
|
||||
step?: number;
|
||||
}> = ({ label, value, onChange, readonly, axis, step = 0.01 }) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
// 允许空字符串、负号、小数点等中间输入状态
|
||||
// Allow empty string, minus sign, decimal point as intermediate states
|
||||
if (inputValue === '' || inputValue === '-' || inputValue === '.' || inputValue === '-.') {
|
||||
return; // 不触发 onChange,等待用户完成输入
|
||||
}
|
||||
const parsed = parseFloat(inputValue);
|
||||
if (!isNaN(parsed)) {
|
||||
onChange(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
// 失去焦点时,如果是无效值则重置为当前值
|
||||
// On blur, if value is invalid, reset to current value
|
||||
const parsed = parseFloat(e.target.value);
|
||||
if (isNaN(parsed)) {
|
||||
e.target.value = String(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
key={value} // 强制在外部值变化时重新渲染
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={readonly}
|
||||
step={step}
|
||||
className="property-input property-input-number property-input-number-compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
|
||||
readonly type = 'vector2';
|
||||
readonly name = 'Vector2 Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'vector2' || fieldType === 'vec2';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector2>): React.ReactElement {
|
||||
const v = value || { x: 0, y: 0 };
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-vector-compact">
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => onChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => onChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Vector3FieldEditor implements IFieldEditor<Vector3> {
|
||||
readonly type = 'vector3';
|
||||
readonly name = 'Vector3 Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'vector3' || fieldType === 'vec3';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector3>): React.ReactElement {
|
||||
const v = value || { x: 0, y: 0, z: 0 };
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-vector-compact">
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => onChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => onChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Z"
|
||||
value={v.z}
|
||||
onChange={(z) => onChange({ ...v, z })}
|
||||
readonly={context.readonly}
|
||||
axis="z"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
readonly type = 'vector4';
|
||||
readonly name = 'Vector4 Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'vector4' || fieldType === 'vec4' || fieldType === 'quaternion';
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
||||
// Support both object {x,y,z,w} and array [0,1,2,3] formats
|
||||
// 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式
|
||||
let v: Vector4;
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
if (isArray) {
|
||||
const arr = value as unknown as number[];
|
||||
v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 };
|
||||
} else {
|
||||
v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
}
|
||||
|
||||
const handleChange = (newV: Vector4) => {
|
||||
if (isArray) {
|
||||
// Return as array if input was array
|
||||
// 如果输入是数组,则返回数组
|
||||
onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4);
|
||||
} else {
|
||||
onChange(newV);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-vector-compact">
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => handleChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => handleChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Z"
|
||||
value={v.z}
|
||||
onChange={(z) => handleChange({ ...v, z })}
|
||||
readonly={context.readonly}
|
||||
axis="z"
|
||||
/>
|
||||
<VectorInput
|
||||
label="W"
|
||||
value={v.w}
|
||||
onChange={(w) => handleChange({ ...v, w })}
|
||||
readonly={context.readonly}
|
||||
axis="w"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './AssetFieldEditor';
|
||||
export * from './VectorFieldEditors';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './AnimationClipsFieldEditor';
|
||||
export * from './EntityRefFieldEditor';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
5
packages/editor/editor-app/src/infrastructure/index.ts
Normal file
5
packages/editor/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';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Settings } 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} />}
|
||||
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
|
||||
<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,139 @@
|
||||
import React from '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;
|
||||
}
|
||||
|
||||
const VectorValue: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
axis: 'x' | 'y' | 'z' | 'w';
|
||||
decimals: number;
|
||||
}> = ({ label, value, axis, decimals }) => (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
|
||||
<span className="property-input property-input-number property-input-number-compact" style={{ cursor: 'default' }}>
|
||||
{formatNumber(value, decimals)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
<div className="property-vector-compact">
|
||||
<VectorValue label="X" value={value.x} axis="x" decimals={decimals} />
|
||||
<VectorValue label="Y" value={value.y} axis="y" decimals={decimals} />
|
||||
</div>
|
||||
</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>
|
||||
<div className="property-vector-compact">
|
||||
<VectorValue label="X" value={value.x} axis="x" decimals={decimals} />
|
||||
<VectorValue label="Y" value={value.y} axis="y" decimals={decimals} />
|
||||
<VectorValue label="Z" value={value.z} axis="z" decimals={decimals} />
|
||||
</div>
|
||||
</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 className="property-color-wrapper">
|
||||
<div
|
||||
className="property-color-preview"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
/>
|
||||
<span className="property-input property-input-color-text" style={{ cursor: 'default' }}>
|
||||
{colorHex.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './PrimitiveRenderers';
|
||||
export * from './VectorRenderers';
|
||||
export * from './ComponentRenderer';
|
||||
export * from './FallbackRenderer';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Reference in New Issue
Block a user