feat: selection box

This commit is contained in:
Maze Winther
2025-07-11 01:37:42 +02:00
parent d643a9a277
commit 6c19dbb6bb
4 changed files with 302 additions and 111 deletions

View File

@ -0,0 +1,58 @@
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}

View File

@ -339,6 +339,8 @@ export function TimelineElement({
left: `${elementLeft}px`, left: `${elementLeft}px`,
width: `${elementWidth}px`, width: `${elementWidth}px`,
}} }}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined} onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined} onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined} onMouseLeave={resizing ? handleResizeEnd : undefined}

View File

@ -48,6 +48,8 @@ import {
TimelinePlayhead, TimelinePlayhead,
useTimelinePlayheadRuler, useTimelinePlayheadRuler,
} from "./timeline-playhead"; } from "./timeline-playhead";
import { SelectionBox } from "./selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import type { DragData, TimelineTrack } from "@/types/timeline"; import type { DragData, TimelineTrack } from "@/types/timeline";
import { import {
getTrackHeight, getTrackHeight,
@ -103,15 +105,7 @@ export function Timeline() {
isInTimeline, isInTimeline,
}); });
// Marquee selection state // Old marquee selection removed - using new SelectionBox component instead
const [marquee, setMarquee] = useState<{
startX: number;
startY: number;
endX: number;
endY: number;
active: boolean;
additive: boolean;
} | null>(null);
// Dynamic timeline width calculation based on playhead position and duration // Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max( const dynamicTimelineWidth = Math.max(
@ -141,9 +135,40 @@ export function Timeline() {
playheadRef, playheadRef,
}); });
// Selection box functionality
const tracksContainerRef = useRef<HTMLDivElement>(null);
const {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
isSelecting,
justFinishedSelecting,
} = useSelectionBox({
containerRef: tracksContainerRef,
playheadRef,
onSelectionComplete: (elements) => {
console.log(JSON.stringify({ onSelectionComplete: elements.length }));
setSelectedElements(elements);
},
});
// Timeline content click to seek handler // Timeline content click to seek handler
const handleTimelineContentClick = useCallback( const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
console.log(
JSON.stringify({
timelineClick: {
isSelecting,
justFinishedSelecting,
willReturn: isSelecting || justFinishedSelecting,
},
})
);
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
return;
}
// Don't seek if clicking on timeline elements, but still deselect // Don't seek if clicking on timeline elements, but still deselect
if ((e.target as HTMLElement).closest(".timeline-element")) { if ((e.target as HTMLElement).closest(".timeline-element")) {
return; return;
@ -161,6 +186,7 @@ export function Timeline() {
} }
// Clear selected elements when clicking empty timeline area // Clear selected elements when clicking empty timeline area
console.log(JSON.stringify({ clearingSelectedElements: true }));
clearSelectedElements(); clearSelectedElements();
// Determine if we're clicking in ruler or tracks area // Determine if we're clicking in ruler or tracks area
@ -209,6 +235,8 @@ export function Timeline() {
rulerScrollRef, rulerScrollRef,
tracksScrollRef, tracksScrollRef,
clearSelectedElements, clearSelectedElements,
isSelecting,
justFinishedSelecting,
] ]
); );
@ -283,107 +311,7 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [redo]); }, [redo]);
// Mouse down on timeline background to start marquee // Old marquee system removed - using new SelectionBox component instead
const handleTimelineMouseDown = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) {
setMarquee({
startX: e.clientX,
startY: e.clientY,
endX: e.clientX,
endY: e.clientY,
active: true,
additive: e.metaKey || e.ctrlKey || e.shiftKey,
});
}
};
// Mouse move to update marquee
useEffect(() => {
if (!marquee || !marquee.active) return;
const handleMouseMove = (e: MouseEvent) => {
setMarquee(
(prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }
);
};
const handleMouseUp = (e: MouseEvent) => {
setMarquee(
(prev) =>
prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }
);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [marquee]);
// On marquee end, select elements in box
useEffect(() => {
if (!marquee || marquee.active) return;
const timeline = timelineRef.current;
if (!timeline) return;
const rect = timeline.getBoundingClientRect();
const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
// Validation: skip if too small
if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
setMarquee(null);
return;
}
// Clamp to timeline bounds
const clamp = (val: number, min: number, max: number) =>
Math.max(min, Math.min(max, val));
const bx1 = clamp(x1, 0, rect.width);
const bx2 = clamp(x2, 0, rect.width);
const by1 = clamp(y1, 0, rect.height);
const by2 = clamp(y2, 0, rect.height);
let newSelection: { trackId: string; elementId: string }[] = [];
tracks.forEach((track, trackIdx) => {
track.elements.forEach((element) => {
const clipLeft =
element.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const clipTop = getCumulativeHeightBefore(tracks, trackIdx);
const clipBottom = clipTop + getTrackHeight(track.type);
const clipRight = clipLeft + getTrackHeight(track.type);
if (
bx1 < clipRight &&
bx2 > clipLeft &&
by1 < clipBottom &&
by2 > clipTop
) {
newSelection.push({ trackId: track.id, elementId: element.id });
}
});
});
if (newSelection.length > 0) {
if (marquee.additive) {
const selectedSet = new Set(
selectedElements.map((c) => c.trackId + ":" + c.elementId)
);
newSelection = [
...selectedElements,
...newSelection.filter(
(c) => !selectedSet.has(c.trackId + ":" + c.elementId)
),
];
}
setSelectedElements(newSelection);
} else if (!marquee.additive) {
clearSelectedElements();
}
setMarquee(null);
}, [
marquee,
tracks,
zoomLevel,
selectedElements,
setSelectedElements,
clearSelectedElements,
]);
const handleDragEnter = (e: React.DragEvent) => { const handleDragEnter = (e: React.DragEvent) => {
// When something is dragged over the timeline, show overlay // When something is dragged over the timeline, show overlay
@ -898,6 +826,7 @@ export function Timeline() {
<div <div
className="flex-1 relative overflow-hidden h-4" className="flex-1 relative overflow-hidden h-4"
onWheel={handleWheel} onWheel={handleWheel}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick} onClick={handleTimelineContentClick}
data-ruler-area data-ruler-area
> >
@ -1016,8 +945,16 @@ export function Timeline() {
<div <div
className="flex-1 relative overflow-hidden" className="flex-1 relative overflow-hidden"
onWheel={handleWheel} onWheel={handleWheel}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick} onClick={handleTimelineContentClick}
ref={tracksContainerRef}
> >
<SelectionBox
startPos={selectionBox?.startPos || null}
currentPos={selectionBox?.currentPos || null}
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<ScrollArea className="w-full h-full" ref={tracksScrollRef}> <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div <div
className="relative flex-1" className="relative flex-1"
@ -1025,7 +962,6 @@ export function Timeline() {
height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
width: `${dynamicTimelineWidth}px`, width: `${dynamicTimelineWidth}px`,
}} }}
onMouseDown={handleTimelineMouseDown}
> >
{tracks.length === 0 ? ( {tracks.length === 0 ? (
<div></div> <div></div>

View File

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback } from "react";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
playheadRef?: React.RefObject<HTMLElement>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[]
) => void;
isEnabled?: boolean;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
}
export function useSelectionBox({
containerRef,
playheadRef,
onSelectionComplete,
isEnabled = true,
}: UseSelectionBoxProps) {
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null
);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Mouse down handler to start selection
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isEnabled) return;
// Only start selection on empty space clicks
if ((e.target as HTMLElement).closest(".timeline-element")) {
return;
}
if (playheadRef?.current?.contains(e.target as Node)) {
return;
}
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
return;
}
setSelectionBox({
startPos: { x: e.clientX, y: e.clientY },
currentPos: { x: e.clientX, y: e.clientY },
isActive: false, // Will become active when mouse moves
});
},
[isEnabled, playheadRef]
);
// Function to select elements within the selection box
const selectElementsInBox = useCallback(
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
if (!containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate selection rectangle in container coordinates
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const endX = endPos.x - containerRect.left;
const endY = endPos.y - containerRect.top;
const selectionRect = {
left: Math.min(startX, endX),
top: Math.min(startY, endY),
right: Math.max(startX, endX),
bottom: Math.max(startY, endY),
};
// Find all timeline elements within the selection rectangle
const timelineElements = container.querySelectorAll(".timeline-element");
const selectedElements: { trackId: string; elementId: string }[] = [];
timelineElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
// Use absolute coordinates for more accurate intersection detection
const elementAbsolute = {
left: elementRect.left,
top: elementRect.top,
right: elementRect.right,
bottom: elementRect.bottom,
};
const selectionAbsolute = {
left: startPos.x,
top: startPos.y,
right: endPos.x,
bottom: endPos.y,
};
// Normalize selection rectangle (handle dragging in any direction)
const normalizedSelection = {
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
};
const elementId = element.getAttribute("data-element-id");
const trackId = element.getAttribute("data-track-id");
// Check if element intersects with selection rectangle (any overlap)
// Using absolute coordinates for more precise detection
const intersects = !(
elementAbsolute.right < normalizedSelection.left ||
elementAbsolute.left > normalizedSelection.right ||
elementAbsolute.bottom < normalizedSelection.top ||
elementAbsolute.top > normalizedSelection.bottom
);
if (intersects && elementId && trackId) {
selectedElements.push({ trackId, elementId });
}
});
// Always call the callback - with elements or empty array to clear selection
console.log(
JSON.stringify({ selectElementsInBox: selectedElements.length })
);
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete]
);
// Effect to track selection box movement
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
// Start selection if mouse moved more than 5px
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: e.clientX, y: e.clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
// Real-time visual feedback: update selection as we drag
if (newSelectionBox.isActive) {
selectElementsInBox(
newSelectionBox.startPos,
newSelectionBox.currentPos
);
}
};
const handleMouseUp = () => {
console.log(
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
);
// If we had an active selection, mark that we just finished selecting
if (selectionBox?.isActive) {
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
setJustFinishedSelecting(true);
// Clear the flag after a short delay to allow click events to check it
setTimeout(() => {
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
setJustFinishedSelecting(false);
}, 50);
}
// Don't call selectElementsInBox again - real-time selection already handled it
// Just clean up the selection box visual
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
justFinishedSelecting,
};
}