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 ( ); } export class AnimationClipsFieldEditor implements IFieldEditor { readonly type = 'animationClips'; readonly name = 'Animation Clips Editor'; readonly priority = 100; canHandle(fieldType: string): boolean { return fieldType === 'animationClips'; } render({ label, value, onChange, context }: FieldEditorProps): React.ReactElement { return ( ); } } 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>(new Set()); const [playingClip, setPlayingClip] = useState(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) => { 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) => { 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 (
{label} {!readonly && ( )}
{clips.length === 0 ? (
No animation clips
) : (
{clips.map((clip, clipIndex) => (
toggleClip(clipIndex)}> {expandedClips.has(clipIndex) ? ( ) : ( )} { e.stopPropagation(); updateClip(clipIndex, { name: e.target.value }); }} onClick={(e) => e.stopPropagation()} disabled={readonly} /> {clip.frames.length} frames {component && clip.frames.length > 0 && ( )} {component && !readonly && ( )} {!readonly && ( )}
{expandedClips.has(clipIndex) && (
updateClip(clipIndex, { speed: val })} disabled={readonly} />
handleFramesDrop(clipIndex, e)} onDragOver={handleFramesDragOver} >
Frames {!readonly && ( )}
{clip.frames.length === 0 ? (
Drop images here or click + to add
) : (
{clip.frames.map((frame, frameIndex) => (
{frameIndex + 1}
updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })} fileExtension=".png" placeholder="Texture..." readonly={readonly} onNavigate={handleNavigate} />
updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })} disabled={readonly} title="Duration (seconds)" /> {!readonly && ( )}
))}
)}
)}
))}
)}
); }