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:
16
app/(app)/animate/page.tsx
Normal file
16
app/(app)/animate/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { AnimationEditor } from '@/components/animate/AnimationEditor';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { getToolByHref } from '@/lib/tools';
|
||||
|
||||
const tool = getToolByHref('/animate')!;
|
||||
|
||||
export const metadata: Metadata = { title: tool.title, description: tool.summary };
|
||||
|
||||
export default function AnimatePage() {
|
||||
return (
|
||||
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}>
|
||||
<AnimationEditor />
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,24 @@ export const FaviconIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AnimateIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3" strokeWidth={2} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 3c-1.2 2.4-1.2 4.8 0 7.2" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 21c1.2-2.4 1.2-4.8 0-7.2" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 12c2.4 1.2 4.8 1.2 7.2 0" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 12c-2.4-1.2-4.8-1.2-7.2 0" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} strokeDasharray="2 2"
|
||||
d="M5.6 5.6c1.8 1.8 3.4 2.6 4.8 2.4" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} strokeDasharray="2 2"
|
||||
d="M18.4 18.4c-1.8-1.8-3.4-2.6-4.8-2.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" strokeWidth={2} />
|
||||
|
||||
104
components/animate/AnimationEditor.tsx
Normal file
104
components/animate/AnimationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
components/animate/AnimationPreview.tsx
Normal file
166
components/animate/AnimationPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
components/animate/AnimationSettings.tsx
Normal file
216
components/animate/AnimationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
components/animate/ExportPanel.tsx
Normal file
79
components/animate/ExportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
components/animate/KeyframeProperties.tsx
Normal file
181
components/animate/KeyframeProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
114
lib/animate/cssBuilder.ts
Normal file
114
lib/animate/cssBuilder.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
47
lib/animate/defaults.ts
Normal file
47
lib/animate/defaults.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AnimationConfig, Keyframe, TransformValue } from '@/types/animate';
|
||||
|
||||
export const DEFAULT_TRANSFORM: TransformValue = {
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
rotate: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
};
|
||||
|
||||
export function newKeyframe(offset: number): Keyframe {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
offset,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: AnimationConfig = {
|
||||
name: 'fadeInUp',
|
||||
duration: 600,
|
||||
delay: 0,
|
||||
easing: 'ease-out',
|
||||
iterationCount: 1,
|
||||
direction: 'normal',
|
||||
fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
offset: 0,
|
||||
properties: {
|
||||
opacity: 0,
|
||||
transform: { ...DEFAULT_TRANSFORM, translateY: 20 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
offset: 100,
|
||||
properties: {
|
||||
opacity: 1,
|
||||
transform: { ...DEFAULT_TRANSFORM },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
257
lib/animate/presets.ts
Normal file
257
lib/animate/presets.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { AnimationPreset, AnimationConfig } from '@/types/animate';
|
||||
import { DEFAULT_TRANSFORM } from './defaults';
|
||||
|
||||
function preset(
|
||||
id: string,
|
||||
name: string,
|
||||
category: AnimationPreset['category'],
|
||||
config: Omit<AnimationConfig, 'name'>,
|
||||
): AnimationPreset {
|
||||
return { id, name, category, config: { ...config, name: id } };
|
||||
}
|
||||
|
||||
const T = DEFAULT_TRANSFORM;
|
||||
|
||||
export const PRESETS: AnimationPreset[] = [
|
||||
// ─── Entrance ────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('fadeIn', 'Fade In', 'Entrance', {
|
||||
duration: 500, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0 } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInUp', 'Fade In Up', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInDown', 'Fade In Down', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: -30 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInLeft', 'Fade In Left', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: -40 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInRight', 'Fade In Right', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: 40 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('zoomIn', 'Zoom In', 'Entrance', {
|
||||
duration: 400, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.5, scaleY: 0.5 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('bounceIn', 'Bounce In', 'Entrance', {
|
||||
duration: 750, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.3, scaleY: 0.3 } } },
|
||||
{ id: 'b', offset: 50, properties: { opacity: 1, transform: { ...T, scaleX: 1.1, scaleY: 1.1 } } },
|
||||
{ id: 'c', offset: 75, properties: { transform: { ...T, scaleX: 0.9, scaleY: 0.9 } } },
|
||||
{ id: 'd', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('slideInLeft', 'Slide In Left', 'Entrance', {
|
||||
duration: 500, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, translateX: -100 } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('rotateIn', 'Rotate In', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, rotate: -180, scaleX: 0.6, scaleY: 0.6 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Exit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('fadeOut', 'Fade Out', 'Exit', {
|
||||
duration: 500, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1 } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeOutDown', 'Fade Out Down', 'Exit', {
|
||||
duration: 600, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('zoomOut', 'Zoom Out', 'Exit', {
|
||||
duration: 400, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, scaleX: 0.4, scaleY: 0.4 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('slideOutRight', 'Slide Out Right', 'Exit', {
|
||||
duration: 500, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, translateX: 100 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Attention ────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('pulse', 'Pulse', 'Attention', {
|
||||
duration: 1000, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'alternate', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, scaleX: 1.08, scaleY: 1.08 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('shake', 'Shake', 'Attention', {
|
||||
duration: 600, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -8 } } },
|
||||
{ id: 'c', offset: 40, properties: { transform: { ...T, translateX: 8 } } },
|
||||
{ id: 'd', offset: 60, properties: { transform: { ...T, translateX: -6 } } },
|
||||
{ id: 'e', offset: 80, properties: { transform: { ...T, translateX: 6 } } },
|
||||
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('wobble', 'Wobble', 'Attention', {
|
||||
duration: 800, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -10, rotate: -5 } } },
|
||||
{ id: 'c', offset: 50, properties: { transform: { ...T, translateX: 8, rotate: 4 } } },
|
||||
{ id: 'd', offset: 80, properties: { transform: { ...T, translateX: -5, rotate: -3 } } },
|
||||
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('swing', 'Swing', 'Attention', {
|
||||
duration: 1000, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 25, properties: { transform: { ...T, rotate: 15 } } },
|
||||
{ id: 'c', offset: 50, properties: { transform: { ...T, rotate: -12 } } },
|
||||
{ id: 'd', offset: 75, properties: { transform: { ...T, rotate: 8 } } },
|
||||
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('flash', 'Flash', 'Attention', {
|
||||
duration: 800, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1 } },
|
||||
{ id: 'b', offset: 25, properties: { opacity: 0 } },
|
||||
{ id: 'c', offset: 50, properties: { opacity: 1 } },
|
||||
{ id: 'd', offset: 75, properties: { opacity: 0 } },
|
||||
{ id: 'e', offset: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Special ──────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('spin', 'Spin', 'Special', {
|
||||
duration: 1000, delay: 0, easing: 'linear',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, rotate: 360 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('ping', 'Ping', 'Special', {
|
||||
duration: 1200, delay: 0, easing: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T }, opacity: 1 } },
|
||||
{ id: 'b', offset: 75, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
|
||||
{ id: 'c', offset: 100, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('wave', 'Wave', 'Special', {
|
||||
duration: 1500, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
|
||||
{ id: 'b', offset: 15, properties: { transform: { ...T, rotate: 14 } } },
|
||||
{ id: 'c', offset: 30, properties: { transform: { ...T, rotate: -8 } } },
|
||||
{ id: 'd', offset: 40, properties: { transform: { ...T, rotate: 14 } } },
|
||||
{ id: 'e', offset: 50, properties: { transform: { ...T, rotate: -4 } } },
|
||||
{ id: 'f', offset: 60, properties: { transform: { ...T, rotate: 10 } } },
|
||||
{ id: 'g', offset: 100, properties: { transform: { ...T, rotate: 0 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('heartbeat', 'Heartbeat', 'Special', {
|
||||
duration: 1300, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 14, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
|
||||
{ id: 'c', offset: 28, properties: { transform: { ...T } } },
|
||||
{ id: 'd', offset: 42, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
|
||||
{ id: 'e', offset: 70, properties: { transform: { ...T } } },
|
||||
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
export const PRESET_CATEGORIES: AnimationPreset['category'][] = [
|
||||
'Entrance',
|
||||
'Exit',
|
||||
'Attention',
|
||||
'Special',
|
||||
];
|
||||
|
||||
export function getPresetsByCategory(category: AnimationPreset['category']): AnimationPreset[] {
|
||||
return PRESETS.filter((p) => p.category === category);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon } from '@/components/AppIcons';
|
||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon } from '@/components/AppIcons';
|
||||
|
||||
export interface Tool {
|
||||
/** Short display name (e.g. "Color") */
|
||||
@@ -86,6 +86,17 @@ export const tools: Tool[] = [
|
||||
icon: QRCodeIcon,
|
||||
badges: ['Open Source', 'Generator', 'Free'],
|
||||
},
|
||||
{
|
||||
shortTitle: 'Animate',
|
||||
title: 'CSS Animation Editor',
|
||||
navTitle: 'CSS Animations',
|
||||
href: '/animate',
|
||||
description: 'Visual editor for CSS keyframe animations with live preview.',
|
||||
summary:
|
||||
'Build and export CSS @keyframe animations visually. Configure timing, easing, transforms, and more — with a live preview and 20+ built-in presets. Export to plain CSS or Tailwind v4.',
|
||||
icon: AnimateIcon,
|
||||
badges: ['Open Source', 'CSS', 'Free'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Look up a tool by its href path */
|
||||
|
||||
64
types/animate.ts
Normal file
64
types/animate.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type EasingPreset =
|
||||
| 'linear'
|
||||
| 'ease'
|
||||
| 'ease-in'
|
||||
| 'ease-out'
|
||||
| 'ease-in-out'
|
||||
| 'cubic-bezier'
|
||||
| 'steps';
|
||||
|
||||
export type AnimationDirection =
|
||||
| 'normal'
|
||||
| 'reverse'
|
||||
| 'alternate'
|
||||
| 'alternate-reverse';
|
||||
|
||||
export type AnimationFillMode = 'none' | 'forwards' | 'backwards' | 'both';
|
||||
|
||||
export type PreviewElement = 'box' | 'circle' | 'text';
|
||||
|
||||
export type PresetCategory = 'Entrance' | 'Exit' | 'Attention' | 'Special';
|
||||
|
||||
export interface TransformValue {
|
||||
translateX: number; // px
|
||||
translateY: number; // px
|
||||
rotate: number; // deg
|
||||
scaleX: number; // ratio (1 = no scale)
|
||||
scaleY: number; // ratio
|
||||
skewX: number; // deg
|
||||
skewY: number; // deg
|
||||
}
|
||||
|
||||
export interface KeyframeProperties {
|
||||
transform?: TransformValue;
|
||||
opacity?: number; // 0–1
|
||||
backgroundColor?: string; // hex or 'none'
|
||||
borderRadius?: number; // px
|
||||
blur?: number; // px
|
||||
brightness?: number; // ratio (1 = no change)
|
||||
}
|
||||
|
||||
export interface Keyframe {
|
||||
id: string;
|
||||
offset: number; // 0–100
|
||||
properties: KeyframeProperties;
|
||||
easing?: string; // per-keyframe easing to next
|
||||
}
|
||||
|
||||
export interface AnimationConfig {
|
||||
name: string;
|
||||
duration: number; // ms
|
||||
delay: number; // ms
|
||||
easing: string; // CSS easing string
|
||||
iterationCount: number | 'infinite';
|
||||
direction: AnimationDirection;
|
||||
fillMode: AnimationFillMode;
|
||||
keyframes: Keyframe[];
|
||||
}
|
||||
|
||||
export interface AnimationPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PresetCategory;
|
||||
config: AnimationConfig;
|
||||
}
|
||||
Reference in New Issue
Block a user