summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/selection-box.tsx58
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx2
-rw-r--r--apps/web/src/components/editor/timeline.tsx158
-rw-r--r--apps/web/src/hooks/use-selection-box.ts195
4 files changed, 302 insertions, 111 deletions
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 00000000..5c452f7c
--- /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<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)",
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx
index 1cd22c74..ee9445f3 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 53a7aacd..b250bda1 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<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
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() {
<div
className="flex-1 relative overflow-hidden h-4"
onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
>
@@ -1016,8 +945,16 @@ export function Timeline() {
<div
className="flex-1 relative overflow-hidden"
onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
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}>
<div
className="relative flex-1"
@@ -1025,7 +962,6 @@ export function Timeline() {
height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
width: `${dynamicTimelineWidth}px`,
}}
- onMouseDown={handleTimelineMouseDown}
>
{tracks.length === 0 ? (
<div></div>
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 00000000..4caecf17
--- /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<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,
+ };
+}