docs: keyframes system
This commit is contained in:
parent
81e7a7fb32
commit
228e2e4342
|
|
@ -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)
|
||||
Loading…
Reference in New Issue