From d8871d9228658a0187105e0d1df30e7de6f6da3f Mon Sep 17 00:00:00 2001 From: JH Date: Fri, 20 Mar 2026 16:25:19 +0900 Subject: [PATCH 1/2] fix: pan timeline when dragging playhead to edges --- .../video-editor/timeline/TimelineEditor.tsx | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index ab177a70..75e82ec8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -169,6 +169,21 @@ 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 formatTimeLabel(milliseconds: number, intervalMs: number) { const totalSeconds = milliseconds / 1000; const hours = Math.floor(totalSeconds / 3600); @@ -204,18 +219,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; 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(null); useEffect(() => { if (!isDragging) return; @@ -225,6 +243,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 +262,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 +322,7 @@ function PlaybackCursor({ }, [ isDragging, onSeek, + onRangeChange, timelineRef, sidebarWidth, range.start, @@ -272,11 +332,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 +362,7 @@ function PlaybackCursor({ }} onMouseDown={(e) => { e.stopPropagation(); // Prevent timeline click + setDragPreviewTimeMs(currentTimeMs); setIsDragging(true); }} > @@ -444,6 +508,7 @@ function Timeline({ videoDurationMs, currentTimeMs, onSeek, + onRangeChange, onSelectZoom, onSelectTrim, onSelectAnnotation, @@ -458,6 +523,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; @@ -532,6 +598,7 @@ function Timeline({ currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} onSeek={onSeek} + onRangeChange={onRangeChange} timelineRef={localTimelineRef} keyframes={keyframes} /> @@ -1351,6 +1418,7 @@ export default function TimelineEditor({ videoDurationMs={totalMs} currentTimeMs={currentTimeMs} onSeek={onSeek} + onRangeChange={setRange} onSelectZoom={onSelectZoom} onSelectTrim={onSelectTrim} onSelectAnnotation={onSelectAnnotation} From 203282be43beaa15266fe863705feeb4b2730774 Mon Sep 17 00:00:00 2001 From: JH Date: Fri, 20 Mar 2026 16:52:16 +0900 Subject: [PATCH 2/2] fix: pan timeline on row scroll --- .../video-editor/timeline/TimelineEditor.tsx | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 75e82ec8..4e0181bf 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -184,6 +184,18 @@ function clampVisibleRange(candidate: Range, totalMs: number): Range { 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); @@ -580,6 +592,46 @@ function Timeline({ ], ); + const handleTimelineWheel = useCallback( + (event: React.WheelEvent) => { + 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); @@ -591,6 +643,7 @@ function Timeline({ style={style} className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group" onClick={handleTimelineClick} + onWheel={handleTimelineWheel} >
@@ -724,17 +777,15 @@ export default function TimelineEditor({ const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", + pan: "Scroll", zoom: "Ctrl + Scroll", }); const timelineContainerRef = useRef(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 }); }); }, []);