From c9985a08d4cb455c51d2a8c5b8ac1f4a75ae5034 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 16 May 2026 12:21:30 +0200 Subject: [PATCH] add empty timeline scrubbing --- .../video-editor/timeline/TimelineEditor.tsx | 153 ++++++++++++++---- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 20faf65..cccb1a9 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -237,6 +237,31 @@ function formatPlayheadTime(ms: number): string { return `${sec.toFixed(1)}s`; } +function shouldStartTimelineScrub(target: EventTarget | null, timelineElement: HTMLElement) { + if (!(target instanceof HTMLElement)) { + return false; + } + + for (let element: HTMLElement | null = target; element && element !== timelineElement; ) { + const className = element.className; + const classText = typeof className === "string" ? className : ""; + + if ( + classText.split(/\s+/).includes("group") || + classText.includes("cursor-grab") || + classText.includes("cursor-grabbing") || + classText.includes("cursor-ew-resize") || + element.style.cursor === "col-resize" + ) { + return false; + } + + element = element.parentElement; + } + + return true; +} + function PlaybackCursor({ currentTimeMs, videoDurationMs, @@ -563,6 +588,8 @@ function Timeline({ const t = useScopedT("timeline"); const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const localTimelineRef = useRef(null); + const isScrubbingTimelineRef = useRef(false); + const scrubPointerIdRef = useRef(null); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -572,41 +599,103 @@ function Timeline({ [setTimelineRef], ); - const handleTimelineClick = useCallback( - (e: React.MouseEvent) => { - if (!onSeek || videoDurationMs <= 0) return; + const seekTimelineAtClientX = useCallback( + (timelineElement: HTMLDivElement, clientX: number) => { + if (!onSeek || videoDurationMs <= 0) return false; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectAnnotation?.(null); - onSelectBlur?.(null); - onSelectSpeed?.(null); + const rect = timelineElement.getBoundingClientRect(); + const clickX = clientX - rect.left - sidebarWidth; - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; - - if (clickX < 0) return; + if (clickX < 0) return false; const relativeMs = pixelsToValue(clickX); const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - onSeek(timeInSeconds); + onSeek(absoluteMs / 1000); + return true; }, - [ - onSeek, - onSelectZoom, - onSelectTrim, - onSelectAnnotation, - onSelectBlur, - onSelectSpeed, - videoDurationMs, - sidebarWidth, - range.start, - pixelsToValue, - ], + [onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start], + ); + + const clearTimelineSelection = useCallback(() => { + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectBlur?.(null); + onSelectSpeed?.(null); + }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]); + + const handleTimelineClick = useCallback( + (e: React.MouseEvent) => { + // Only clear selection if clicking on empty space (not on items) + // This is handled by event propagation - items stop propagation + clearTimelineSelection(); + seekTimelineAtClientX(e.currentTarget, e.clientX); + }, + [clearTimelineSelection, seekTimelineAtClientX], + ); + + const handleTimelinePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) { + return; + } + + if (!shouldStartTimelineScrub(e.target, e.currentTarget)) { + return; + } + + if (!seekTimelineAtClientX(e.currentTarget, e.clientX)) { + return; + } + + clearTimelineSelection(); + isScrubbingTimelineRef.current = true; + scrubPointerIdRef.current = e.pointerId; + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + }, + [clearTimelineSelection, seekTimelineAtClientX], + ); + + const handleTimelinePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) { + return; + } + + seekTimelineAtClientX(e.currentTarget, e.clientX); + e.preventDefault(); + }, + [seekTimelineAtClientX], + ); + + const stopTimelineScrub = useCallback((e: React.PointerEvent) => { + if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) { + return; + } + + isScrubbingTimelineRef.current = false; + scrubPointerIdRef.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }, []); + + const handleTimelinePointerLeave = useCallback((e: React.PointerEvent) => { + if (isScrubbingTimelineRef.current && scrubPointerIdRef.current === e.pointerId) { + seekTimelineAtClientX(e.currentTarget, e.clientX); + } + }, [seekTimelineAtClientX]); + + const handleTimelineLostPointerCapture = useCallback( + (e: React.PointerEvent) => { + if (scrubPointerIdRef.current === e.pointerId) { + isScrubbingTimelineRef.current = false; + scrubPointerIdRef.current = null; + } + }, + [], ); const handleTimelineWheel = useCallback( @@ -658,9 +747,15 @@ function Timeline({ return (