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:
2026-02-28 14:17:04 +01:00
parent 4a0aa85859
commit eeef3283c8
14 changed files with 1524 additions and 1 deletions

View File

@@ -0,0 +1,104 @@
'use client';
import { useState, useCallback } from 'react';
import { AnimationSettings } from './AnimationSettings';
import { AnimationPreview } from './AnimationPreview';
import { KeyframeTimeline } from './KeyframeTimeline';
import { KeyframeProperties } from './KeyframeProperties';
import { PresetLibrary } from './PresetLibrary';
import { ExportPanel } from './ExportPanel';
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
export function AnimationEditor() {
const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG);
const [selectedId, setSelectedId] = useState<string | null>(
DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id
);
const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
const updateKeyframeProps = useCallback((id: string, props: KFProps) => {
setConfig((c) => ({
...c,
keyframes: c.keyframes.map((k) => k.id === id ? { ...k, properties: props } : k),
}));
}, []);
const addKeyframe = useCallback((offset: number) => {
const kf = newKeyframe(offset);
setConfig((c) => ({ ...c, keyframes: [...c.keyframes, kf] }));
setSelectedId(kf.id);
}, []);
const deleteKeyframe = useCallback((id: string) => {
setConfig((c) => {
if (c.keyframes.length <= 2) return c;
const next = c.keyframes.filter((k) => k.id !== id);
return { ...c, keyframes: next };
});
setSelectedId((prev) => {
if (prev !== id) return prev;
const remaining = config.keyframes.filter((k) => k.id !== id);
return remaining[remaining.length - 1]?.id ?? null;
});
}, [config.keyframes]);
const moveKeyframe = useCallback((id: string, newOffset: number) => {
const clamped = Math.min(100, Math.max(0, Math.round(newOffset)));
setConfig((c) => ({
...c,
keyframes: c.keyframes.map((k) => k.id === id ? { ...k, offset: clamped } : k),
}));
}, []);
const loadPreset = useCallback((presetConfig: AnimationConfig) => {
setConfig(presetConfig);
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
}, []);
return (
<div className="space-y-6">
{/* Row 1: Settings + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
<div className="lg:col-span-1">
<AnimationSettings config={config} onChange={setConfig} />
</div>
<div className="lg:col-span-2">
<AnimationPreview
config={config}
element={previewElement}
onElementChange={setPreviewElement}
/>
</div>
</div>
{/* Row 2: Keyframe Timeline */}
<KeyframeTimeline
keyframes={config.keyframes}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={addKeyframe}
onDelete={deleteKeyframe}
onMove={moveKeyframe}
/>
{/* Row 3: Keyframe Properties + Export */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
<div className="lg:col-span-1">
<KeyframeProperties
keyframe={selectedKeyframe}
onChange={updateKeyframeProps}
/>
</div>
<div className="lg:col-span-2">
<ExportPanel config={config} />
</div>
</div>
{/* Row 4: Preset Library */}
<PresetLibrary currentName={config.name} onSelect={loadPreset} />
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate';
interface Props {
config: AnimationConfig;
element: PreviewElement;
onElementChange: (e: PreviewElement) => void;
}
const SPEEDS: { label: string; value: string }[] = [
{ label: '0.25×', value: '0.25' },
{ label: '0.5×', value: '0.5' },
{ label: '1×', value: '1' },
{ label: '2×', value: '2' },
];
export function AnimationPreview({ config, element, onElementChange }: Props) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const elementRef = useRef<HTMLDivElement>(null);
const [restartKey, setRestartKey] = useState(0);
const [paused, setPaused] = useState(false);
const [speed, setSpeed] = useState('1');
// Inject @keyframes CSS into document head
useEffect(() => {
if (!styleRef.current) {
styleRef.current = document.createElement('style');
styleRef.current.id = 'kit-animate-preview';
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildCSS(config);
}, [config]);
// Cleanup on unmount
useEffect(() => {
return () => {
styleRef.current?.remove();
};
}, []);
const restart = () => {
setPaused(false);
setRestartKey((k) => k + 1);
};
const scaledDuration = Math.round(config.duration / Number(speed));
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Preview</CardTitle>
<Select value={speed} onValueChange={setSpeed}>
<SelectTrigger className="w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPEEDS.map((s) => (
<SelectItem key={s.value} value={s.value} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
{/* Preview canvas */}
<div className="flex-1 min-h-52 flex items-center justify-center rounded-xl bg-gradient-to-br from-muted/20 to-muted/5 border border-border relative overflow-hidden">
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-5 pointer-events-none"
style={{
backgroundImage: 'linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
{/* Animated element */}
<div
key={restartKey}
ref={elementRef}
className="animated relative z-10"
style={{
animationDuration: `${scaledDuration}ms`,
animationPlayState: paused ? 'paused' : 'running',
}}
>
{element === 'box' && (
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
)}
{element === 'circle' && (
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
)}
{element === 'text' && (
<span className="text-4xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
Hello
</span>
)}
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between gap-3">
<ToggleGroup
type="single"
value={element}
onValueChange={(v) => v && onElementChange(v as PreviewElement)}
className="gap-1"
>
<ToggleGroupItem value="box" size="sm" title="Box">
<Square className="h-3.5 w-3.5" />
</ToggleGroupItem>
<ToggleGroupItem value="circle" size="sm" title="Circle">
<Circle className="h-3.5 w-3.5" />
</ToggleGroupItem>
<ToggleGroupItem value="text" size="sm" title="Text">
<Type className="h-3.5 w-3.5" />
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-1.5">
<Button
size="icon-xs"
variant="outline"
onClick={() => { setPaused(false); }}
disabled={!paused}
title="Play"
>
<Play className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={() => setPaused(true)}
disabled={paused}
title="Pause"
>
<Pause className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={restart}
title="Restart"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Infinity } from 'lucide-react';
import type { AnimationConfig } from '@/types/animate';
interface Props {
config: AnimationConfig;
onChange: (config: AnimationConfig) => void;
}
const EASINGS = [
{ value: 'linear', label: 'Linear' },
{ value: 'ease', label: 'Ease' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'ease-in-out', label: 'Ease In Out' },
{ value: 'cubic-bezier', label: 'Cubic Bézier' },
{ value: 'steps(4, end)', label: 'Steps (4)' },
{ value: 'steps(8, end)', label: 'Steps (8)' },
];
export function AnimationSettings({ config, onChange }: Props) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value });
const isInfinite = config.iterationCount === 'infinite';
const isCubic = config.easing === 'cubic-bezier';
// Parse cubic-bezier values from string like "cubic-bezier(x1,y1,x2,y2)"
const cubicValues = (() => {
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
if (!m) return [0.25, 0.1, 0.25, 1.0];
return m[1].split(',').map(Number);
})();
const setCubic = (index: number, val: number) => {
const v = [...cubicValues];
v[index] = val;
set('easing', `cubic-bezier(${v.join(',')})`);
};
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
set('name', val || 'myAnimation');
}}
className="font-mono text-xs"
/>
</div>
{/* Duration + Delay */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Duration</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
className="text-xs"
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Delay</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
className="text-xs"
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
</div>
</div>
{/* Easing */}
<div className="space-y-1.5">
<Label className="text-xs">Easing</Label>
<Select
value={isCubic ? 'cubic-bezier' : config.easing}
onValueChange={(v) => {
if (v === 'cubic-bezier') {
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)');
} else {
set('easing', v);
}
}}
>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EASINGS.map((e) => (
<SelectItem key={e.value} value={e.value} className="text-xs">
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label>
<div className="grid grid-cols-4 gap-1.5">
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label} className="space-y-0.5">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
<Input
type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="text-xs px-1.5"
/>
</div>
))}
</div>
</div>
)}
{/* Iteration */}
<div className="space-y-1.5">
<Label className="text-xs">Iterations</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={999}
value={isInfinite ? '' : config.iterationCount}
disabled={isInfinite}
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
className="text-xs flex-1"
placeholder="1"
/>
<Button
size="icon-xs"
variant={isInfinite ? 'default' : 'outline'}
onClick={() =>
set('iterationCount', isInfinite ? 1 : 'infinite')
}
title="Toggle infinite"
>
<Infinity className="h-3 w-3" />
</Button>
</div>
</div>
{/* Direction */}
<div className="space-y-1.5">
<Label className="text-xs">Direction</Label>
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal" className="text-xs">Normal</SelectItem>
<SelectItem value="reverse" className="text-xs">Reverse</SelectItem>
<SelectItem value="alternate" className="text-xs">Alternate</SelectItem>
<SelectItem value="alternate-reverse" className="text-xs">Alternate Reverse</SelectItem>
</SelectContent>
</Select>
</div>
{/* Fill Mode */}
<div className="space-y-1.5">
<Label className="text-xs">Fill Mode</Label>
<Select value={config.fillMode} onValueChange={(v) => set('fillMode', v as AnimationConfig['fillMode'])}>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs">None</SelectItem>
<SelectItem value="forwards" className="text-xs">Forwards</SelectItem>
<SelectItem value="backwards" className="text-xs">Backwards</SelectItem>
<SelectItem value="both" className="text-xs">Both</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig } from '@/types/animate';
interface Props {
config: AnimationConfig;
}
function CodeBlock({ code, filename }: { code: string; filename: string }) {
const copy = () => {
navigator.clipboard.writeText(code);
toast.success('Copied to clipboard!');
};
const download = () => {
const blob = new Blob([code], { type: 'text/css' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
toast.success(`Downloaded ${filename}`);
};
return (
<div className="space-y-3">
<div className="relative">
<pre className="p-4 rounded-xl bg-muted/30 border border-border text-xs font-mono leading-relaxed overflow-auto max-h-72 text-foreground/90 whitespace-pre">
<code>{code}</code>
</pre>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={copy} className="flex-1">
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</Button>
<Button size="sm" variant="outline" onClick={download} className="flex-1">
<Download className="h-3.5 w-3.5 mr-1.5" />
Download .css
</Button>
</div>
</div>
);
}
export function ExportPanel({ config }: Props) {
const css = useMemo(() => buildCSS(config), [config]);
const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Export</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="css">
<TabsList className="mb-4">
<TabsTrigger value="css" className="text-xs">Plain CSS</TabsTrigger>
<TabsTrigger value="tailwind" className="text-xs">Tailwind v4</TabsTrigger>
</TabsList>
<TabsContent value="css">
<CodeBlock code={css} filename={`${config.name}.css`} />
</TabsContent>
<TabsContent value="tailwind">
<CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
import { DEFAULT_TRANSFORM } from '@/lib/animate/defaults';
interface Props {
keyframe: Keyframe | null;
onChange: (id: string, props: KeyframeProperties) => void;
}
interface SliderRowProps {
label: string;
unit?: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
}
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
return (
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
<Slider
min={min}
max={max}
step={step}
value={[value]}
onValueChange={([v]) => onChange(v)}
/>
</div>
<div className="flex items-center gap-0.5 pt-4">
<Input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-xs px-1.5 h-7"
/>
{unit && <span className="text-[10px] text-muted-foreground">{unit}</span>}
</div>
</div>
);
}
export function KeyframeProperties({ keyframe, onChange }: Props) {
if (!keyframe) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Properties</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<MousePointerClick className="h-8 w-8 mx-auto mb-3 opacity-20" />
<p className="text-xs text-muted-foreground">Select a keyframe on the timeline to edit its properties</p>
</CardContent>
</Card>
);
}
const props = keyframe.properties;
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
const setTransform = (key: keyof TransformValue, value: number) => {
onChange(keyframe.id, {
...props,
transform: { ...t, [key]: value },
});
};
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => {
onChange(keyframe.id, { ...props, [key]: value });
};
const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
return (
<Card className="h-full overflow-auto">
<CardHeader>
<CardTitle>
Properties
<span className="text-muted-foreground font-normal text-sm ml-2">{keyframe.offset}%</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Transform */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Transform</p>
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div>
{/* Visual */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Visual</p>
<SliderRow
label="Opacity"
value={props.opacity ?? 1}
min={0} max={1} step={0.01}
onChange={(v) => setProp('opacity', v)}
/>
{/* Background color */}
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">Background Color</Label>
<div className="flex items-center gap-2">
<Input
type="color"
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
onChange={(e) => setProp('backgroundColor', e.target.value)}
disabled={!hasBg}
className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')}
/>
<Input
type="text"
value={hasBg ? props.backgroundColor! : ''}
onChange={(e) => setProp('backgroundColor', e.target.value)}
disabled={!hasBg}
placeholder="none"
className="font-mono text-xs flex-1"
/>
<Button
size="xs"
variant={hasBg ? 'default' : 'outline'}
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className="shrink-0"
>
{hasBg ? 'On' : 'Off'}
</Button>
</div>
</div>
<SliderRow
label="Border Radius"
unit="px"
value={props.borderRadius ?? 0}
min={0} max={200}
onChange={(v) => setProp('borderRadius', v)}
/>
</div>
{/* Filters */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filter</p>
<SliderRow
label="Blur"
unit="px"
value={props.blur ?? 0}
min={0} max={50}
onChange={(v) => setProp('blur', v)}
/>
<SliderRow
label="Brightness"
value={props.brightness ?? 1}
min={0} max={3} step={0.01}
onChange={(v) => setProp('brightness', v)}
/>
</div>
</CardContent>
</Card>
);
}

View 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>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils/cn';
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, AnimationPreset } from '@/types/animate';
interface Props {
currentName: string;
onSelect: (config: AnimationConfig) => void;
}
function PresetCard({ preset, isActive, onSelect }: {
preset: AnimationPreset;
isActive: boolean;
onSelect: () => void;
}) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const key = `preset-${preset.id}`;
// Each card gets its own @keyframes injected with a unique name to avoid conflicts
useEffect(() => {
const renamedConfig = {
...preset.config,
name: key,
keyframes: preset.config.keyframes,
};
if (!styleRef.current) {
styleRef.current = document.createElement('style');
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildCSS(renamedConfig).replace(
/animation: \S+/,
`animation: ${key}`
);
return () => {
styleRef.current?.remove();
styleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<button
onClick={onSelect}
className={cn(
'group relative flex flex-col items-center gap-2 p-3 rounded-xl border transition-all duration-200 text-left hover:border-primary/50 hover:bg-accent/30',
isActive ? 'border-primary bg-primary/5 shadow-sm shadow-primary/20' : 'border-border bg-card/50'
)}
>
{/* Mini preview */}
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
<div
className="animated w-8 h-8 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
style={{ animationName: key, animationDuration: `${preset.config.duration}ms`, animationIterationCount: 'infinite', animationDirection: 'alternate' }}
/>
</div>
<span className={cn(
'text-[11px] font-medium text-center leading-tight',
isActive ? 'text-primary' : 'text-foreground/80'
)}>
{preset.name}
</span>
</button>
);
}
export function PresetLibrary({ currentName, onSelect }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Presets</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="Entrance">
<TabsList className="mb-4">
{PRESET_CATEGORIES.map((cat) => (
<TabsTrigger key={cat} value={cat} className="text-xs">
{cat}
</TabsTrigger>
))}
</TabsList>
{PRESET_CATEGORIES.map((cat) => (
<TabsContent key={cat} value={cat}>
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
{PRESETS.filter((p) => p.category === cat).map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isActive={preset.id === currentName}
onSelect={() => onSelect(preset.config)}
/>
))}
</div>
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
);
}