diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 4c99ff57..4071d3ad 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -10,13 +10,13 @@ export function KeyboardShortcutsHelp() { const t = useScopedT("editor"); const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", + pan: "Shift + Scroll", zoom: "Ctrl + Scroll", }); useEffect(() => { Promise.all([ - formatShortcut(["shift", "mod", "Scroll"]), + formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"]), ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx index b773683f..a09e5c88 100644 --- a/src/components/video-editor/TutorialHelp.tsx +++ b/src/components/video-editor/TutorialHelp.tsx @@ -149,13 +149,13 @@ export function KeyboardShortcutsDialog({ const { shortcuts, isMac, openConfig } = useShortcuts(); const t = useScopedT("editor"); const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", + pan: "Shift + Scroll", zoom: "Ctrl + Scroll", }); useEffect(() => { Promise.all([ - formatShortcut(["shift", "mod", "Scroll"]), + formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"]), ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts new file mode 100644 index 00000000..bff6a651 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTimelineWheelPanDeltaPx } from "./useTimelineRange"; + +describe("resolveTimelineWheelPanDeltaPx", () => { + it("uses trackpad horizontal wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 24, + deltaY: 0, + deltaMode: 0, + }), + ).toBe(24); + }); + + it("uses shifted vertical wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + shiftKey: true, + }), + ).toBe(48); + }); + + it("keeps ctrl wheel available for timeline zoom unless shift is also held", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + }), + ).toBe(0); + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + shiftKey: true, + }), + ).toBe(48); + }); + + it("uses regular wheel movement when the timeline has no vertical overflow", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 20, + deltaMode: 0, + canScrollVertically: false, + }), + ).toBe(20); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.ts index e3d5ff59..c72b2fd9 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineRange.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.ts @@ -7,6 +7,40 @@ interface UseTimelineRangeParams { timelineContainerRef: RefObject; } +export interface TimelineWheelPanDeltaInput { + deltaX: number; + deltaY: number; + deltaMode: number; + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + canScrollVertically?: boolean; +} + +export function resolveTimelineWheelPanDeltaPx({ + deltaX, + deltaY, + deltaMode, + shiftKey = false, + ctrlKey = false, + metaKey = false, + canScrollVertically = true, +}: TimelineWheelPanDeltaInput) { + if ((ctrlKey || metaKey) && !shiftKey) { + return 0; + } + + if (Math.abs(deltaX) > 0) { + return normalizeWheelDeltaToPixels(deltaX, deltaMode); + } + + if ((shiftKey || !canScrollVertically) && Math.abs(deltaY) > 0) { + return normalizeWheelDeltaToPixels(deltaY, deltaMode); + } + + return 0; +} + export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) { const [range, setRange] = useState(() => createInitialRange(totalMs)); @@ -42,29 +76,34 @@ export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineR const handleTimelineWheel = useCallback( (event: WheelEvent) => { - if (event.ctrlKey || event.metaKey || totalMs <= 0) { + if (((event.ctrlKey || event.metaKey) && !event.shiftKey) || totalMs <= 0) { return; } - const rawHorizontalDelta = - Math.abs(event.deltaX) > 0 - ? event.deltaX - : event.shiftKey && Math.abs(event.deltaY) > 0 - ? event.deltaY - : 0; + const container = timelineContainerRef.current; + const horizontalDeltaPx = resolveTimelineWheelPanDeltaPx({ + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + canScrollVertically: container + ? container.scrollHeight > container.clientHeight + 1 + : true, + }); - if (rawHorizontalDelta === 0) { + if (horizontalDeltaPx === 0) { return; } - const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; + const containerWidth = container?.clientWidth ?? 0; const visibleRangeMs = clampedRange.end - clampedRange.start; if (containerWidth <= 0 || visibleRangeMs <= 0) { return; } event.preventDefault(); - const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode); const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; panTimelineRange(deltaMs); }, diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d2d9828c..d4384693 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -37,7 +37,7 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [ display: "Del / ⌫", bindings: [{ key: "delete" }, { key: "backspace" }], }, - { label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] }, + { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, ];