diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx new file mode 100644 index 0000000..5c452f7 --- /dev/null +++ b/apps/web/src/components/editor/selection-box.tsx @@ -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; + isActive: boolean; +} + +export function SelectionBox({ + startPos, + currentPos, + containerRef, + isActive, +}: SelectionBoxProps) { + const selectionBoxRef = useRef(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 ( +
+ ); +} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 1cd22c7..ee9445f 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -339,6 +339,8 @@ export function TimelineElement({ left: `${elementLeft}px`, width: `${elementWidth}px`, }} + data-element-id={element.id} + data-track-id={track.id} onMouseMove={resizing ? handleResizeMove : undefined} onMouseUp={resizing ? handleResizeEnd : undefined} onMouseLeave={resizing ? handleResizeEnd : undefined} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 53a7aac..b250bda 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -48,6 +48,8 @@ import { TimelinePlayhead, useTimelinePlayheadRuler, } from "./timeline-playhead"; +import { SelectionBox } from "./selection-box"; +import { useSelectionBox } from "@/hooks/use-selection-box"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -103,15 +105,7 @@ export function Timeline() { isInTimeline, }); - // Marquee selection state - const [marquee, setMarquee] = useState<{ - startX: number; - startY: number; - endX: number; - endY: number; - active: boolean; - additive: boolean; - } | null>(null); + // Old marquee selection removed - using new SelectionBox component instead // Dynamic timeline width calculation based on playhead position and duration const dynamicTimelineWidth = Math.max( @@ -141,9 +135,40 @@ export function Timeline() { playheadRef, }); + // Selection box functionality + const tracksContainerRef = useRef(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 const handleTimelineContentClick = useCallback( (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 if ((e.target as HTMLElement).closest(".timeline-element")) { return; @@ -161,6 +186,7 @@ export function Timeline() { } // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); clearSelectedElements(); // Determine if we're clicking in ruler or tracks area @@ -209,6 +235,8 @@ export function Timeline() { rulerScrollRef, tracksScrollRef, clearSelectedElements, + isSelecting, + justFinishedSelecting, ] ); @@ -283,107 +311,7 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [redo]); - // Mouse down on timeline background to start marquee - 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, - ]); + // Old marquee system removed - using new SelectionBox component instead const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay @@ -898,6 +826,7 @@ export function Timeline() {
@@ -1016,8 +945,16 @@ export function Timeline() {
+
{tracks.length === 0 ? (
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts new file mode 100644 index 0000000..4caecf1 --- /dev/null +++ b/apps/web/src/hooks/use-selection-box.ts @@ -0,0 +1,195 @@ +import { useState, useEffect, useCallback } from "react"; + +interface UseSelectionBoxProps { + containerRef: React.RefObject; + playheadRef?: React.RefObject; + 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( + 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, + }; +}