import { useCallback, useEffect, useRef, useState } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; import { useEditor } from "../use-editor"; interface UseSelectionBoxProps { containerRef: React.RefObject; headerRef?: React.RefObject; onSelectionComplete: ({ elements, isAdditive, }: { elements: { trackId: string; elementId: string }[]; isAdditive: boolean; }) => void; isEnabled?: boolean; tracksScrollRef: React.RefObject; zoomLevel: number; } interface SelectionBoxState { startPos: { x: number; y: number }; currentPos: { x: number; y: number }; isActive: boolean; isAdditive: boolean; } interface SelectionRectangle { left: number; top: number; right: number; bottom: number; } function getNormalizedRectangle({ startPos, endPos, }: { startPos: { x: number; y: number }; endPos: { x: number; y: number }; }): SelectionRectangle { return { left: Math.min(startPos.x, endPos.x), top: Math.min(startPos.y, endPos.y), right: Math.max(startPos.x, endPos.x), bottom: Math.max(startPos.y, endPos.y), }; } function getSelectionRectangleInContent({ container, scrollContainer, startPos, endPos, }: { container: HTMLElement; scrollContainer: HTMLDivElement | null; startPos: { x: number; y: number }; endPos: { x: number; y: number }; }): SelectionRectangle { const containerRect = container.getBoundingClientRect(); const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; const scrollLeft = scrollContainer?.scrollLeft ?? 0; const scrollTop = scrollContainer?.scrollTop ?? 0; const adjustedStart = { x: startPos.x - containerRect.left + scrollLeft, y: startPos.y - scrollRect.top + scrollTop, }; const adjustedEnd = { x: endPos.x - containerRect.left + scrollLeft, y: endPos.y - scrollRect.top + scrollTop, }; return getNormalizedRectangle({ startPos: adjustedStart, endPos: adjustedEnd, }); } function isRectangleIntersecting({ elementRectangle, selectionRectangle, }: { elementRectangle: SelectionRectangle; selectionRectangle: SelectionRectangle; }): boolean { return !( elementRectangle.right < selectionRectangle.left || elementRectangle.left > selectionRectangle.right || elementRectangle.bottom < selectionRectangle.top || elementRectangle.top > selectionRectangle.bottom ); } export function useSelectionBox({ containerRef, headerRef, onSelectionComplete, isEnabled = true, tracksScrollRef, zoomLevel, }: UseSelectionBoxProps) { const editor = useEditor(); const [selectionBox, setSelectionBox] = useState( null, ); const justFinishedSelectingRef = useRef(false); const handleMouseDown = useCallback( ({ clientX, clientY, shiftKey, ctrlKey, metaKey }: React.MouseEvent) => { if (!isEnabled) return; setSelectionBox({ startPos: { x: clientX, y: clientY }, currentPos: { x: clientX, y: clientY }, isActive: false, isAdditive: shiftKey || ctrlKey || metaKey, }); }, [isEnabled], ); const selectElementsInBox = useCallback( ({ startPos, endPos, isAdditive, }: { startPos: { x: number; y: number }; endPos: { x: number; y: number }; isAdditive: boolean; }) => { if (!containerRef.current) return; const tracks = editor.timeline.getTracks(); const container = containerRef.current; const selectionRectangle = getSelectionRectangleInContent({ container, scrollContainer: tracksScrollRef.current, startPos, endPos, }); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const timelineHeaderHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; const selectedElements: { trackId: string; elementId: string }[] = []; for (const [trackIndex, track] of tracks.entries()) { const trackTop = getCumulativeHeightBefore({ tracks, trackIndex, }); const trackHeight = getTrackHeight({ type: track.type }); const elementTop = timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; const elementBottom = elementTop + trackHeight; for (const element of track.elements) { const elementLeft = element.startTime * pixelsPerSecond; const elementRight = elementLeft + element.duration * pixelsPerSecond; const elementRectangle = { left: elementLeft, top: elementTop, right: elementRight, bottom: elementBottom, }; const intersects = isRectangleIntersecting({ elementRectangle, selectionRectangle, }); if (intersects) { selectedElements.push({ trackId: track.id, elementId: element.id, }); } } } onSelectionComplete({ elements: selectedElements, isAdditive }); }, [ containerRef, headerRef, onSelectionComplete, editor, tracksScrollRef, zoomLevel, ], ); useEffect(() => { if (!selectionBox) return; const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { const deltaX = Math.abs(clientX - selectionBox.startPos.x); const deltaY = Math.abs(clientY - selectionBox.startPos.y); const shouldActivate = deltaX > 5 || deltaY > 5; const newSelectionBox = { ...selectionBox, currentPos: { x: clientX, y: clientY }, isActive: shouldActivate || selectionBox.isActive, }; setSelectionBox(newSelectionBox); if (newSelectionBox.isActive) { selectElementsInBox({ startPos: newSelectionBox.startPos, endPos: newSelectionBox.currentPos, isAdditive: newSelectionBox.isAdditive, }); } }; const handleMouseUp = () => { if (selectionBox?.isActive) { justFinishedSelectingRef.current = true; requestAnimationFrame(() => { justFinishedSelectingRef.current = false; }); } setSelectionBox(null); }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [selectionBox, selectElementsInBox]); useEffect(() => { if (!selectionBox) return; const previousBodyUserSelect = document.body.style.userSelect; const container = containerRef.current; const previousContainerUserSelect = container?.style.userSelect ?? ""; document.body.style.userSelect = "none"; if (container) container.style.userSelect = "none"; return () => { document.body.style.userSelect = previousBodyUserSelect; if (container) container.style.userSelect = previousContainerUserSelect; }; }, [selectionBox, containerRef]); const shouldIgnoreClick = useCallback(() => { return justFinishedSelectingRef.current; }, []); return { selectionBox, handleMouseDown, isSelecting: selectionBox?.isActive || false, shouldIgnoreClick, }; }