summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts')
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts217
1 files changed, 217 insertions, 0 deletions
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
new file mode 100644
index 00000000..e471e2d4
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
@@ -0,0 +1,217 @@
+import { useCallback, useRef, useMemo } from "react";
+import { useBoxSelect } from "@/selection/hooks/use-box-select";
+import {
+ useKeyframeSelection,
+ getSelectedKeyframeId,
+} from "./use-keyframe-selection";
+import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types";
+import type { ExpandedRow } from "@/timeline/components/expanded-layout";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import {
+ KEYFRAME_LANE_HEIGHT_PX,
+ KEYFRAME_DIAMOND_SIZE_PX,
+} from "@/timeline/components/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,
+ };
+}