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
@@ -592,9 +592,12 @@ export function useElementInteraction({
});
if (!alreadySelected) {
selectElement({ trackId: track.id, elementId: element.id });
return;
}
editor.selection.clearKeyframeSelection();
},
[isElementSelected, selectElement],
[editor.selection, isElementSelected, selectElement],
);
return {
@@ -0,0 +1,229 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/types/animation";
function getSelectedKeyframeId({
keyframe,
}: {
keyframe: SelectedKeyframeRef;
}): string {
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
}
function mergeUniqueKeyframes({
keyframes,
}: {
keyframes: SelectedKeyframeRef[];
}): SelectedKeyframeRef[] {
const keyframesById = new Map<string, SelectedKeyframeRef>();
for (const keyframe of keyframes) {
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
}
return [...keyframesById.values()];
}
export function useKeyframeSelection() {
const editor = useEditor();
const selectedKeyframes = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedKeyframes(),
);
const keyframeSelectionAnchor = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getKeyframeSelectionAnchor(),
);
const isKeyframeSelected = useCallback(
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
const keyframeId = getSelectedKeyframeId({ keyframe });
return selectedKeyframes.some(
(selectedKeyframe) =>
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
);
},
[selectedKeyframes],
);
const setKeyframeSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
editor.selection.setSelectedKeyframes({
keyframes: uniqueKeyframes,
anchorKeyframe:
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
});
},
[editor],
);
const addKeyframesToSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const mergedKeyframes = mergeUniqueKeyframes({
keyframes: [...selectedKeyframes, ...keyframes],
});
editor.selection.setSelectedKeyframes({
keyframes: mergedKeyframes,
anchorKeyframe:
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const removeKeyframesFromSelection = useCallback(
({
keyframes,
anchorKeyframe,
}: {
keyframes: SelectedKeyframeRef[];
anchorKeyframe?: SelectedKeyframeRef;
}) => {
const keyframeIdsToRemove = new Set(
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const nextKeyframes = selectedKeyframes.filter(
(selectedKeyframe) =>
!keyframeIdsToRemove.has(
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
),
);
editor.selection.setSelectedKeyframes({
keyframes: nextKeyframes,
anchorKeyframe:
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
});
},
[selectedKeyframes, editor],
);
const clearKeyframeSelection = useCallback(() => {
editor.selection.clearKeyframeSelection();
}, [editor]);
const toggleKeyframeSelection = useCallback(
({
keyframes,
isMultiKey,
}: {
keyframes: SelectedKeyframeRef[];
isMultiKey: boolean;
}) => {
const anchorKeyframe = keyframes[0];
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (areAllKeyframesSelected) {
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
return;
}
addKeyframesToSelection({ keyframes, anchorKeyframe });
},
[
setKeyframeSelection,
isKeyframeSelected,
removeKeyframesFromSelection,
addKeyframesToSelection,
],
);
const selectKeyframeRange = useCallback(
({
orderedKeyframes,
targetKeyframes,
isAdditive,
}: {
orderedKeyframes: SelectedKeyframeRef[];
targetKeyframes: SelectedKeyframeRef[];
isAdditive: boolean;
}) => {
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
return;
}
const anchorKeyframe =
keyframeSelectionAnchor ??
selectedKeyframes[selectedKeyframes.length - 1] ??
targetKeyframes[0];
if (!anchorKeyframe) {
return;
}
const targetKeyframeIds = new Set(
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
);
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
const anchorIndex = orderedKeyframes.findIndex(
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
);
if (anchorIndex === -1) {
if (isAdditive) {
addKeyframesToSelection({
keyframes: targetKeyframes,
anchorKeyframe,
});
return;
}
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
return;
}
const targetIndexes = orderedKeyframes
.map((keyframe, index) => ({
keyframeId: getSelectedKeyframeId({ keyframe }),
index,
}))
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
.map(({ index }) => index);
if (targetIndexes.length === 0) {
return;
}
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
if (isAdditive) {
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
return;
}
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
},
[
keyframeSelectionAnchor,
selectedKeyframes,
addKeyframesToSelection,
setKeyframeSelection,
],
);
return {
selectedKeyframes,
keyframeSelectionAnchor,
isKeyframeSelected,
setKeyframeSelection,
addKeyframesToSelection,
removeKeyframesFromSelection,
clearKeyframeSelection,
toggleKeyframeSelection,
selectKeyframeRange,
};
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface UseSnapIndicatorPositionParams {
@@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
? trackLabelsRef.current.offsetWidth
: 0;
const timelinePosition =
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const timelinePosition = timelineTimeToSnappedPixels({
time: snapPoint?.time ?? 0,
zoomLevel,
});
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return {