feat: implement keyframe animation system

Add keyframe support for transform, opacity, and volume properties.
Includes animation engine (interpolation, mutations, resolvers), timeline
markers with selection/snapping, properties panel toggles, keyframe-aware
renderer, and full undo/redo command support.

Also refactor element command constructors to object params, extract
timeline pixel math to pixel-utils.ts, and update cursor rules.
This commit is contained in:
Maze Winther
2026-02-27 16:33:57 +01:00
parent 9b94f89def
commit 49426f19cd
55 changed files with 4139 additions and 312 deletions
@@ -0,0 +1,151 @@
import { useEditor } from "@/hooks/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
import type { ElementUpdatePatch } from "@/types/timeline";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse,
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: valueAtPlayhead,
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value,
},
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}
@@ -0,0 +1,32 @@
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { KeyframeIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
export function KeyframeToggle({
isActive,
isDisabled = false,
title,
onToggle,
}: {
isActive: boolean;
isDisabled?: boolean;
title: string;
onToggle: () => void;
}) {
return (
<Button
variant="text"
aria-pressed={isActive}
disabled={isDisabled}
title={title}
onClick={onToggle}
className="[&>svg]:size-3.5 mb-0.5"
>
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(isActive && "text-primary fill-primary")}
/>
</Button>
);
}
@@ -139,21 +139,28 @@ export function SectionFields({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
return (
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
);
}
export function SectionField({
label,
beforeLabel,
children,
className,
}: {
label: string;
beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<Label>{label}</Label>
<div className="flex items-center gap-1.5">
{beforeLabel}
<Label>{label}</Label>
</div>
{children}
</div>
);
@@ -1,9 +1,15 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { AnimationPropertyPath } from "@/types/animation";
import type { VisualElement } from "@/types/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -13,71 +19,125 @@ import {
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
type TransformElement = {
id: string;
transform: Transform;
type: ElementType;
};
function parseFloat_({ input }: { input: string }): number | null {
function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
staticValue,
defaultValue,
}: {
hasAnimatedKeyframes: boolean;
isPlayheadWithinElementRange: boolean;
resolvedValue: number;
staticValue: number;
defaultValue: number;
}): boolean {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
return isNearlyEqual({
leftValue: resolvedValue,
rightValue: defaultValue,
});
}
return staticValue === defaultValue;
}
export function TransformSection({
element,
trackId,
}: {
element: TransformElement;
element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
const playheadTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const isPlayheadWithinElementRange =
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
const previewTransform = (transform: Partial<Transform>) => {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { transform: { ...element.transform, ...transform } },
const positionX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.x",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.x).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.x,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
x: value,
},
],
});
};
const commit = () => editor.timeline.commitPreview();
const positionX = usePropertyDraft({
displayValue: Math.round(element.transform.position.x).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, x: value },
}),
onCommit: commit,
},
}),
});
const positionY = usePropertyDraft({
displayValue: Math.round(element.transform.position.y).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, y: value },
}),
onCommit: commit,
const positionY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position.y",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.y).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.y,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: {
...element.transform.position,
y: value,
},
},
}),
});
const scale = usePropertyDraft({
displayValue: Math.round(element.transform.scale * 100).toString(),
const scale = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scale",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
onPreview: (value) => previewTransform({ scale: value }),
onCommit: commit,
valueAtPlayhead: resolvedTransform.scale,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scale: value,
},
}),
});
const scaleFieldProps = {
className: "flex-1",
@@ -88,66 +148,148 @@ export function TransformSection({
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
onReset: () =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
scale: DEFAULT_TRANSFORM.scale,
},
},
},
],
}),
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scale,
staticValue: element.transform.scale,
defaultValue: DEFAULT_TRANSFORM.scale,
}),
};
const rotation = usePropertyDraft({
displayValue: Math.round(element.transform.rotate).toString(),
const rotation = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.rotate",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
onPreview: (value) => previewTransform({ rotate: value }),
onCommit: commit,
valueAtPlayhead: resolvedTransform.rotate,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
rotate: value,
},
}),
});
const hasPositionKeyframe =
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
const togglePositionKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
const keyframesToRemove: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}> = [];
if (positionX.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.x" as const,
keyframeId: positionX.keyframeIdAtTime,
});
}
if (positionY.keyframeIdAtTime) {
keyframesToRemove.push({
trackId,
elementId: element.id,
propertyPath: "transform.position.y" as const,
keyframeId: positionY.keyframeIdAtTime,
});
}
editor.timeline.removeKeyframes({
keyframes: keyframesToRemove,
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId: element.id,
propertyPath: "transform.position.x",
time: localTime,
value: resolvedTransform.position.x,
},
{
trackId,
elementId: element.id,
propertyPath: "transform.position.y",
time: localTime,
value: resolvedTransform.position.y,
},
],
});
};
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<SectionContent>
<SectionFields>
<SectionField label="Scale">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
<SectionContent>
<SectionFields>
<SectionField
label="Scale"
beforeLabel={
<KeyframeToggle
isActive={scale.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle scale keyframe"
onToggle={scale.toggleKeyframe}
/>
)}
<Button
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
</SectionField>
<SectionField label="Position">
<div className="flex items-center gap-2">
}
>
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
type="button"
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
</SectionField>
<SectionField
label="Position"
beforeLabel={
<KeyframeToggle
isActive={hasPositionKeyframe}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe"
onToggle={togglePositionKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
@@ -158,27 +300,15 @@ export function TransformSection({
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
},
},
},
},
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x,
defaultValue: DEFAULT_TRANSFORM.position.x,
})}
/>
<NumberField
icon="Y"
@@ -190,63 +320,56 @@ export function TransformSection({
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
},
},
},
},
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y,
defaultValue: DEFAULT_TRANSFORM.position.y,
})}
/>
</div>
</SectionField>
</div>
</SectionField>
<SectionField label="Rotation">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
},
},
],
})
<SectionField
label="Rotation"
beforeLabel={
<KeyframeToggle
isActive={rotation.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle rotation keyframe"
onToggle={rotation.toggleKeyframe}
/>
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</SectionField>
</SectionFields>
</SectionContent>
>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.rotate,
staticValue: element.transform.rotate,
defaultValue: DEFAULT_TRANSFORM.rotate,
})}
/>
</div>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}