Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/video-editor/timeline/ItemGlass.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
38 changes: 36 additions & 2 deletions src/components/video-editor/timeline/TimelineEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -336,6 +337,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
selectedZoomIds,
setSelectedZoomIds,
selectZoomIds,
deleteSelectedZoom,
handleKeyframeMove,
clearSelectedBlocks,
handleSelectZoom,
Expand Down Expand Up @@ -403,6 +408,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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 (
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
Expand All @@ -420,7 +430,28 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}

return (
<div className="flex-1 min-h-0 flex flex-col bg-transparent overflow-hidden">
<div className="relative flex-1 min-h-0 flex flex-col bg-transparent overflow-hidden">
{(selectAllBlocksActive || pickedZoomCount > 1) && pickedZoomCount > 0 && (
<div
data-testid="zoom-selection-bar"
className="absolute right-4 top-1 z-30 flex items-center gap-2 rounded-full border border-foreground/10 bg-editor-surface/95 py-1 pl-3 pr-1 text-xs shadow-lg backdrop-blur"
>
<span className="text-foreground/80">
{t("timeline.zoomsSelected", "{{count}} zooms selected", {
count: String(pickedZoomCount),
})}
</span>
<Button
variant="destructive-soft"
size="sm"
className="h-7 gap-1.5 rounded-full px-3 text-xs"
onClick={deleteSelectedZoom}
>
<Trash2 className="h-3 w-3" />
{t("timeline.deleteZooms", "Delete")}
</Button>
</div>
)}
<div
ref={timelineContainerRef}
data-testid="timeline-scroll"
Expand All @@ -440,6 +471,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onClick={() => {
setSelectedKeyframeId(null);
setSelectAllBlocksActive(false);
setSelectedZoomIds([]);
}}
onWheel={handleTimelineWheel}
>
Expand Down Expand Up @@ -506,6 +538,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
selectedAudioId={selectedAudioId}
selectedCaptionId={selectedCaptionId}
selectAllBlocksActive={selectAllBlocksActive}
selectedZoomIds={selectedZoomIds}
onSelectZoomIds={selectZoomIds}
onClearBlockSelection={clearSelectedBlocks}
keyframes={keyframes}
sourceAudioTracks={sourceAudioTracks}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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[];
Expand All @@ -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[];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -482,7 +490,10 @@ function AudioItemWithWaveform({
const TimelineCanvasRows = memo(function TimelineCanvasRows({
videoPath,
items,
videoDurationMs,
selectAllBlocksActive,
selectedZoomIds,
onSelectZoomIds,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
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
Expand Down Expand Up @@ -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 && (
<div
data-testid="zoom-selection-box"
className="pointer-events-none absolute inset-y-0.5 z-[4] rounded-md border border-sky-400/80 bg-sky-400/15"
style={{
[direction === "rtl" ? "right" : "left"]: valueToPixels(
Math.min(selectionBox.fromMs, selectionBox.toMs) - rangeStart,
),
width: valueToPixels(Math.abs(selectionBox.toMs - selectionBox.fromMs)),
}}
/>
)}
{canShowGhostZoom && !selectionBox && ghostStartMs !== null && (
<div
data-testid="timeline-add-preview"
className="absolute inset-0 z-[3] pointer-events-none"
Expand Down Expand Up @@ -830,23 +926,25 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
</div>
</div>
)}
{zoomItems
.filter((item) => !hiddenIds.has(item.id))
.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
onSelectId={onSelectZoom}
zoomDepth={item.zoomDepth}
zoomMode={item.zoomMode}
variant="zoom"
>
{item.label}
</Item>
))}
{visibleZoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={
selectAllBlocksActive ||
item.id === selectedZoomId ||
(boxedZoomIds ?? pickedZoomIds).has(item.id)
}
onSelectId={onSelectZoom}
zoomDepth={item.zoomDepth}
zoomMode={item.zoomMode}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>

{annotationRows.map(({ rowId, items: rowItems }, index) => (
Expand Down Expand Up @@ -919,6 +1017,8 @@ export default function TimelineCanvas({
selectedAudioId,
selectedCaptionId,
selectAllBlocksActive = false,
selectedZoomIds,
onSelectZoomIds,
onClearBlockSelection,
keyframes = [],
sourceAudioTracks = [],
Expand Down Expand Up @@ -1186,6 +1286,8 @@ export default function TimelineCanvas({
items={items}
videoDurationMs={videoDurationMs}
selectAllBlocksActive={selectAllBlocksActive}
selectedZoomIds={selectedZoomIds}
onSelectZoomIds={onSelectZoomIds}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export function useTimelineEditorRuntime({
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
selectedZoomIds,
setSelectedZoomIds,
selectZoomIds,
hasAnyZoomBlocks,
activateSelectAllZooms,
addKeyframe,
Expand Down Expand Up @@ -274,6 +277,8 @@ export function useTimelineEditorRuntime({
selectedAudioId,
selectedCaptionId,
selectAllBlocksActive,
selectedZoomIds,
clearSelectedBlocks,
addKeyframe,
handleAddZoom,
handleSplitClip,
Expand Down Expand Up @@ -313,6 +318,10 @@ export function useTimelineEditorRuntime({
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
selectedZoomIds,
setSelectedZoomIds,
selectZoomIds,
deleteSelectedZoom,
handleKeyframeMove,
clearSelectedBlocks,
handleSelectZoom,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +46,8 @@ export function useTimelineKeyboardShortcuts({
selectedAudioId,
selectedCaptionId,
selectAllBlocksActive,
selectedZoomIds = [],
clearSelectedBlocks,
addKeyframe,
handleAddZoom,
handleSplitClip,
Expand Down Expand Up @@ -84,6 +88,7 @@ export function useTimelineKeyboardShortcuts({
) {
const target = resolveDeleteSelectionTarget({
selectAllBlocksActive,
selectedZoomIds,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
Expand All @@ -110,6 +115,12 @@ export function useTimelineKeyboardShortcuts({
return;
}

if (e.key === "Escape" && (selectAllBlocksActive || selectedZoomIds.length > 0)) {
e.preventDefault();
clearSelectedBlocks?.();
return;
}

if (!isTimelineFocusedRef.current) {
return;
}
Expand Down Expand Up @@ -143,6 +154,7 @@ export function useTimelineKeyboardShortcuts({
activateSelectAllZooms,
addKeyframe,
annotationCount,
clearSelectedBlocks,
cycleAnnotationsAtCurrentTime,
deleteSelectedAnnotation,
deleteSelectedAudio,
Expand All @@ -158,6 +170,7 @@ export function useTimelineKeyboardShortcuts({
isTimelineFocusedRef,
keyShortcuts,
selectAllBlocksActive,
selectedZoomIds,
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
Expand Down
Loading