fix: findings that were high, removed monolithic timeline canvas

This commit is contained in:
Alan Trebugeais
2026-05-08 12:10:00 +02:00
parent 830027fb29
commit 041d479962
4 changed files with 434 additions and 258 deletions
@@ -1,10 +1,5 @@
import { useTimelineContext } from "dnd-timeline";
import { useCallback, useMemo, useRef, useState } from "react";
import { Plus } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import glassStyles from "../../ItemGlass.module.css";
import Item from "../../Item";
import Row from "../../Row";
import { useCallback, useMemo, useRef } from "react";
import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
@@ -12,20 +7,11 @@ import {
TIMELINE_AXIS_HEIGHT_PX,
} from "../../timelineLayout";
import type { AudioPeaksData } from "../../useAudioPeaks";
import AudioWaveform from "../../AudioWaveform";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
getAudioTrackIndex,
getAudioTrackRowId,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../../core/rows";
import type { TimelineRenderItem } from "../../model/timelineModel";
import TimelineAxis from "../axis/TimelineAxis";
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
import PlaybackCursor from "../playhead/PlaybackCursor";
import { TimelineCanvasRows } from "./TimelineCanvasRows";
import { useTimelineCanvasHover } from "./useTimelineCanvasHover";
interface TimelineCanvasProps {
items: TimelineRenderItem[];
@@ -79,10 +65,6 @@ export default function TimelineCanvas({
const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } =
useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
@@ -127,124 +109,40 @@ export default function TimelineCanvas({
],
);
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID);
const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId));
const audioItems = items.filter((item) => isAudioTrackRowId(item.rowId));
const audioRowIds = useMemo(
() =>
Array.from(new Set(audioItems.map((item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId))))).sort(
(left, right) => getAudioTrackIndex(left) - getAudioTrackIndex(right),
),
[audioItems],
);
const annotationRowIds = useMemo(
() =>
Array.from(
new Set(annotationItems.map((item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)))),
).sort((left, right) => getAnnotationTrackIndex(left) - getAnnotationTrackIndex(right)),
[annotationItems],
() => new Set(items.map((item) => item.rowId)).size,
[items],
);
const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length;
const timelineRowCount = Math.max(2, audioRowIds);
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
const sideProperty = direction === "rtl" ? "right" : "left";
const visibleDurationMs = Math.max(1, range.end - range.start);
const ghostStartMs = zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
const ghostDurationMs = Math.min(1000, videoDurationMs);
const ghostEndMs =
ghostStartMs === null
? null
: Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
const ghostStartOffsetPx = ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - range.start));
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start));
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
const timelineGhostOffsetPx = timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start));
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
const canShowGhostZoom =
isZoomRowHovered && ghostStartMs !== null && (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
const updateTimelineHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
const contentWidth = Math.max(1, rect.width - sidebarWidth);
const contentX =
direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth;
const clampedX = Math.max(0, Math.min(contentX, contentWidth));
const ratio = clampedX / contentWidth;
const nextMs = range.start + ratio * visibleDurationMs;
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
},
[direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs],
);
const handleTimelineMouseEnter = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateTimelineHoverTime],
);
const handleTimelineMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isTimelineHovered) setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isTimelineHovered, updateTimelineHoverTime],
);
const handleTimelineMouseLeave = useCallback(() => {
setIsTimelineHovered(false);
setTimelineHoverMs(null);
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const updateZoomRowHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
if (rect.width <= 0) return;
const position =
direction === "rtl"
? Math.max(0, Math.min(rect.right - clientX, rect.width))
: Math.max(0, Math.min(clientX - rect.left, rect.width));
const ratio = position / rect.width;
const nextMs = range.start + ratio * visibleDurationMs;
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
},
[direction, range.start, videoDurationMs, visibleDurationMs],
);
const handleZoomRowMouseEnter = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateZoomRowHoverTime],
);
const handleZoomRowMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isZoomRowHovered) setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isZoomRowHovered, updateZoomRowHoverTime],
);
const handleZoomRowMouseLeave = useCallback(() => {
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const handleZoomRowClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (!onAddZoomAtMs || zoomRowHoverMs === null) return;
const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) return;
onAddZoomAtMs(startMs);
},
[canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs],
);
const {
canShowGhostPlayhead,
timelineGhostOffsetPx,
handleTimelineMouseEnter,
handleTimelineMouseMove,
handleTimelineMouseLeave,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
handleZoomRowMouseEnter,
handleZoomRowMouseMove,
handleZoomRowMouseLeave,
handleZoomRowClick,
} = useTimelineCanvasHover({
direction,
sidebarWidth,
rangeStart: range.start,
rangeEnd: range.end,
videoDurationMs,
onAddZoomAtMs,
canPlaceZoomAtMs,
valueToPixels,
});
return (
<div
@@ -279,119 +177,29 @@ export default function TimelineCanvas({
)}
<div className="relative z-10 flex flex-1 min-h-0 flex-col" style={{ minHeight: timelineRowsMinHeightPx }}>
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint="Press C to split clip">
{audioPeaks && <AudioWaveform peaks={audioPeaks} />}
<ClipMarkerOverlay videoDurationMs={videoDurationMs} />
{clipItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedClipId}
onSelect={() => onSelectClip?.(item.id)}
variant="clip"
>
{item.label}
</Item>
))}
</Row>
<Row
id={ZOOM_ROW_ID}
isEmpty={zoomItems.length === 0}
onMouseEnter={handleZoomRowMouseEnter}
onMouseMove={handleZoomRowMouseMove}
onMouseLeave={handleZoomRowMouseLeave}
onClick={handleZoomRowClick}
>
{canShowGhostZoom && ghostStartMs !== null && (
<div className="absolute inset-0 z-[3] pointer-events-none">
<div
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
style={
direction === "rtl"
? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
: { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
}
>
<div
className={cn(
glassStyles.glassPurple,
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
)}
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
<div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white">
<Plus className="h-2.5 w-2.5" />
</div>
</div>
</div>
</div>
)}
{zoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomMode={item.zoomMode}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>
{annotationRowIds.map((rowId, index) => {
const rowItems = annotationItems.filter(
(item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)) === rowId,
);
return (
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? "Press A to add annotation" : undefined}>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
onSelect={() => onSelectAnnotation?.(item.id)}
variant="annotation"
>
{item.label}
</Item>
))}
</Row>
);
})}
{audioRowIds.map((rowId, index) => {
const rowItems = audioItems.filter(
(item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)) === rowId,
);
return (
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? "Click music icon to add audio" : undefined}>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
onSelect={() => onSelectAudio?.(item.id)}
variant="audio"
>
{item.label}
</Item>
))}
</Row>
);
})}
<TimelineCanvasRows
items={items}
videoDurationMs={videoDurationMs}
selectAllBlocksActive={selectAllBlocksActive}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
onSelectZoom={onSelectZoom}
onSelectClip={onSelectClip}
onSelectAnnotation={onSelectAnnotation}
onSelectAudio={onSelectAudio}
audioPeaks={audioPeaks}
direction={direction}
canShowGhostZoom={canShowGhostZoom}
ghostStartMs={ghostStartMs}
ghostStartOffsetPx={ghostStartOffsetPx}
ghostWidthPx={ghostWidthPx}
onZoomRowMouseEnter={handleZoomRowMouseEnter}
onZoomRowMouseMove={handleZoomRowMouseMove}
onZoomRowMouseLeave={handleZoomRowMouseLeave}
onZoomRowClick={handleZoomRowClick}
/>
</div>
</div>
);
@@ -0,0 +1,222 @@
import { Plus } from "@phosphor-icons/react";
import { useMemo, type MouseEventHandler } from "react";
import { cn } from "@/lib/utils";
import AudioWaveform from "../../AudioWaveform";
import glassStyles from "../../ItemGlass.module.css";
import Item from "../../Item";
import Row from "../../Row";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
getAudioTrackIndex,
getAudioTrackRowId,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../../core/rows";
import type { TimelineRenderItem } from "../../model/timelineModel";
import type { AudioPeaksData } from "../../useAudioPeaks";
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
interface TimelineCanvasRowsProps {
items: TimelineRenderItem[];
videoDurationMs: number;
selectAllBlocksActive: boolean;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
onSelectZoom?: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
audioPeaks?: AudioPeaksData | null;
direction: "ltr" | "rtl";
canShowGhostZoom: boolean;
ghostStartMs: number | null;
ghostStartOffsetPx: number;
ghostWidthPx: number;
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
}
export function TimelineCanvasRows({
items,
videoDurationMs,
selectAllBlocksActive,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
audioPeaks,
direction,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
onZoomRowMouseEnter,
onZoomRowMouseMove,
onZoomRowMouseLeave,
onZoomRowClick,
}: TimelineCanvasRowsProps) {
const zoomItems = useMemo(() => items.filter((item) => item.rowId === ZOOM_ROW_ID), [items]);
const clipItems = useMemo(() => items.filter((item) => item.rowId === CLIP_ROW_ID), [items]);
const annotationItems = useMemo(
() => items.filter((item) => isAnnotationTrackRowId(item.rowId)),
[items],
);
const audioItems = useMemo(() => items.filter((item) => isAudioTrackRowId(item.rowId)), [items]);
const audioRowIds = useMemo(
() =>
Array.from(
new Set(audioItems.map((item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)))),
).sort((left, right) => getAudioTrackIndex(left) - getAudioTrackIndex(right)),
[audioItems],
);
const annotationRowIds = useMemo(
() =>
Array.from(
new Set(
annotationItems.map((item) =>
getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)),
),
),
).sort((left, right) => getAnnotationTrackIndex(left) - getAnnotationTrackIndex(right)),
[annotationItems],
);
return (
<>
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint="Press C to split clip">
{audioPeaks && <AudioWaveform peaks={audioPeaks} />}
<ClipMarkerOverlay videoDurationMs={videoDurationMs} />
{clipItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedClipId}
onSelect={() => onSelectClip?.(item.id)}
variant="clip"
>
{item.label}
</Item>
))}
</Row>
<Row
id={ZOOM_ROW_ID}
isEmpty={zoomItems.length === 0}
onMouseEnter={onZoomRowMouseEnter}
onMouseMove={onZoomRowMouseMove}
onMouseLeave={onZoomRowMouseLeave}
onClick={onZoomRowClick}
>
{canShowGhostZoom && ghostStartMs !== null && (
<div className="absolute inset-0 z-[3] pointer-events-none">
<div
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
style={
direction === "rtl"
? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
: { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
}
>
<div
className={cn(
glassStyles.glassPurple,
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
)}
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
<div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white">
<Plus className="h-2.5 w-2.5" />
</div>
</div>
</div>
</div>
)}
{zoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomMode={item.zoomMode}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>
{annotationRowIds.map((rowId, index) => {
const rowItems = annotationItems.filter(
(item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)) === rowId,
);
return (
<Row
key={rowId}
id={rowId}
isEmpty={rowItems.length === 0}
hint={index === 0 ? "Press A to add annotation" : undefined}
>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
onSelect={() => onSelectAnnotation?.(item.id)}
variant="annotation"
>
{item.label}
</Item>
))}
</Row>
);
})}
{audioRowIds.map((rowId, index) => {
const rowItems = audioItems.filter(
(item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)) === rowId,
);
return (
<Row
key={rowId}
id={rowId}
isEmpty={rowItems.length === 0}
hint={index === 0 ? "Click music icon to add audio" : undefined}
>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
onSelect={() => onSelectAudio?.(item.id)}
variant="audio"
>
{item.label}
</Item>
))}
</Row>
);
})}
</>
);
}
@@ -0,0 +1,144 @@
import { useCallback, useState, type MouseEvent } from "react";
interface UseTimelineCanvasHoverParams {
direction: "ltr" | "rtl";
sidebarWidth: number;
rangeStart: number;
rangeEnd: number;
videoDurationMs: number;
onAddZoomAtMs?: (startMs: number) => void;
canPlaceZoomAtMs?: (startMs: number) => boolean;
valueToPixels: (value: number) => number;
}
export function useTimelineCanvasHover({
direction,
sidebarWidth,
rangeStart,
rangeEnd,
videoDurationMs,
onAddZoomAtMs,
canPlaceZoomAtMs,
valueToPixels,
}: UseTimelineCanvasHoverParams) {
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
const visibleDurationMs = Math.max(1, rangeEnd - rangeStart);
const updateTimelineHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
const contentWidth = Math.max(1, rect.width - sidebarWidth);
const contentX =
direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth;
const clampedX = Math.max(0, Math.min(contentX, contentWidth));
const ratio = clampedX / contentWidth;
const nextMs = rangeStart + ratio * visibleDurationMs;
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
},
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs],
);
const handleTimelineMouseEnter = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateTimelineHoverTime],
);
const handleTimelineMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!isTimelineHovered) setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isTimelineHovered, updateTimelineHoverTime],
);
const handleTimelineMouseLeave = useCallback(() => {
setIsTimelineHovered(false);
setTimelineHoverMs(null);
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const updateZoomRowHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
if (rect.width <= 0) return;
const position =
direction === "rtl"
? Math.max(0, Math.min(rect.right - clientX, rect.width))
: Math.max(0, Math.min(clientX - rect.left, rect.width));
const ratio = position / rect.width;
const nextMs = rangeStart + ratio * visibleDurationMs;
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
},
[direction, rangeStart, videoDurationMs, visibleDurationMs],
);
const handleZoomRowMouseEnter = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateZoomRowHoverTime],
);
const handleZoomRowMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!isZoomRowHovered) setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isZoomRowHovered, updateZoomRowHoverTime],
);
const handleZoomRowMouseLeave = useCallback(() => {
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const handleZoomRowClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (!onAddZoomAtMs || zoomRowHoverMs === null) return;
const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) return;
onAddZoomAtMs(startMs);
},
[canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs],
);
const ghostStartMs =
zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
const ghostDurationMs = Math.min(1000, videoDurationMs);
const ghostEndMs =
ghostStartMs === null
? null
: Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
const ghostStartOffsetPx =
ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart));
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
const timelineGhostOffsetPx =
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
const canShowGhostZoom =
isZoomRowHovered &&
ghostStartMs !== null &&
(onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
return {
canShowGhostPlayhead,
timelineGhostOffsetPx,
handleTimelineMouseEnter,
handleTimelineMouseMove,
handleTimelineMouseLeave,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
handleZoomRowMouseEnter,
handleZoomRowMouseMove,
handleZoomRowMouseLeave,
handleZoomRowClick,
};
}
@@ -1,15 +1,17 @@
import { useEffect, type RefObject } from "react";
import { matchesShortcut } from "@/lib/shortcuts";
import { matchesShortcut, type ShortcutBinding } from "@/lib/shortcuts";
interface TimelineShortcutBindings {
addKeyframe: ShortcutBinding;
addZoom: ShortcutBinding;
splitClip: ShortcutBinding;
addAnnotation: ShortcutBinding;
deleteSelected: ShortcutBinding;
}
interface UseTimelineKeyboardShortcutsParams {
isMac: boolean;
keyShortcuts: {
addKeyframe: unknown;
addZoom: unknown;
splitClip: unknown;
addAnnotation: unknown;
deleteSelected: unknown;
};
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject<boolean>;
hasAnyTimelineBlocks: boolean;
annotationCount: number;
@@ -76,10 +78,10 @@ export function useTimelineKeyboardShortcuts({
return;
}
if (matchesShortcut(e, keyShortcuts.addKeyframe as never, isMac)) addKeyframe();
if (matchesShortcut(e, keyShortcuts.addZoom as never, isMac)) handleAddZoom();
if (matchesShortcut(e, keyShortcuts.splitClip as never, isMac)) handleSplitClip();
if (matchesShortcut(e, keyShortcuts.addAnnotation as never, isMac)) {
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe();
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom();
if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip();
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
@@ -92,7 +94,7 @@ export function useTimelineKeyboardShortcuts({
if (
e.key === "Delete" ||
e.key === "Backspace" ||
matchesShortcut(e, keyShortcuts.deleteSelected as never, isMac)
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
) {
if (selectAllBlocksActive) {
e.preventDefault();