diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 901c652b5..5fb656bcb 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -98,8 +98,11 @@ background: color-mix(in srgb, var(--accent) 88%, white); } +/* An inset ring keeps the selection visible on zooms only a few pixels wide. */ .glassBlue.selected { + background: color-mix(in srgb, var(--accent) 82%, white); border-color: color-mix(in srgb, var(--accent) 60%, white); + box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.9); z-index: 10; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 47252a86d..8e86be8a1 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,4 +1,5 @@ -import { Plus } from "@/components/ui/icons"; +import { Button } from "@/components/ui/button"; +import { Plus, Trash as Trash2 } from "@/components/ui/icons"; import type { Span } from "dnd-timeline"; import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import type { @@ -336,6 +337,10 @@ const TimelineEditor = forwardRef( setSelectedKeyframeId, selectAllBlocksActive, setSelectAllBlocksActive, + selectedZoomIds, + setSelectedZoomIds, + selectZoomIds, + deleteSelectedZoom, handleKeyframeMove, clearSelectedBlocks, handleSelectZoom, @@ -403,6 +408,11 @@ const TimelineEditor = forwardRef( isTimelineFocusedRef, }); + const zoomIds = new Set(zoomRegions.map((region) => region.id)); + const pickedZoomCount = selectAllBlocksActive + ? zoomRegions.length + : selectedZoomIds.filter((id) => zoomIds.has(id)).length; + if (!videoDuration || videoDuration === 0) { return (
@@ -420,7 +430,28 @@ const TimelineEditor = forwardRef( } return ( -
+
+ {(selectAllBlocksActive || pickedZoomCount > 1) && pickedZoomCount > 0 && ( +
+ + {t("timeline.zoomsSelected", "{{count}} zooms selected", { + count: String(pickedZoomCount), + })} + + +
+ )}
( onClick={() => { setSelectedKeyframeId(null); setSelectAllBlocksActive(false); + setSelectedZoomIds([]); }} onWheel={handleTimelineWheel} > @@ -506,6 +538,8 @@ const TimelineEditor = forwardRef( selectedAudioId={selectedAudioId} selectedCaptionId={selectedCaptionId} selectAllBlocksActive={selectAllBlocksActive} + selectedZoomIds={selectedZoomIds} + onSelectZoomIds={selectZoomIds} onClearBlockSelection={clearSelectedBlocks} keyframes={keyframes} sourceAudioTracks={sourceAudioTracks} diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index 824cd3b68..254fb82f8 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -31,6 +31,7 @@ import { isAudioTrackRowId, } from "../../core/rows"; import type { TimelineRenderItem } from "../../core/timelineTypes"; +import { getZoomIdsInRange } from "../../hooks/utils/timelineSelectionUtils"; import { DEFAULT_CAPTION_DURATION_MS } from "../../hooks/actions/useTimelineCaptionActions"; import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks"; import Item from "../../Item"; @@ -54,6 +55,9 @@ const HINT_CLIP = "Press C to split clip"; const HINT_ANNOTATION = "Press A to add annotation"; const HINT_AUDIO = "Click music icon to add audio"; +/** How far the pointer moves before a press on the zoom row becomes a selection box. */ +const SELECTION_BOX_THRESHOLD_PX = 4; + interface TimelineCanvasProps { videoPath?: string | null; items: TimelineRenderItem[]; @@ -78,6 +82,8 @@ interface TimelineCanvasProps { selectedAudioId?: string | null; selectedCaptionId?: string | null; selectAllBlocksActive?: boolean; + selectedZoomIds?: readonly string[]; + onSelectZoomIds?: (ids: string[]) => void; onClearBlockSelection?: () => void; keyframes?: { id: string; time: number }[]; sourceAudioTracks?: SourceAudioTrackWithPeaks[]; @@ -406,6 +412,8 @@ interface TimelineCanvasRowsProps { items: TimelineRenderItem[]; videoDurationMs: number; selectAllBlocksActive: boolean; + selectedZoomIds?: readonly string[]; + onSelectZoomIds?: (ids: string[]) => void; selectedZoomId: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; @@ -482,7 +490,10 @@ function AudioItemWithWaveform({ const TimelineCanvasRows = memo(function TimelineCanvasRows({ videoPath, items, + videoDurationMs, selectAllBlocksActive, + selectedZoomIds, + onSelectZoomIds, selectedZoomId, selectedClipId, selectedAnnotationId, @@ -590,6 +601,79 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ [clipItems, clips], ); + const [selectionBox, setSelectionBox] = useState<{ fromMs: number; toMs: number } | null>(null); + const visibleZoomItems = useMemo( + () => zoomItems.filter((item) => !hiddenIds.has(item.id)), + [zoomItems, hiddenIds], + ); + const pickedZoomIds = useMemo(() => new Set(selectedZoomIds ?? []), [selectedZoomIds]); + const boxedZoomIds = useMemo( + () => + selectionBox + ? new Set( + getZoomIdsInRange(visibleZoomItems, selectionBox.fromMs, selectionBox.toMs), + ) + : null, + [selectionBox, visibleZoomItems], + ); + + // Pressing and dragging on empty zoom-row space selects every zoom the box covers. + // A press without a drag still adds a zoom. + const handleZoomRowMouseDown = useCallback( + (event: MouseEvent) => { + onZoomRowMouseDown(event); + if (event.button !== 0 || !onSelectZoomIds) return; + if ((event.target as HTMLElement).closest("[data-timeline-item]")) return; + const rect = event.currentTarget.getBoundingClientRect(); + const msAt = (clientX: number) => { + const offset = direction === "rtl" ? rect.right - clientX : clientX - rect.left; + const clamped = Math.max(0, Math.min(offset, rect.width)); + return Math.max(0, Math.min(videoDurationMs, rangeStart + pixelsToValue(clamped))); + }; + const startX = event.clientX; + const fromMs = msAt(startX); + let dragging = false; + const handleMove = (moveEvent: globalThis.MouseEvent) => { + if ( + !dragging && + Math.abs(moveEvent.clientX - startX) < SELECTION_BOX_THRESHOLD_PX + ) { + return; + } + dragging = true; + moveEvent.preventDefault(); + window.getSelection()?.removeAllRanges(); + setSelectionBox({ fromMs, toMs: msAt(moveEvent.clientX) }); + }; + const handleUp = (upEvent: globalThis.MouseEvent) => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + if (!dragging) return; + setSelectionBox(null); + // The click that ends a drag must not add a zoom, seek or clear the selection. + const swallowClick = (clickEvent: globalThis.MouseEvent) => + clickEvent.stopPropagation(); + window.addEventListener("click", swallowClick, { capture: true, once: true }); + window.setTimeout( + () => window.removeEventListener("click", swallowClick, { capture: true }), + 0, + ); + onSelectZoomIds(getZoomIdsInRange(visibleZoomItems, fromMs, msAt(upEvent.clientX))); + }; + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [ + direction, + onSelectZoomIds, + onZoomRowMouseDown, + pixelsToValue, + rangeStart, + videoDurationMs, + visibleZoomItems, + ], + ); + const zoomGhost = ghostStartMs === null ? null @@ -793,10 +877,22 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ onMouseEnter={onZoomRowMouseEnter} onMouseMove={onZoomRowMouseMove} onMouseLeave={onZoomRowMouseLeave} - onMouseDown={onZoomRowMouseDown} + onMouseDown={handleZoomRowMouseDown} onClick={onZoomRowClick} > - {canShowGhostZoom && ghostStartMs !== null && ( + {selectionBox && ( +
+ )} + {canShowGhostZoom && !selectionBox && ghostStartMs !== null && (
)} - {zoomItems - .filter((item) => !hiddenIds.has(item.id)) - .map((item) => ( - - {item.label} - - ))} + {visibleZoomItems.map((item) => ( + + {item.label} + + ))} {annotationRows.map(({ rowId, items: rowItems }, index) => ( @@ -919,6 +1017,8 @@ export default function TimelineCanvas({ selectedAudioId, selectedCaptionId, selectAllBlocksActive = false, + selectedZoomIds, + onSelectZoomIds, onClearBlockSelection, keyframes = [], sourceAudioTracks = [], @@ -1186,6 +1286,8 @@ export default function TimelineCanvas({ items={items} videoDurationMs={videoDurationMs} selectAllBlocksActive={selectAllBlocksActive} + selectedZoomIds={selectedZoomIds} + onSelectZoomIds={onSelectZoomIds} selectedZoomId={selectedZoomId} selectedClipId={selectedClipId} selectedAnnotationId={selectedAnnotationId} diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 37fed7ca1..d52cd1e30 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -127,6 +127,9 @@ export function useTimelineEditorRuntime({ setSelectedKeyframeId, selectAllBlocksActive, setSelectAllBlocksActive, + selectedZoomIds, + setSelectedZoomIds, + selectZoomIds, hasAnyZoomBlocks, activateSelectAllZooms, addKeyframe, @@ -274,6 +277,8 @@ export function useTimelineEditorRuntime({ selectedAudioId, selectedCaptionId, selectAllBlocksActive, + selectedZoomIds, + clearSelectedBlocks, addKeyframe, handleAddZoom, handleSplitClip, @@ -313,6 +318,10 @@ export function useTimelineEditorRuntime({ setSelectedKeyframeId, selectAllBlocksActive, setSelectAllBlocksActive, + selectedZoomIds, + setSelectedZoomIds, + selectZoomIds, + deleteSelectedZoom, handleKeyframeMove, clearSelectedBlocks, handleSelectZoom, diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts index 2aec7d828..70b1e2119 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -17,6 +17,8 @@ interface UseTimelineKeyboardShortcutsParams { selectedAudioId?: string | null; selectedCaptionId?: string | null; selectAllBlocksActive: boolean; + selectedZoomIds?: readonly string[]; + clearSelectedBlocks?: () => void; addKeyframe: () => void; handleAddZoom: () => void; handleSplitClip: () => void; @@ -44,6 +46,8 @@ export function useTimelineKeyboardShortcuts({ selectedAudioId, selectedCaptionId, selectAllBlocksActive, + selectedZoomIds = [], + clearSelectedBlocks, addKeyframe, handleAddZoom, handleSplitClip, @@ -84,6 +88,7 @@ export function useTimelineKeyboardShortcuts({ ) { const target = resolveDeleteSelectionTarget({ selectAllBlocksActive, + selectedZoomIds, selectedKeyframeId, selectedZoomId, selectedClipId, @@ -110,6 +115,12 @@ export function useTimelineKeyboardShortcuts({ return; } + if (e.key === "Escape" && (selectAllBlocksActive || selectedZoomIds.length > 0)) { + e.preventDefault(); + clearSelectedBlocks?.(); + return; + } + if (!isTimelineFocusedRef.current) { return; } @@ -143,6 +154,7 @@ export function useTimelineKeyboardShortcuts({ activateSelectAllZooms, addKeyframe, annotationCount, + clearSelectedBlocks, cycleAnnotationsAtCurrentTime, deleteSelectedAnnotation, deleteSelectedAudio, @@ -158,6 +170,7 @@ export function useTimelineKeyboardShortcuts({ isTimelineFocusedRef, keyShortcuts, selectAllBlocksActive, + selectedZoomIds, selectedAnnotationId, selectedAudioId, selectedCaptionId, diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts index 59bcc7f9a..da914382c 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -49,6 +49,8 @@ export function useTimelineSelection({ const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); + // Zooms picked together with a selection box. One zoom selects normally instead. + const [selectedZoomIds, setSelectedZoomIds] = useState([]); const hasAnyZoomBlocks = useMemo(() => zoomRegions.length > 0, [zoomRegions.length]); const addKeyframe = useCallback(() => { @@ -76,8 +78,13 @@ export function useTimelineSelection({ ); const deleteSelectedZoom = useCallback(() => { + const existingIds = new Set(zoomRegions.map((region) => region.id)); + const pickedIds = selectedZoomIds.filter((id) => existingIds.has(id)); if (selectAllBlocksActive) { zoomRegions.map((region) => region.id).forEach((id) => onZoomDelete(id)); + } else if (pickedIds.length > 0) { + // Deleting in one handler batches into a single state change and undo step. + pickedIds.forEach((id) => onZoomDelete(id)); } else if (selectedZoomId) { onZoomDelete(selectedZoomId); } else { @@ -90,8 +97,10 @@ export function useTimelineSelection({ onSelectAudio?.(null); onSelectCaption?.(null); setSelectAllBlocksActive(false); + setSelectedZoomIds([]); }, [ selectAllBlocksActive, + selectedZoomIds, zoomRegions, onZoomDelete, selectedZoomId, @@ -133,6 +142,7 @@ export function useTimelineSelection({ onSelectAudio?.(null); onSelectCaption?.(null); setSelectAllBlocksActive(false); + setSelectedZoomIds([]); }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]); const activateSelectAllZooms = useCallback(() => { @@ -142,6 +152,7 @@ export function useTimelineSelection({ onSelectAudio?.(null); onSelectCaption?.(null); setSelectedKeyframeId(null); + setSelectedZoomIds([]); setSelectAllBlocksActive(true); }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]); @@ -149,15 +160,30 @@ export function useTimelineSelection({ (id: string | null) => { setSelectAllBlocksActive(false); setSelectedKeyframeId(null); + setSelectedZoomIds([]); onSelectZoom(id); }, [onSelectZoom], ); + const selectZoomIds = useCallback( + (ids: string[]) => { + if (ids.length === 1) { + handleSelectZoom(ids[0]); + return; + } + clearSelectedBlocks(); + setSelectedKeyframeId(null); + setSelectedZoomIds(ids); + }, + [clearSelectedBlocks, handleSelectZoom], + ); + const handleSelectClip = useCallback( (id: string | null) => { setSelectAllBlocksActive(false); setSelectedKeyframeId(null); + setSelectedZoomIds([]); onSelectClip?.(id); }, [onSelectClip], @@ -167,6 +193,7 @@ export function useTimelineSelection({ (id: string | null) => { setSelectAllBlocksActive(false); setSelectedKeyframeId(null); + setSelectedZoomIds([]); onSelectAnnotation?.(id); }, [onSelectAnnotation], @@ -176,6 +203,7 @@ export function useTimelineSelection({ (id: string | null) => { setSelectAllBlocksActive(false); setSelectedKeyframeId(null); + setSelectedZoomIds([]); onSelectAudio?.(id); }, [onSelectAudio], @@ -185,6 +213,7 @@ export function useTimelineSelection({ (id: string | null) => { setSelectAllBlocksActive(false); setSelectedKeyframeId(null); + setSelectedZoomIds([]); onSelectCaption?.(id); }, [onSelectCaption], @@ -220,6 +249,9 @@ export function useTimelineSelection({ setSelectedKeyframeId, selectAllBlocksActive, setSelectAllBlocksActive, + selectedZoomIds, + setSelectedZoomIds, + selectZoomIds, hasAnyZoomBlocks, activateSelectAllZooms, addKeyframe, diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts index c1e3289a5..79e414d05 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "vitest"; -import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils"; +import { getZoomIdsInRange, resolveDeleteSelectionTarget } from "./timelineSelectionUtils"; describe("timelineSelectionUtils", () => { + it("treats zooms picked with a selection box as a zoom deletion target", () => { + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedZoomIds: ["z-1", "z-2"], + selectedKeyframeId: "kf-1", + selectedZoomId: null, + selectedClipId: "c-1", + }), + ).toBe("zoom"); + }); + + it("picks the zooms a selection box overlaps in either drag direction", () => { + const zooms = [ + { id: "a", span: { start: 0, end: 1000 } }, + { id: "b", span: { start: 2000, end: 3000 } }, + { id: "c", span: { start: 4000, end: 5000 } }, + ]; + expect(getZoomIdsInRange(zooms, 900, 2100)).toEqual(["a", "b"]); + expect(getZoomIdsInRange(zooms, 4500, 1500)).toEqual(["b", "c"]); + expect(getZoomIdsInRange(zooms, 1000, 2000)).toEqual([]); + }); + it("treats zoom select-all as a zoom deletion target", () => { expect( resolveDeleteSelectionTarget({ diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts index 9e825635b..0b92f6d20 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts @@ -9,6 +9,7 @@ export type DeleteSelectionTarget = interface ResolveDeleteSelectionTargetParams { selectAllBlocksActive: boolean; + selectedZoomIds?: readonly string[]; selectedKeyframeId: string | null; selectedZoomId: string | null; selectedClipId?: string | null; @@ -19,6 +20,7 @@ interface ResolveDeleteSelectionTargetParams { export function resolveDeleteSelectionTarget({ selectAllBlocksActive, + selectedZoomIds = [], selectedKeyframeId, selectedZoomId, selectedClipId, @@ -26,7 +28,7 @@ export function resolveDeleteSelectionTarget({ selectedAudioId, selectedCaptionId, }: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget { - if (selectAllBlocksActive) return "zoom"; + if (selectAllBlocksActive || selectedZoomIds.length > 0) return "zoom"; if (selectedKeyframeId) return "keyframe"; if (selectedZoomId) return "zoom"; if (selectedClipId) return "clip"; @@ -35,3 +37,16 @@ export function resolveDeleteSelectionTarget({ if (selectedCaptionId) return "caption"; return "none"; } + +/** Ids of the zoom blocks that overlap the time range a selection box covers. */ +export function getZoomIdsInRange( + zooms: ReadonlyArray<{ id: string; span: { start: number; end: number } }>, + fromMs: number, + toMs: number, +): string[] { + const start = Math.min(fromMs, toMs); + const end = Math.max(fromMs, toMs); + return zooms + .filter((zoom) => zoom.span.start < end && zoom.span.end > start) + .map((zoom) => zoom.id); +} diff --git a/tests/ui/block-deletion.spec.ts b/tests/ui/block-deletion.spec.ts index 516a2b9be..e08973aac 100644 --- a/tests/ui/block-deletion.spec.ts +++ b/tests/ui/block-deletion.spec.ts @@ -167,3 +167,29 @@ test("a dragged annotation can be selected across its whole block and deleted wi await expect(annotation).toHaveCount(0); await expect(clip).toHaveCount(1); }); + +test("a selection box picks several zooms and deletes them in one undo step", async ({ page }) => { + const row = page.locator('[data-timeline-row="row-zoom"]'); + const box = (await row.boundingBox())!; + const y = box.y + box.height / 2; + const at = (fraction: number) => box.x + box.width * fraction; + for (const fraction of [0.1, 0.45, 0.8]) { + await page.mouse.click(at(fraction), y); + } + const zooms = page.locator('[data-variant="zoom"]'); + await expect(zooms).toHaveCount(3); + + await page.mouse.move(at(0.35), y); + await page.mouse.down(); + await page.mouse.move(at(0.97), y, { steps: 12 }); + await expect(page.getByTestId("zoom-selection-box")).toBeVisible(); + await page.mouse.up(); + await expect(page.getByTestId("zoom-selection-bar")).toContainText("2 zooms selected"); + await expect(zooms).toHaveCount(3); + + await page.keyboard.press("Delete"); + await expect(zooms).toHaveCount(1); + expect(Number(await zooms.getAttribute("data-start-ms"))).toBeLessThan(1000); + await page.keyboard.press("ControlOrMeta+z"); + await expect(zooms).toHaveCount(3); +});