Files
OpenCut/apps/web/src/hooks/timeline/use-timeline-interactions.ts
T
2025-12-25 21:15:36 +01:00

150 lines
3.9 KiB
TypeScript

import { useCallback, useRef } from "react";
import { useProjectStore } from "@/stores/project-store";
import type { RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
interface UseTimelineInteractionsProps {
playheadRef: RefObject<HTMLDivElement>;
tracksContainerRef: RefObject<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
tracksScrollRef: RefObject<HTMLDivElement>;
zoomLevel: number;
duration: number;
isSelecting: boolean;
justFinishedSelecting: boolean;
clearSelectedElements: () => void;
seek: (time: number) => void;
}
export function useTimelineInteractions({
playheadRef,
tracksContainerRef,
rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration,
isSelecting,
justFinishedSelecting,
clearSelectedElements,
seek,
}: UseTimelineInteractionsProps) {
const { activeProject } = useProjectStore();
const mouseTrackingRef = useRef({
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
});
const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const isTimelineBackground =
!target.closest(".timeline-element") &&
!playheadRef.current?.contains(target) &&
!target.closest("[data-track-labels]");
if (isTimelineBackground) {
mouseTrackingRef.current = {
isMouseDown: true,
downX: e.clientX,
downY: e.clientY,
downTime: e.timeStamp,
};
}
},
[playheadRef],
);
const shouldProcessTimelineClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
if (!isMouseDown) return false;
const deltaX = Math.abs(e.clientX - downX);
const deltaY = Math.abs(e.clientY - downY);
const deltaTime = e.timeStamp - downTime;
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
if (isSelecting || justFinishedSelecting) return false;
if (target.closest(".timeline-element")) return false;
if (playheadRef.current?.contains(target)) return false;
if (target.closest("[data-track-labels]")) {
clearSelectedElements();
return false;
}
return true;
},
[isSelecting, justFinishedSelecting, clearSelectedElements, playheadRef],
);
const handleTimelineSeek = useCallback(
(e: React.MouseEvent) => {
const isRulerClick = (e.target as HTMLElement).closest(
"[data-ruler-area]",
);
const scrollContainer = isRulerClick
? rulerScrollRef.current
: tracksScrollRef.current;
if (!scrollContainer) return;
const rect = scrollContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
const projectFps = activeProject?.fps || 30;
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
seek(time);
},
[
duration,
zoomLevel,
seek,
rulerScrollRef,
tracksScrollRef,
activeProject?.fps,
],
);
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
mouseTrackingRef.current = {
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
};
if (shouldProcessTimelineClick(e)) {
clearSelectedElements();
handleTimelineSeek(e);
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
return {
handleTimelineMouseDown,
handleTimelineContentClick,
};
}