summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/element
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/timeline/element')
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts760
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts137
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts222
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts314
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
5 files changed, 0 insertions, 1662 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
deleted file mode 100644
index 5458efe7..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ /dev/null
@@ -1,760 +0,0 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
- type RefObject,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import {
- buildMoveGroup,
- resolveGroupMove,
- snapGroupEdges,
- type GroupMoveResult,
- type MoveGroup,
-} from "@/lib/timeline/group-move";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { roundToFrame } from "opencut-wasm";
-import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
-import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import { generateUUID } from "@/utils/id";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { registerCanceller } from "@/lib/cancel-interaction";
-import type {
- DropTarget,
- ElementRef,
- ElementDragState,
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/lib/timeline";
-
-interface UseElementInteractionProps {
- zoomLevel: number;
- timelineRef: RefObject<HTMLDivElement | null>;
- tracksContainerRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- snappingEnabled: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}
-
-const MOUSE_BUTTON_RIGHT = 2;
-
-const initialDragState: ElementDragState = {
- isDragging: false,
- elementId: null,
- dragElementIds: [],
- dragTimeOffsets: {},
- trackId: null,
- startMouseX: 0,
- startMouseY: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- currentMouseY: 0,
-};
-
-interface PendingDragState {
- elementId: string;
- trackId: string;
- selectedElements: ElementRef[];
- startMouseX: number;
- startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
-}
-
-function getClickOffsetTime({
- clientX,
- elementRect,
- zoomLevel,
-}: {
- clientX: number;
- elementRect: DOMRect;
- zoomLevel: number;
-}): number {
- const clickOffsetX = clientX - elementRect.left;
- const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- return Math.round(seconds * TICKS_PER_SECOND);
-}
-
-function getVerticalDragDirection({
- startMouseY,
- currentMouseY,
-}: {
- startMouseY: number;
- currentMouseY: number;
-}): "up" | "down" | null {
- if (currentMouseY < startMouseY) return "up";
- if (currentMouseY > startMouseY) return "down";
- return null;
-}
-
-function getDragDropTarget({
- clientX,
- clientY,
- elementId,
- trackId,
- tracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime,
- verticalDragDirection,
-}: {
- clientX: number;
- clientY: number;
- elementId: string;
- trackId: string;
- tracks: SceneTracks;
- tracksContainerRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- zoomLevel: number;
- snappedTime: number;
- verticalDragDirection?: "up" | "down" | null;
-}): DropTarget | null {
- const containerRect = tracksContainerRef.current?.getBoundingClientRect();
- const scrollContainer = tracksScrollRef.current;
- if (!containerRect || !scrollContainer) return null;
-
- const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
- ({ id }) => id === trackId,
- );
- const movingElement = sourceTrack?.elements.find(
- ({ id }) => id === elementId,
- );
- if (!movingElement) return null;
-
- const elementDuration = movingElement.duration;
- const scrollLeft = scrollContainer.scrollLeft;
- const scrollTop = scrollContainer.scrollTop;
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
- const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseX = clientX - scrollContainerRect.left + scrollLeft;
- const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
-
- return computeDropTarget({
- elementType: movingElement.type,
- mouseX,
- mouseY,
- tracks,
- playheadTime: snappedTime,
- isExternalDrop: false,
- elementDuration,
- pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
- zoomLevel,
- startTimeOverride: snappedTime,
- excludeElementId: movingElement.id,
- verticalDragDirection,
- });
-}
-
-interface StartDragParams
- extends Omit<
- ElementDragState,
- "isDragging" | "currentTime" | "currentMouseY"
- > {
- initialCurrentTime: number;
- initialCurrentMouseY: number;
-}
-
-export function useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- snappingEnabled,
- onSnapPointChange,
-}: UseElementInteractionProps) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const {
- selectedElements,
- isElementSelected,
- selectElement,
- handleElementClick: handleSelectionClick,
- } = useElementSelection();
-
- const [dragState, setDragState] =
- useState<ElementDragState>(initialDragState);
- const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
- const pendingDragRef = useRef<PendingDragState | null>(null);
- const moveGroupRef = useRef<MoveGroup | null>(null);
- const newTrackIdsRef = useRef<string[]>([]);
- const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
- const lastMouseXRef = useRef(0);
- const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
-
- const startDrag = useCallback(
- ({
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- initialCurrentTime,
- initialCurrentMouseY,
- }: StartDragParams) => {
- setDragState({
- isDragging: true,
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- currentTime: initialCurrentTime,
- currentMouseY: initialCurrentMouseY,
- });
- },
- [],
- );
-
- const endDrag = useCallback(() => {
- moveGroupRef.current = null;
- newTrackIdsRef.current = [];
- groupMoveResultRef.current = null;
- setDragState(initialDragState);
- setDragDropTarget(null);
- }, []);
-
- const cancelCurrentDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownLocationRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- onSnapPointChange?.(null);
- }, [endDrag, onSnapPointChange]);
-
- const resolveGroupDragMove = useCallback(
- ({
- group,
- snappedTime,
- dropTarget,
- }: {
- group: MoveGroup;
- snappedTime: number;
- dropTarget: DropTarget | null;
- }): GroupMoveResult | null => {
- if (!dropTarget) {
- return null;
- }
-
- if (dropTarget.isNewTrack) {
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
- }
-
- const orderedTracks = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ];
- const targetTrack = orderedTracks[dropTarget.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const existingTrackResult = resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "existingTrack",
- anchorTargetTrackId: targetTrack.id,
- },
- });
- if (existingTrackResult) {
- return existingTrackResult;
- }
-
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
- },
- [sceneTracks],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- return registerCanceller({ fn: cancelCurrentDrag });
- }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
-
- const getDragSnapResult = useCallback(
- ({
- frameSnappedTime,
- group,
- }: {
- frameSnappedTime: number;
- group: MoveGroup | null;
- }) => {
- if (!group || !snappingEnabled || isShiftHeldRef.current) {
- return { snappedTime: frameSnappedTime, snapPoint: null };
- }
-
- const groupSnap = snapGroupEdges({
- group,
- anchorStartTime: frameSnappedTime,
- tracks: sceneTracks,
- playheadTime: editor.playback.getCurrentTime(),
- zoomLevel,
- });
-
- return {
- snappedTime: groupSnap.snappedAnchorStartTime,
- snapPoint: groupSnap.snapPoint,
- };
- },
- [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
- let startedDragThisEvent = false;
- const timeline = timelineRef.current;
- const scrollContainer = tracksScrollRef.current;
- if (!timeline || !scrollContainer) return;
- lastMouseXRef.current = clientX;
-
- if (isPendingDrag && pendingDragRef.current) {
- const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
- if (
- deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
- deltaY > TIMELINE_DRAG_THRESHOLD_PX
- ) {
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const adjustedTime = Math.max(
- 0,
- mouseTime - pendingDragRef.current.clickOffsetTime,
- );
- const snappedTime =
- roundToFrame({
- time: adjustedTime,
- rate: activeProject.settings.fps,
- }) ?? adjustedTime;
- const moveGroup = buildMoveGroup({
- anchorRef: {
- trackId: pendingDragRef.current.trackId,
- elementId: pendingDragRef.current.elementId,
- },
- selectedElements: pendingDragRef.current.selectedElements,
- tracks: sceneTracks,
- });
- if (!moveGroup) {
- return;
- }
-
- moveGroupRef.current = moveGroup;
- newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
- const dragTimeOffsets: Record<string, number> = {};
- for (const member of moveGroup.members) {
- dragTimeOffsets[member.elementId] = member.timeOffset;
- }
- const {
- snappedTime: initialSnappedTime,
- snapPoint: initialSnapPoint,
- } = getDragSnapResult({
- frameSnappedTime: snappedTime,
- group: moveGroup,
- });
- const verticalDragDirection = getVerticalDragDirection({
- startMouseY: pendingDragRef.current.startMouseY,
- currentMouseY: clientY,
- });
- const anchorDropTarget = getDragDropTarget({
- clientX,
- clientY,
- elementId: pendingDragRef.current.elementId,
- trackId: pendingDragRef.current.trackId,
- tracks: sceneTracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime: initialSnappedTime,
- verticalDragDirection,
- });
- const nextGroupMoveResult =
- anchorDropTarget != null
- ? resolveGroupDragMove({
- group: moveGroup,
- snappedTime: initialSnappedTime,
- dropTarget: anchorDropTarget,
- })
- : null;
- groupMoveResultRef.current = nextGroupMoveResult;
- setDragDropTarget(
- anchorDropTarget &&
- (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
- ? {
- ...anchorDropTarget,
- isNewTrack: true,
- }
- : null,
- );
- startDrag({
- ...pendingDragRef.current,
- dragElementIds: moveGroup.members.map((member) => member.elementId),
- dragTimeOffsets,
- initialCurrentTime: initialSnappedTime,
- initialCurrentMouseY: clientY,
- });
- onSnapPointChange?.(initialSnapPoint);
- startedDragThisEvent = true;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- } else {
- return;
- }
- }
-
- if (startedDragThisEvent) {
- return;
- }
-
- if (dragState.elementId && dragState.trackId) {
- const alreadySelected = isElementSelected({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- if (!alreadySelected) {
- selectElement({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- }
- }
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const fps = activeProject.settings.fps;
- const frameSnappedTime =
- roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
-
- const moveGroup = moveGroupRef.current;
- const { snappedTime, snapPoint } = getDragSnapResult({
- frameSnappedTime,
- group: moveGroup,
- });
- setDragState((previousDragState) => ({
- ...previousDragState,
- currentTime: snappedTime,
- currentMouseY: clientY,
- }));
- onSnapPointChange?.(snapPoint);
-
- if (dragState.elementId && dragState.trackId) {
- const verticalDragDirection = getVerticalDragDirection({
- startMouseY: dragState.startMouseY,
- currentMouseY: clientY,
- });
- const anchorDropTarget = getDragDropTarget({
- clientX,
- clientY,
- elementId: dragState.elementId,
- trackId: dragState.trackId,
- tracks: sceneTracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime,
- verticalDragDirection,
- });
- const nextGroupMoveResult =
- moveGroup && anchorDropTarget
- ? resolveGroupDragMove({
- group: moveGroup,
- snappedTime,
- dropTarget: anchorDropTarget,
- })
- : null;
- groupMoveResultRef.current = nextGroupMoveResult;
- setDragDropTarget(
- anchorDropTarget &&
- (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
- ? {
- ...anchorDropTarget,
- isNewTrack: true,
- }
- : null,
- );
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.startMouseY,
- dragState.trackId,
- zoomLevel,
- isElementSelected,
- selectElement,
- editor.project,
- timelineRef,
- tracksScrollRef,
- tracksContainerRef,
- headerRef,
- isPendingDrag,
- startDrag,
- getDragSnapResult,
- resolveGroupDragMove,
- sceneTracks,
- onSnapPointChange,
- ]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- if (mouseDownLocationRef.current) {
- const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
- const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
- if (
- deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
- deltaY <= TIMELINE_DRAG_THRESHOLD_PX
- ) {
- mouseDownLocationRef.current = null;
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
- }
-
- const moveGroup = moveGroupRef.current;
- if (!moveGroup) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const groupMoveResult = groupMoveResultRef.current;
- if (!groupMoveResult) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const didMove = groupMoveResult.moves.some((move) => {
- const currentMember = moveGroup.members.find(
- (member) => member.elementId === move.elementId,
- );
- const originalStartTime =
- dragState.startElementTime + (currentMember?.timeOffset ?? 0);
- return (
- currentMember?.trackId !== move.targetTrackId ||
- originalStartTime !== move.newStartTime
- );
- });
- if (!didMove && groupMoveResult.createTracks.length === 0) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- editor.timeline.moveElements({
- moves: groupMoveResult.moves,
- createTracks: groupMoveResult.createTracks,
- });
- endDrag();
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.elementId,
- dragState.startElementTime,
- dragState.trackId,
- endDrag,
- onSnapPointChange,
- editor.timeline,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag, onSnapPointChange]);
-
- const handleElementMouseDown = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
-
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: false,
- });
- }
- return;
- }
-
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
-
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
-
- if (isMultiSelect) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: true,
- });
- }
-
- const clickOffsetTime = getClickOffsetTime({
- clientX: event.clientX,
- elementRect: event.currentTarget.getBoundingClientRect(),
- zoomLevel,
- });
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const pendingSelectedElements = isElementSelected(elementRef)
- ? selectedElements
- : [elementRef];
- pendingDragRef.current = {
- elementId: element.id,
- trackId: track.id,
- selectedElements: pendingSelectedElements,
- startMouseX: event.clientX,
- startMouseY: event.clientY,
- startElementTime: element.startTime,
- clickOffsetTime,
- };
- setIsPendingDrag(true);
- },
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
-
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
-
- if (mouseDownLocationRef.current) {
- const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
- const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
- if (
- deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
- deltaY > TIMELINE_DRAG_THRESHOLD_PX
- ) {
- mouseDownLocationRef.current = null;
- return;
- }
- }
-
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
-
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
-
- editor.selection.clearKeyframeSelection();
- },
- [editor.selection, isElementSelected, selectElement, selectedElements],
- );
-
- return {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
deleted file mode 100644
index 42187b7f..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useCallback } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export function useElementSelection() {
- const editor = useEditor();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
-
- const isElementSelected = useCallback(
- ({ trackId, elementId }: ElementRef) =>
- selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- ),
- [selectedElements],
- );
-
- const selectElement = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: [{ trackId, elementId }],
- });
- },
- [editor],
- );
-
- const addElementToSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
- if (alreadySelected) return;
-
- editor.selection.setSelectedElements({
- elements: [...selectedElements, { trackId, elementId }],
- });
- },
- [selectedElements, editor],
- );
-
- const removeElementFromSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: selectedElements.filter(
- (element) =>
- !(element.trackId === trackId && element.elementId === elementId),
- ),
- });
- },
- [selectedElements, editor],
- );
-
- const toggleElementSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
-
- if (alreadySelected) {
- removeElementFromSelection({ trackId, elementId });
- } else {
- addElementToSelection({ trackId, elementId });
- }
- },
- [selectedElements, addElementToSelection, removeElementFromSelection],
- );
-
- const clearElementSelection = useCallback(() => {
- editor.selection.clearSelection();
- }, [editor]);
-
- const setElementSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- editor.selection.setSelectedElements({ elements });
- },
- [editor],
- );
-
-
- /**
- * Merges elements into the current selection, deduplicating by identity.
- * Used for additive box-select where the pre-drag selection is preserved.
- */
- const mergeElementsIntoSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- const merged = [
- ...selectedElements.filter(
- (selectedElement) =>
- !elements.some(
- (element) =>
- element.trackId === selectedElement.trackId &&
- element.elementId === selectedElement.elementId,
- ),
- ),
- ...elements,
- ];
- editor.selection.setSelectedElements({ elements: merged });
- },
- [selectedElements, editor],
- );
-
-
- /**
- * Handles click interaction on an element.
- * - Regular click: select only this element
- * - Multi-key click (Ctrl/Cmd): toggle this element in selection
- */
- const handleElementClick = useCallback(
- ({
- trackId,
- elementId,
- isMultiKey,
- }: ElementRef & { isMultiKey: boolean }) => {
- if (isMultiKey) {
- toggleElementSelection({ trackId, elementId });
- } else {
- selectElement({ trackId, elementId });
- }
- },
- [toggleElementSelection, selectElement],
- );
-
- return {
- selectedElements,
- isElementSelected,
- selectElement,
- setElementSelection,
- mergeElementsIntoSelection,
- addElementToSelection,
- removeElementFromSelection,
- toggleElementSelection,
- clearElementSelection,
- handleElementClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
deleted file mode 100644
index 0fc46372..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-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,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
deleted file mode 100644
index 24bff26a..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { getKeyframeById } from "@/lib/animation";
-import { useKeyframeSelection } from "./use-keyframe-selection";
-import { roundToFrame, snappedSeekTime } from "opencut-wasm";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
-import { BatchCommand } from "@/lib/commands";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
-import type { Command } from "@/lib/commands/base-command";
-import { registerCanceller } from "@/lib/cancel-interaction";
-export interface KeyframeDragState {
- isDragging: boolean;
- draggingKeyframeIds: Set<string>;
- deltaTime: number;
-}
-
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
-
-interface PendingKeyframeDrag {
- keyframeRefs: SelectedKeyframeRef[];
- startMouseX: number;
-}
-
-export function useKeyframeDrag({
- zoomLevel,
- element,
- displayedStartTime,
-}: {
- zoomLevel: number;
- element: TimelineElement;
- displayedStartTime: number;
-}) {
- const editor = useEditor();
- const {
- selectedKeyframes,
- isKeyframeSelected,
- setKeyframeSelection,
- toggleKeyframeSelection,
- selectKeyframeRange,
- } = useKeyframeSelection();
-
- const [dragState, setDragState] =
- useState<KeyframeDragState>(initialDragState);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
-
- const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
- const mouseDownXRef = useRef<number | null>(null);
-
- const activeProject = editor.project.getActive();
- const fps = activeProject.settings.fps;
-
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-
- const endDrag = useCallback(() => {
- setDragState(initialDragState);
- }, []);
-
- const cancelDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownXRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- }, [endDrag]);
-
- const commitDrag = useCallback(
- ({
- keyframeRefs,
- deltaTime,
- }: {
- keyframeRefs: SelectedKeyframeRef[];
- deltaTime: number;
- }) => {
- const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
- const keyframe = getKeyframeById({
- animations: element.animations,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- });
- if (!keyframe) return [];
- const nextTime = Math.max(
- 0,
- Math.min(element.duration, keyframe.time + deltaTime),
- );
- return [
- new RetimeKeyframeCommand({
- trackId: keyframeRef.trackId,
- elementId: keyframeRef.elementId,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- nextTime,
- }),
- ];
- });
-
- if (commands.length === 1) {
- editor.command.execute({ command: commands[0] });
- } else if (commands.length > 1) {
- editor.command.execute({ command: new BatchCommand(commands) });
- }
- },
- [editor.command, element],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- return registerCanceller({ fn: cancelDrag });
- }, [dragState.isDragging, isPendingDrag, cancelDrag]);
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- if (isPendingDrag && pendingDragRef.current) {
- const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
-
- const pending = pendingDragRef.current;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- setDragState({
- isDragging: true,
- draggingKeyframeIds: new Set(
- pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
- ),
- deltaTime: 0,
- });
- return;
- }
-
- if (!dragState.isDragging) return;
-
- const startX = mouseDownXRef.current ?? clientX;
- const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
- const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
-
- setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = () => {
- const draggingRefs = selectedKeyframes.filter(
- (keyframe) =>
- keyframe.elementId === element.id &&
- dragState.draggingKeyframeIds.has(keyframe.keyframeId),
- );
-
- if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
- commitDrag({
- keyframeRefs: draggingRefs,
- deltaTime: dragState.deltaTime,
- });
- }
-
- endDrag();
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.draggingKeyframeIds,
- dragState.deltaTime,
- selectedKeyframes,
- element.id,
- commitDrag,
- endDrag,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag]);
-
- const handleKeyframeMouseDown = useCallback(
- ({
- event,
- keyframes,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => {
- event.preventDefault();
- event.stopPropagation();
-
- mouseDownXRef.current = event.clientX;
-
- const anySelected = keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
-
- const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
- if (!anySelected && !isModifierKey) {
- setKeyframeSelection({ keyframes });
- }
-
- const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
-
- pendingDragRef.current = {
- keyframeRefs: keyframeRefsToTrack,
- startMouseX: event.clientX,
- };
- setIsPendingDrag(true);
- },
- [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
-);
-
- const handleKeyframeClick = useCallback(
- ({
- event,
- keyframes,
- orderedKeyframes,
- indicatorTime,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => {
- event.stopPropagation();
-
- const wasDrag =
- mouseDownXRef.current !== null &&
- Math.abs(event.clientX - mouseDownXRef.current) >
- TIMELINE_DRAG_THRESHOLD_PX;
- mouseDownXRef.current = null;
-
- if (wasDrag) return;
-
- const duration = editor.timeline.getTotalDuration();
- const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
-
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
-
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps],
- );
-
- const getVisualOffsetPx = useCallback(
- ({
- indicatorTime,
- indicatorOffsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- }: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }): number => {
- if (!isBeingDragged) return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(element.duration, indicatorTime + dragState.deltaTime),
- );
- return (
- timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
- zoomLevel,
- }) - elementLeft
- );
- },
- [dragState.deltaTime, element.duration, zoomLevel],
- );
-
- return {
- keyframeDragState: dragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
deleted file mode 100644
index 2b5a0566..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-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,
- };
-}