mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: expandable keyframe lanes in timeline
Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user