OpenCut/legacy/timeline/use-selection-box.ts

266 lines
7.1 KiB
TypeScript

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<HTMLElement | null>;
headerRef?: React.RefObject<HTMLElement | null>;
onSelectionComplete: ({
elements,
isAdditive,
}: {
elements: { trackId: string; elementId: string }[];
isAdditive: boolean;
}) => void;
isEnabled?: boolean;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
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<SelectionBoxState | null>(
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,
};
}