feat: add CSS Animation Editor tool
Comprehensive visual editor for CSS @keyframe animations: - AnimationSettings: name, duration, delay, easing (incl. cubic-bezier), iteration, direction, fill-mode - KeyframeTimeline: drag-to-reposition keyframe markers, click-track to add, delete selected - KeyframeProperties: per-keyframe transform (translate/rotate/scale/skew), opacity, background-color, border-radius, blur, brightness via sliders - AnimationPreview: live preview on box/circle/text element with play/pause/restart and speed control (0.25×–2×) - PresetLibrary: 22 presets across Entrance/Exit/Attention/Special categories with animated thumbnails - ExportPanel: plain CSS and Tailwind v4 @utility formats with copy and download Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
146
components/animate/KeyframeTimeline.tsx
Normal file
146
components/animate/KeyframeTimeline.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { Keyframe } from '@/types/animate';
|
||||
|
||||
interface Props {
|
||||
keyframes: Keyframe[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onAdd: (offset: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMove: (id: string, newOffset: number) => void;
|
||||
}
|
||||
|
||||
const TICKS = [0, 25, 50, 75, 100];
|
||||
|
||||
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getOffsetFromEvent = (clientX: number): number => {
|
||||
if (!trackRef.current) return 0;
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const pct = ((clientX - rect.left) / rect.width) * 100;
|
||||
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||
};
|
||||
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Ignore clicks that land directly on a keyframe marker
|
||||
if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
|
||||
onAdd(getOffsetFromEvent(e.clientX));
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
onSelect(id);
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
|
||||
const handleMove = (me: PointerEvent) => {
|
||||
onMove(id, getOffsetFromEvent(me.clientX));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
el.removeEventListener('pointermove', handleMove);
|
||||
el.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
el.addEventListener('pointermove', handleMove);
|
||||
el.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
const sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
|
||||
const selectedKf = keyframes.find((k) => k.id === selectedId);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Keyframes</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''}
|
||||
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
onClick={() => onAdd(50)}
|
||||
title="Add keyframe at 50%"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
disabled={!selectedId || keyframes.length <= 2}
|
||||
onClick={() => selectedId && onDelete(selectedId)}
|
||||
title="Delete selected keyframe"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-16 bg-muted/30 rounded-lg border border-border cursor-crosshair select-none"
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{/* Center line */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border" />
|
||||
|
||||
{/* Tick marks */}
|
||||
{TICKS.map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${tick}%` }}
|
||||
>
|
||||
<div className="w-px h-2 bg-muted-foreground/30 mt-0" />
|
||||
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Keyframe markers */}
|
||||
{sorted.map((kf) => (
|
||||
<button
|
||||
key={kf.id}
|
||||
data-keyframe-marker
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 rounded-sm transition-all duration-150 touch-none',
|
||||
kf.id === selectedId
|
||||
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
|
||||
: 'bg-muted-foreground/60 hover:bg-primary/70'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
|
||||
onPointerDown={(e) => handlePointerDown(e, kf.id)}
|
||||
title={`${kf.offset}% — drag to move`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Offset labels below */}
|
||||
<div className="relative h-5 mt-1">
|
||||
{sorted.map((kf) => (
|
||||
<span
|
||||
key={kf.id}
|
||||
className={cn(
|
||||
'absolute -translate-x-1/2 text-[10px] transition-colors',
|
||||
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
>
|
||||
{kf.offset}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user