docs: keyframes system

This commit is contained in:
Maze Winther 2026-03-02 16:02:32 +01:00
parent 81e7a7fb32
commit 228e2e4342
1 changed files with 204 additions and 0 deletions

204
docs/keyframes.md Normal file
View File

@ -0,0 +1,204 @@
# Keyframe System
Keyframes allow element properties to change over time. The system is split into three layers: the **data model** (how keyframes are stored), the **registry** (which properties support keyframes and how to read/write them), and the **UI** (hooks and components that wire it all together).
## How It Works
### Data model
Every `BaseTimelineElement` has an optional `animations?: ElementAnimations` field:
```typescript
interface ElementAnimations {
channels: Record<string, AnimationChannel | undefined>;
}
```
A channel is a typed bucket of keyframes keyed by property path (e.g. `"opacity"`, `"background.color"`). Three channel types exist: `NumberAnimationChannel`, `ColorAnimationChannel`, and `DiscreteAnimationChannel`.
### Registry
`src/lib/animation/property-registry.ts` defines which property paths are animatable and how to read/write their values on an element. `src/types/animation.ts` holds the canonical list of valid paths in `ANIMATION_PROPERTY_PATHS`.
### Resolver
`src/lib/animation/resolve.ts` provides functions that return the effective value of a property at a given local time — falling back to the element's static value when no keyframes exist.
### Renderer
Nodes in `src/services/renderer/` call the resolve functions before drawing so that animated properties interpolate correctly during export and preview.
### UI
Two hooks in `src/components/editor/panels/properties/hooks/` handle the keyframe-aware field logic:
- `useKeyframedNumberProperty` — for numeric fields (opacity, position, scale, etc.)
- `useKeyframedColorProperty` — for color pickers
Both hooks handle the toggle/add/remove keyframe flow and automatically switch between writing to the static property and writing to the animation channel depending on whether keyframes are active.
---
## Adding a New Animatable Property
Using `"background.paddingX"` as an example.
### 1. Register the path — `src/types/animation.ts`
```typescript
export const ANIMATION_PROPERTY_PATHS = [
// ...existing paths
"background.paddingX",
] as const;
```
### 2. Add a registry entry — `src/lib/animation/property-registry.ts`
```typescript
"background.paddingX": {
valueKind: "number", // "number" | "color" | "discrete"
defaultInterpolation: "linear",
numericRange: { min: 0 }, // optional, only for number properties
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? { ...element, background: { ...element.background, paddingX: value as number } }
: element,
},
```
**Notes:**
- `getValue` must return the effective value including any defaults — this is what gets recorded when a keyframe is added.
- `setValue` receives `AnimationValue` (`number | string | boolean`). Cast to the correct type since `coerceAnimationValueForProperty` already validated it upstream.
- For color properties, use `valueKind: "color"` and cast `value as string`.
### 3. Add a resolve function — `src/lib/animation/resolve.ts`
For **numbers**, use the existing generic `resolveNumberAtTime`:
```typescript
import { resolveNumberAtTime } from "@/lib/animation";
const resolvedPaddingX = resolveNumberAtTime({
baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
```
For **colors**, use `resolveColorAtTime`:
```typescript
const resolvedColor = resolveColorAtTime({
baseColor: element.color,
animations: element.animations,
propertyPath: "color",
localTime,
});
```
If neither fits (new value kind), add a dedicated resolve function following the same pattern as `resolveOpacityAtTime` and export it from `src/lib/animation/index.ts`.
### 4. Wire the renderer
In the relevant node (`src/services/renderer/nodes/`), call the resolve function before drawing:
```typescript
const resolvedPaddingX = resolveNumberAtTime({
baseValue: this.params.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
animations: this.params.animations,
propertyPath: "background.paddingX",
localTime,
});
```
Use the resolved value (not `this.params.*`) anywhere that value affects rendering.
### 5. Wire the UI
In the properties panel, replace `usePropertyDraft` with the appropriate keyframe hook and add a `KeyframeToggle` to the field.
**For number fields:**
```typescript
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedPaddingX = resolveNumberAtTime({
baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
const paddingX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedPaddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
buildBaseUpdates: ({ value }) => ({
background: { ...element.background, paddingX: value },
}),
});
```
In JSX:
```tsx
<SectionField
label="Width"
beforeLabel={
<KeyframeToggle
isActive={paddingX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle background width keyframe"
onToggle={paddingX.toggleKeyframe}
/>
}
>
<NumberField
value={paddingX.displayValue}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingX,
staticValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
})}
/>
</SectionField>
```
**For color fields**, use `useKeyframedColorProperty` instead. It returns `{ onChange, onChangeEnd, toggleKeyframe, isKeyframedAtTime }` — wire `onChange({ color })` and `onChangeEnd` directly to the `ColorPicker`.
---
## Checklist
- [ ] Path added to `ANIMATION_PROPERTY_PATHS`
- [ ] Registry entry added with correct `valueKind`, `supportsElement`, `getValue`, `setValue`
- [ ] Resolve call added in the renderer node
- [ ] UI field uses `useKeyframedNumberProperty` or `useKeyframedColorProperty` (not `usePropertyDraft`)
- [ ] `KeyframeToggle` added to the `SectionField`
- [ ] `onReset` calls `commitValue` (not `editor.timeline.updateElements` directly)