Merge pull request #243 from ryujh030820/feature/improve-timeline-navigation
Improve timeline navigation while scrubbing and scrolling
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user