feat: expandable keyframe lanes in timeline

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-04-13 05:08:29 +02:00
parent 2272598d42
commit 36efdf66e7
10 changed files with 1119 additions and 370 deletions
@@ -41,6 +41,8 @@ const MOUSE_BUTTON_RIGHT = 2;
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
dragElementIds: [],
dragTimeOffsets: {},
trackId: null,
startMouseX: 0,
startMouseY: 0,
@@ -202,6 +204,8 @@ export function useElementInteraction({
setDragState({
isDragging: true,
elementId,
dragElementIds: elementId ? [elementId] : [],
dragTimeOffsets: {},
trackId,
startMouseX,
startMouseY,
@@ -521,6 +525,7 @@ export function useElementInteraction({
}, [
dragState.isDragging,
dragState.elementId,
dragState.startElementTime,
dragState.startMouseY,
dragState.trackId,
dragState.currentTime,
@@ -0,0 +1,222 @@
import { useCallback, useRef, useMemo } from "react";
import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import {
useKeyframeSelection,
getSelectedKeyframeId,
} from "./use-keyframe-selection";
import type {
SelectedKeyframeRef,
ElementKeyframe,
} from "@/lib/animation/types";
import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
KEYFRAME_LANE_HEIGHT_PX,
KEYFRAME_DIAMOND_SIZE_PX,
} from "@/components/editor/panels/timeline/layout";
export function useKeyframeBoxSelect({
trackId,
elementId,
rows,
keyframes,
displayedStartTime,
zoomLevel,
elementLeft,
}: {
trackId: string;
elementId: string;
rows: ExpandedRow[];
keyframes: ElementKeyframe[];
displayedStartTime: number;
zoomLevel: number;
elementLeft: number;
}) {
const {
selectedKeyframes,
keyframeSelectionAnchor,
setKeyframeSelection,
clearKeyframeSelection,
} = useKeyframeSelection();
const containerRef = useRef<HTMLDivElement>(null);
const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]);
const keyframeEntries = useMemo(() => {
const entries: Array<{
id: string;
ref: SelectedKeyframeRef;
rowIndex: number;
offsetPx: number;
}> = [];
for (const kf of keyframes) {
const rowIndex = rows.findIndex(
(r) => r.propertyPath === kf.propertyPath,
);
if (rowIndex === -1) continue;
const ref: SelectedKeyframeRef = {
trackId,
elementId,
propertyPath: kf.propertyPath,
keyframeId: kf.id,
};
const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time,
zoomLevel,
});
entries.push({
id: getSelectedKeyframeId({ keyframe: ref }),
ref,
rowIndex,
offsetPx: kfLeft - elementLeft,
});
}
return entries;
}, [
keyframes,
rows,
trackId,
elementId,
displayedStartTime,
zoomLevel,
elementLeft,
]);
const idToRefMap = useMemo(() => {
const map = new Map<string, SelectedKeyframeRef>();
for (const entry of keyframeEntries) {
map.set(entry.id, entry.ref);
}
return map;
}, [keyframeEntries]);
const selectedIds = useMemo(
() => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
[selectedKeyframes],
);
const anchorId = useMemo(
() =>
keyframeSelectionAnchor
? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor })
: null,
[keyframeSelectionAnchor],
);
const resolveIntersections = useCallback(
({
startPos,
currentPos,
}: {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
}) => {
const container = containerRef.current;
if (!container) return [];
const containerRect = container.getBoundingClientRect();
const sx = startPos.x - containerRect.left;
const sy = startPos.y - containerRect.top;
const cx = currentPos.x - containerRect.left;
const cy = currentPos.y - containerRect.top;
const selLeft = Math.min(sx, cx);
const selTop = Math.min(sy, cy);
const selRight = Math.max(sx, cx);
const selBottom = Math.max(sy, cy);
const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2;
return keyframeEntries
.filter((entry) => {
const kfX = entry.offsetPx;
const kfY =
entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX +
KEYFRAME_LANE_HEIGHT_PX / 2;
return !(
kfX + halfHit < selLeft ||
kfX - halfHit > selRight ||
kfY + halfHit < selTop ||
kfY - halfHit > selBottom
);
})
.map((entry) => entry.id);
},
[keyframeEntries],
);
const onSelectionChange = useCallback(
({
intersectedIds,
isAdditive,
}: {
intersectedIds: string[];
initialSelectedIds: string[];
initialAnchorId: string | null;
isAdditive: boolean;
}) => {
const intersectedRefs = intersectedIds
.map((id) => idToRefMap.get(id))
.filter((ref): ref is SelectedKeyframeRef => ref != null);
if (isAdditive) {
setKeyframeSelection({
keyframes: [
...initialKeyframesRef.current,
...intersectedRefs,
],
});
} else {
setKeyframeSelection({ keyframes: intersectedRefs });
}
},
[idToRefMap, setKeyframeSelection],
);
const {
selectionBox,
handleMouseDown: boxSelectMouseDown,
isSelecting,
shouldIgnoreClick,
} = useBoxSelect<string>({
containerRef,
resolveIntersections,
selectedIds,
anchorId,
onSelectionChange,
});
const handleExpandedAreaMouseDown = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
initialKeyframesRef.current = selectedKeyframes;
boxSelectMouseDown(event);
},
[boxSelectMouseDown, selectedKeyframes],
);
const handleExpandedAreaClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
if (shouldIgnoreClick()) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
clearKeyframeSelection();
},
[shouldIgnoreClick, clearKeyframeSelection],
);
return {
containerRef,
selectionBox,
isBoxSelecting: isSelecting,
handleExpandedAreaMouseDown,
handleExpandedAreaClick,
};
}
@@ -1,229 +1,229 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
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];
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
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,
};
}
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
export 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];
const areAllKeyframesSelected = keyframes.every((keyframe) =>
isKeyframeSelected({ keyframe }),
);
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
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,
};
}