Merge pull request #243 from ryujh030820/feature/improve-timeline-navigation

Improve timeline navigation while scrubbing and scrolling
This commit is contained in:
Sid
2026-03-21 16:43:25 -07:00
committed by GitHub
@@ -169,6 +169,33 @@ function createInitialRange(totalMs: number): Range {
return { start: 0, end: FALLBACK_RANGE_MS };
}
function clampVisibleRange(candidate: Range, totalMs: number): Range {
if (totalMs <= 0) {
return candidate;
}
const span = Math.max(candidate.end - candidate.start, 1);
if (span >= totalMs) {
return { start: 0, end: totalMs };
}
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
return { start, end: start + span };
}
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageSizePx;
}
return delta;
}
function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
@@ -204,18 +231,21 @@ function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
onRangeChange,
timelineRef,
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
useEffect(() => {
if (!isDragging) return;
@@ -225,6 +255,7 @@ function PlaybackCursor({
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
@@ -243,11 +274,51 @@ function PlaybackCursor({
absoluteMs = nearbyKeyframe.time;
}
setDragPreviewTimeMs(absoluteMs);
const visibleMs = range.end - range.start;
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
const msPerPixel = visibleMs / contentWidth;
const overflowLeftPx = Math.max(0, -clickX);
const overflowRightPx = Math.max(0, clickX - contentWidth);
if (overflowLeftPx > 0 && range.start > 0) {
const shiftMs = overflowLeftPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start - shiftMs,
end: previous.end - shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
const shiftMs = overflowRightPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
}
}
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
setDragPreviewTimeMs(null);
document.body.style.cursor = "";
};
@@ -263,6 +334,7 @@ function PlaybackCursor({
}, [
isDragging,
onSeek,
onRangeChange,
timelineRef,
sidebarWidth,
range.start,
@@ -272,11 +344,14 @@ function PlaybackCursor({
keyframes,
]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
const displayTimeMs =
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
if (videoDurationMs <= 0 || displayTimeMs < 0) {
return null;
}
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
if (clampedTime < range.start || clampedTime > range.end) {
return null;
@@ -299,6 +374,7 @@ function PlaybackCursor({
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setDragPreviewTimeMs(currentTimeMs);
setIsDragging(true);
}}
>
@@ -444,6 +520,7 @@ function Timeline({
videoDurationMs,
currentTimeMs,
onSeek,
onRangeChange,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
@@ -458,6 +535,7 @@ function Timeline({
videoDurationMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
@@ -514,6 +592,46 @@ function Timeline({
],
);
const handleTimelineWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
return;
}
const visibleMs = range.end - range.start;
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
return;
}
const dominantDelta =
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (dominantDelta === 0) {
return;
}
event.preventDefault();
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
const shiftMs = pixelsToValue(normalizedDeltaPx);
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
},
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
);
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
@@ -525,6 +643,7 @@ function Timeline({
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
@@ -532,6 +651,7 @@ function Timeline({
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
onRangeChange={onRangeChange}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
@@ -657,17 +777,15 @@ export default function TimelineEditor({
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Scroll",
zoom: "Ctrl + Scroll",
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan, zoom });
});
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan: "Scroll", zoom });
});
}, []);
@@ -1351,6 +1469,7 @@ export default function TimelineEditor({
videoDurationMs={totalMs}
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onRangeChange={setRange}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}