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:
104
components/animate/PresetLibrary.tsx
Normal file
104
components/animate/PresetLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user