266 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|