From a013e92f56ea95ab62fd25f4e90a86a29a35b87c Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Thu, 7 May 2026 13:56:33 +0200 Subject: [PATCH] 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 --- src/components/video-editor/VideoEditor.tsx | 8 +- .../video-editor/timeline/TimelineEditor.tsx | 120 ++++++++++++++---- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index a19098d6..e1dc8894 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 740d01cb..4024dd3a 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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; }) { const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -494,10 +513,11 @@ function TimelineAxis({ return (
{/* Minor Ticks */} {markers.minorTicks.map((time) => { @@ -645,6 +665,7 @@ function Timeline({ const localTimelineRef = useRef(null); const [isTimelineHovered, setIsTimelineHovered] = useState(false); const [timelineHoverMs, setTimelineHoverMs] = useState(null); + const [isSeeking, setIsSeeking] = useState(false); const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); @@ -656,12 +677,22 @@ function Timeline({ [setTimelineRef], ); - const handleTimelineClick = useCallback( + const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { - 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} >
- +