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