add: When user drags the playhead, it will scrub through, which is an intended from multiple video editor.

scrubbing stops the playhead. as per usual editors
This commit is contained in:
Alan Trebugeais
2026-05-07 13:56:33 +02:00
parent 4ad5e9a29a
commit a013e92f56
2 changed files with 101 additions and 27 deletions
+7 -1
View File
@@ -3275,8 +3275,14 @@ export default function VideoEditor() {
}, []);
function handleSeek(time: number) {
const video = videoPlaybackRef.current?.video;
const playback = videoPlaybackRef.current;
const video = playback?.video;
if (!video) return;
if (!video.paused) {
playback?.pause();
}
video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000;
}
@@ -323,11 +323,17 @@ function PlaybackCursor({
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current || !onSeek) return;
let frameId: number | null = null;
let lastMouseEvent: MouseEvent | null = null;
const updateSeek = () => {
if (!lastMouseEvent || !timelineRef.current || !onSeek) {
frameId = null;
return;
}
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth;
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
@@ -347,6 +353,14 @@ function PlaybackCursor({
}
onSeek(absoluteMs / 1000);
frameId = null;
};
const handleMouseMove = (e: MouseEvent) => {
lastMouseEvent = e;
if (frameId === null) {
frameId = requestAnimationFrame(updateSeek);
}
};
const handleMouseUp = () => {
@@ -359,6 +373,9 @@ function PlaybackCursor({
document.body.style.cursor = "ew-resize";
return () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
@@ -427,9 +444,11 @@ function PlaybackCursor({
function TimelineAxis({
videoDurationMs,
currentTimeMs,
onMouseDown,
}: {
videoDurationMs: number;
currentTimeMs: number;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
}) {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
@@ -494,10 +513,11 @@ function TimelineAxis({
return (
<div
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none"
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none cursor-ew-resize"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
}}
onMouseDown={onMouseDown}
>
{/* Minor Ticks */}
{markers.minorTicks.map((time) => {
@@ -645,6 +665,7 @@ function Timeline({
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
const [isSeeking, setIsSeeking] = useState(false);
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
@@ -656,12 +677,22 @@ function Timeline({
[setTimelineRef],
);
const handleTimelineClick = useCallback(
const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return;
// Only clear selection if clicking on empty space (not on items)
// This is handled by event propagation - items stop propagation
const rect = localTimelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
if (clickX < 0) return;
setIsSeeking(true);
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
onSeek(absoluteMs / 1000);
// Clear selection when clicking on empty space
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectClip?.(null);
@@ -669,20 +700,13 @@ function Timeline({
onSelectSpeed?.(null);
onSelectAudio?.(null);
onClearBlockSelection?.();
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
if (clickX < 0) return;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
},
[
onSeek,
videoDurationMs,
sidebarWidth,
range.start,
pixelsToValue,
onSelectZoom,
onSelectTrim,
onSelectClip,
@@ -690,13 +714,54 @@ function Timeline({
onSelectSpeed,
onSelectAudio,
onClearBlockSelection,
videoDurationMs,
sidebarWidth,
range.start,
pixelsToValue,
],
);
useEffect(() => {
if (!isSeeking) return;
let frameId: number | null = null;
let lastMouseEvent: MouseEvent | null = null;
const updateSeek = () => {
if (!lastMouseEvent || !localTimelineRef.current || !onSeek || videoDurationMs <= 0) {
frameId = null;
return;
}
const rect = localTimelineRef.current.getBoundingClientRect();
const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
onSeek(absoluteMs / 1000);
frameId = null;
};
const handleMouseMove = (e: MouseEvent) => {
lastMouseEvent = e;
if (frameId === null) {
frameId = requestAnimationFrame(updateSeek);
}
};
const handleMouseUp = () => {
setIsSeeking(false);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isSeeking, onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start]);
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));
@@ -852,14 +917,17 @@ function Timeline({
...style,
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
}}
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
onClick={handleTimelineClick}
className="select-none bg-editor-bg relative group flex flex-col"
onMouseEnter={handleTimelineMouseEnter}
onMouseMove={handleTimelineMouseMove}
onMouseLeave={handleTimelineMouseLeave}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground)/0.03)_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<TimelineAxis
videoDurationMs={videoDurationMs}
currentTimeMs={currentTimeMs}
onMouseDown={handleTimelineMouseDown}
/>
<PlaybackCursor
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}