2025-11-23 14:49:37 +08:00
|
|
|
import React, { useState, useCallback, useEffect } from 'react';
|
|
|
|
|
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
2025-12-08 21:26:35 +08:00
|
|
|
import { Core } from '@esengine/ecs-framework';
|
2025-11-23 14:49:37 +08:00
|
|
|
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
|
2025-12-01 22:28:51 +08:00
|
|
|
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/sprite';
|
2025-11-23 14:49:37 +08:00
|
|
|
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;
|
2025-12-06 14:08:48 +08:00
|
|
|
clip.frames = [...clip.frames, { textureGuid: '', duration: 0.1 }];
|
2025-11-23 14:49:37 +08:00
|
|
|
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;
|
2025-12-06 14:08:48 +08:00
|
|
|
const newFrames = texturePaths.map((textureGuid) => ({
|
|
|
|
|
textureGuid,
|
2025-11-23 14:49:37 +08:00
|
|
|
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
|
2025-12-06 14:08:48 +08:00
|
|
|
value={frame.textureGuid}
|
|
|
|
|
onChange={(val) => updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })}
|
2025-11-23 14:49:37 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|