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>
2026-02-28 14:17:04 +01:00
|
|
|
import type { AnimationConfig, Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
|
|
|
|
|
import { DEFAULT_TRANSFORM } from './defaults';
|
|
|
|
|
|
|
|
|
|
function isIdentityTransform(t: TransformValue): boolean {
|
|
|
|
|
return (
|
|
|
|
|
t.translateX === 0 &&
|
|
|
|
|
t.translateY === 0 &&
|
|
|
|
|
t.rotate === 0 &&
|
|
|
|
|
t.scaleX === 1 &&
|
|
|
|
|
t.scaleY === 1 &&
|
|
|
|
|
t.skewX === 0 &&
|
|
|
|
|
t.skewY === 0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildTransform(t: TransformValue): string {
|
|
|
|
|
if (isIdentityTransform(t)) return '';
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (t.translateX !== 0 || t.translateY !== 0)
|
|
|
|
|
parts.push(`translate(${t.translateX}px, ${t.translateY}px)`);
|
|
|
|
|
if (t.rotate !== 0) parts.push(`rotate(${t.rotate}deg)`);
|
|
|
|
|
if (t.scaleX !== 1 || t.scaleY !== 1) {
|
|
|
|
|
parts.push(t.scaleX === t.scaleY ? `scale(${t.scaleX})` : `scale(${t.scaleX}, ${t.scaleY})`);
|
|
|
|
|
}
|
|
|
|
|
if (t.skewX !== 0 || t.skewY !== 0)
|
|
|
|
|
parts.push(`skew(${t.skewX}deg, ${t.skewY}deg)`);
|
|
|
|
|
return parts.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildProperties(props: KeyframeProperties): string[] {
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
|
|
|
|
if (props.transform) {
|
|
|
|
|
const t = { ...DEFAULT_TRANSFORM, ...props.transform };
|
|
|
|
|
const val = buildTransform(t);
|
|
|
|
|
lines.push(`transform: ${val || 'none'}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.opacity !== undefined) lines.push(`opacity: ${props.opacity}`);
|
|
|
|
|
if (props.backgroundColor && props.backgroundColor !== 'none')
|
|
|
|
|
lines.push(`background-color: ${props.backgroundColor}`);
|
|
|
|
|
if (props.borderRadius !== undefined && props.borderRadius !== 0)
|
|
|
|
|
lines.push(`border-radius: ${props.borderRadius}px`);
|
|
|
|
|
|
|
|
|
|
const filterParts: string[] = [];
|
|
|
|
|
if (props.blur !== undefined && props.blur !== 0) filterParts.push(`blur(${props.blur}px)`);
|
|
|
|
|
if (props.brightness !== undefined && props.brightness !== 1)
|
|
|
|
|
filterParts.push(`brightness(${props.brightness})`);
|
|
|
|
|
if (filterParts.length) lines.push(`filter: ${filterParts.join(' ')}`);
|
|
|
|
|
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildIterationCount(count: number | 'infinite'): string {
|
|
|
|
|
return count === 'infinite' ? 'infinite' : String(count);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildAnimationShorthand(config: AnimationConfig): string {
|
|
|
|
|
const iter = buildIterationCount(config.iterationCount);
|
|
|
|
|
const delay = config.delay ? ` ${config.delay}ms` : '';
|
|
|
|
|
return `${config.name} ${config.duration}ms ${config.easing}${delay} ${iter} ${config.direction} ${config.fillMode}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 17:08:22 +01:00
|
|
|
export function buildKeyframesOnly(config: AnimationConfig): string {
|
|
|
|
|
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
|
|
|
|
|
let out = `@keyframes ${config.name} {\n`;
|
|
|
|
|
for (const kf of sorted) {
|
|
|
|
|
const lines = buildProperties(kf.properties);
|
|
|
|
|
if (lines.length === 0) {
|
|
|
|
|
out += ` ${kf.offset}% { }\n`;
|
|
|
|
|
} else {
|
|
|
|
|
out += ` ${kf.offset}% {\n`;
|
|
|
|
|
for (const line of lines) out += ` ${line};\n`;
|
|
|
|
|
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
|
|
|
|
|
out += ` }\n`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out += `}\n`;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-02-28 14:17:04 +01:00
|
|
|
export function buildCSS(config: AnimationConfig): string {
|
|
|
|
|
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
|
|
|
|
|
|
|
|
|
|
let out = `@keyframes ${config.name} {\n`;
|
|
|
|
|
for (const kf of sorted) {
|
|
|
|
|
const lines = buildProperties(kf.properties);
|
|
|
|
|
if (lines.length === 0) {
|
|
|
|
|
out += ` ${kf.offset}% { }\n`;
|
|
|
|
|
} else {
|
|
|
|
|
out += ` ${kf.offset}% {\n`;
|
|
|
|
|
for (const line of lines) out += ` ${line};\n`;
|
|
|
|
|
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
|
|
|
|
|
out += ` }\n`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out += `}\n\n`;
|
|
|
|
|
|
|
|
|
|
out += `.animated {\n`;
|
|
|
|
|
out += ` animation: ${buildAnimationShorthand(config)};\n`;
|
|
|
|
|
out += `}\n\n`;
|
|
|
|
|
|
|
|
|
|
out += `/* Usage: add class="animated" to your element */`;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildTailwindCSS(config: AnimationConfig): string {
|
|
|
|
|
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
|
|
|
|
|
|
|
|
|
|
let out = `/* In your globals.css */\n\n`;
|
|
|
|
|
|
|
|
|
|
out += `@keyframes ${config.name} {\n`;
|
|
|
|
|
for (const kf of sorted) {
|
|
|
|
|
const lines = buildProperties(kf.properties);
|
|
|
|
|
if (lines.length === 0) {
|
|
|
|
|
out += ` ${kf.offset}% { }\n`;
|
|
|
|
|
} else {
|
|
|
|
|
out += ` ${kf.offset}% {\n`;
|
|
|
|
|
for (const line of lines) out += ` ${line};\n`;
|
|
|
|
|
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
|
|
|
|
|
out += ` }\n`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out += `}\n\n`;
|
|
|
|
|
|
|
|
|
|
out += `@utility animate-${config.name} {\n`;
|
|
|
|
|
out += ` animation: ${buildAnimationShorthand(config)};\n`;
|
|
|
|
|
out += `}\n\n`;
|
|
|
|
|
|
|
|
|
|
out += `/* Usage: className="animate-${config.name}" */`;
|
|
|
|
|
return out;
|
|
|
|
|
}
|