From 32541d7e3d09c957ffe03255b21050e21c526bd6 Mon Sep 17 00:00:00 2001 From: Franco Rodriguez Date: Wed, 23 Sep 2026 23:10:53 +0200 Subject: [PATCH 1/3] feat(editor): add frame-by-frame stepping and timeline navigation shortcuts --- .../video-editor/KeyboardShortcutsHelp.tsx | 18 ++- .../hooks/useEditorGlobalInteractions.test.ts | 109 +++++++++++++++++ .../hooks/useEditorGlobalInteractions.ts | 66 ++++++++++- .../hooks/useEditorPlaybackControls.test.ts | 112 ++++++++++++++++++ .../hooks/useEditorPlaybackControls.ts | 32 +++++ .../hooks/useTimelineEditingController.ts | 5 + src/i18n/locales/de/editor.json | 4 +- src/i18n/locales/en/editor.json | 4 +- src/i18n/locales/es/editor.json | 4 +- src/i18n/locales/fr/editor.json | 4 +- src/i18n/locales/it/editor.json | 4 +- src/i18n/locales/ko/editor.json | 4 +- src/i18n/locales/nl/editor.json | 4 +- src/i18n/locales/pt-BR/editor.json | 4 +- src/i18n/locales/ru/editor.json | 4 +- src/i18n/locales/zh-CN/editor.json | 4 +- src/i18n/locales/zh-TW/editor.json | 4 +- src/lib/shortcuts.ts | 30 +++++ 18 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 src/components/video-editor/hooks/useEditorPlaybackControls.test.ts diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index dc1e3c2d1..dfba6a9f3 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,8 +1,8 @@ import { Kbd } from "@heroui/react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Gear as Settings2, Question as HelpCircle } from "@/components/ui/icons"; import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Question as HelpCircle, Gear as Settings2 } from "@/components/ui/icons"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; @@ -75,6 +75,18 @@ export function KeyboardShortcutsHelp() { {t("keyboardShortcuts.tab")} +
+ + {t("keyboardShortcuts.stepFrame")} + + , / . +
+
+ + {t("keyboardShortcuts.stepSecond")} + + ⇧ + ← / → +
diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts index e02b7dcd5..adb5098ff 100644 --- a/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts @@ -118,3 +118,112 @@ describe("editor playback shortcut", () => { expect(playback.pause).toHaveBeenCalledOnce(); }); }); + +describe("editor frame-by-frame and timeline stepping shortcuts", () => { + function setupStepping() { + vi.stubGlobal("HTMLInputElement", Input); + vi.stubGlobal("HTMLTextAreaElement", Textarea); + vi.stubGlobal("HTMLSelectElement", Select); + const handlers = new Map void>(); + vi.stubGlobal("window", { + addEventListener: (name: string, handler: (event: KeyboardEvent) => void) => + handlers.set(name, handler), + removeEventListener: vi.fn(), + }); + const stepFrameBackward = vi.fn(); + const stepFrameForward = vi.fn(); + const stepTimeSeconds = vi.fn(); + const handlePreviewSkipBack = vi.fn(); + const handlePreviewSkipForward = vi.fn(); + + useEditorGlobalInteractions({ + timeline: {}, + videoPlaybackRef: { current: { video: {}, isPlaying: false, pause: vi.fn() } }, + shortcuts: DEFAULT_SHORTCUTS, + isMac: true, + startPlayback: vi.fn(), + handleUndo: vi.fn(), + handleRedo: vi.fn(), + stepFrameBackward, + stepFrameForward, + stepTimeSeconds, + handlePreviewSkipBack, + handlePreviewSkipForward, + } as unknown as Parameters[0]); + + const send = (type = "keydown", options: Record = {}) => { + const event = { + key: "", + code: "", + target: new Element(), + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + repeat: false, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + ...options, + }; + handlers.get(type)!(event as unknown as KeyboardEvent); + return event; + }; + + return { + send, + stepFrameBackward, + stepFrameForward, + stepTimeSeconds, + handlePreviewSkipBack, + handlePreviewSkipForward, + }; + } + + it("steps 1 frame backward with comma or ArrowLeft", () => { + const { send, stepFrameBackward } = setupStepping(); + const e1 = send("keydown", { key: "," }); + expect(e1.preventDefault).toHaveBeenCalled(); + expect(stepFrameBackward).toHaveBeenCalledTimes(1); + + const e2 = send("keydown", { key: "ArrowLeft" }); + expect(e2.preventDefault).toHaveBeenCalled(); + expect(stepFrameBackward).toHaveBeenCalledTimes(2); + }); + + it("steps 1 frame forward with period or ArrowRight", () => { + const { send, stepFrameForward } = setupStepping(); + const e1 = send("keydown", { key: "." }); + expect(e1.preventDefault).toHaveBeenCalled(); + expect(stepFrameForward).toHaveBeenCalledTimes(1); + + const e2 = send("keydown", { key: "ArrowRight" }); + expect(e2.preventDefault).toHaveBeenCalled(); + expect(stepFrameForward).toHaveBeenCalledTimes(2); + }); + + it("steps 1 second with Shift + Arrow keys", () => { + const { send, stepTimeSeconds } = setupStepping(); + send("keydown", { key: "ArrowLeft", shiftKey: true }); + expect(stepTimeSeconds).toHaveBeenCalledWith(-1); + + send("keydown", { key: "ArrowRight", shiftKey: true }); + expect(stepTimeSeconds).toHaveBeenCalledWith(1); + }); + + it("skips to keyframes with Alt + Arrow keys", () => { + const { send, handlePreviewSkipBack, handlePreviewSkipForward } = setupStepping(); + send("keydown", { key: "ArrowLeft", altKey: true }); + expect(handlePreviewSkipBack).toHaveBeenCalled(); + + send("keydown", { key: "ArrowRight", altKey: true }); + expect(handlePreviewSkipForward).toHaveBeenCalled(); + }); + + it("does not step frames when typing in input or textarea", () => { + const { send, stepFrameForward, stepFrameBackward } = setupStepping(); + send("keydown", { key: ".", target: new Input() }); + send("keydown", { key: "ArrowLeft", target: new Textarea() }); + expect(stepFrameForward).not.toHaveBeenCalled(); + expect(stepFrameBackward).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts index 1e5b0dd43..d02cbed2c 100644 --- a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts @@ -12,6 +12,11 @@ type Input = { handleUndo: () => void; handleRedo: () => void; startPlayback: () => void; + stepFrameBackward?: () => void; + stepFrameForward?: () => void; + stepTimeSeconds?: (seconds: number) => void; + handlePreviewSkipBack?: () => void; + handlePreviewSkipForward?: () => void; }; export function useEditorGlobalInteractions({ @@ -22,6 +27,11 @@ export function useEditorGlobalInteractions({ handleUndo, handleRedo, startPlayback, + stepFrameBackward, + stepFrameForward, + stepTimeSeconds, + handlePreviewSkipBack, + handlePreviewSkipForward, }: Input) { const heldPlaybackKey = useRef(null); @@ -72,6 +82,48 @@ export function useEditorGlobalInteractions({ } return; } + + if (!editable) { + if (event.altKey && !event.ctrlKey && !event.metaKey) { + if (event.key === "ArrowLeft" && handlePreviewSkipBack) { + event.preventDefault(); + handlePreviewSkipBack(); + return; + } + if (event.key === "ArrowRight" && handlePreviewSkipForward) { + event.preventDefault(); + handlePreviewSkipForward(); + return; + } + } + + if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (event.key === "ArrowLeft" && stepTimeSeconds) { + event.preventDefault(); + stepTimeSeconds(-1); + return; + } + if (event.key === "ArrowRight" && stepTimeSeconds) { + event.preventDefault(); + stepTimeSeconds(1); + return; + } + } + + if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) { + if ((event.key === "," || event.key === "ArrowLeft") && stepFrameBackward) { + event.preventDefault(); + stepFrameBackward(); + return; + } + if ((event.key === "." || event.key === "ArrowRight") && stepFrameForward) { + event.preventDefault(); + stepFrameForward(); + return; + } + } + } + if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return; consumePlaybackKey(event); if (event.repeat) return; @@ -89,7 +141,19 @@ export function useEditorGlobalInteractions({ window.removeEventListener("keyup", handleKeyUp, { capture: true }); window.removeEventListener("blur", releasePlaybackKey); }; - }, [shortcuts, isMac, handleUndo, handleRedo, startPlayback, videoPlaybackRef]); + }, [ + shortcuts, + isMac, + handleUndo, + handleRedo, + startPlayback, + videoPlaybackRef, + stepFrameBackward, + stepFrameForward, + stepTimeSeconds, + handlePreviewSkipBack, + handlePreviewSkipForward, + ]); useEffect(() => { if ( diff --git a/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts b/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts new file mode 100644 index 000000000..a388685e6 --- /dev/null +++ b/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { useEditorPlaybackControls } from "./useEditorPlaybackControls"; + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useRef: (current: unknown) => ({ current }), +})); + +describe("useEditorPlaybackControls frame stepping", () => { + function setup(timelinePlayheadTime = 5.0, timelineDuration = 10.0) { + const video = {}; + const playback = { + video, + isPlaying: false, + pause: vi.fn(() => { + playback.isPlaying = false; + }), + play: vi.fn().mockResolvedValue(undefined), + seekTimeline: vi.fn(), + }; + const videoPlaybackRef = { current: playback }; + const timelineRef = { + current: { + keyframes: [ + { id: "k1", time: 2000 }, + { id: "k2", time: 8000 }, + ], + }, + }; + const playSourceAudioPreview = vi.fn(); + + const controls = useEditorPlaybackControls({ + videoPlaybackRef: videoPlaybackRef as unknown as Parameters< + typeof useEditorPlaybackControls + >[0]["videoPlaybackRef"], + timelineRef: timelineRef as unknown as Parameters< + typeof useEditorPlaybackControls + >[0]["timelineRef"], + playSourceAudioPreview, + timelinePlayheadTime, + timelineDuration, + }); + + return { controls, playback, videoPlaybackRef, timelineRef }; + } + + it("steps forward by 1 frame (1/60s) and pauses active playback", () => { + const { controls, playback } = setup(2.0, 10.0); + playback.isPlaying = true; + + controls.stepFrameForward(); + + expect(playback.pause).toHaveBeenCalled(); + expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 + 1 / 60, 5)); + }); + + it("steps backward by 1 frame (1/60s) and pauses active playback", () => { + const { controls, playback } = setup(2.0, 10.0); + playback.isPlaying = true; + + controls.stepFrameBackward(); + + expect(playback.pause).toHaveBeenCalled(); + expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 - 1 / 60, 5)); + }); + + it("supports custom fps (e.g. 30fps) for frame stepping", () => { + const { controls, playback } = setup(2.0, 10.0); + + controls.stepFrameForward(30); + expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 + 1 / 30, 5)); + + controls.stepFrameBackward(30); + expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 - 1 / 30, 5)); + }); + + it("clamps frame stepping at 0 when stepping backward near start", () => { + const { controls, playback } = setup(0.005, 10.0); + + controls.stepFrameBackward(); + + expect(playback.seekTimeline).toHaveBeenCalledWith(0); + }); + + it("clamps frame stepping at duration when stepping forward near end", () => { + const { controls, playback } = setup(9.995, 10.0); + + controls.stepFrameForward(); + + expect(playback.seekTimeline).toHaveBeenCalledWith(10.0); + }); + + it("steps time by custom seconds (e.g. +1s and -1s)", () => { + const { controls, playback } = setup(5.0, 10.0); + + controls.stepTimeSeconds(1); + expect(playback.seekTimeline).toHaveBeenCalledWith(6.0); + + controls.stepTimeSeconds(-2.5); + expect(playback.seekTimeline).toHaveBeenCalledWith(2.5); + }); + + it("clamps stepTimeSeconds within [0, duration]", () => { + const { controls, playback } = setup(1.0, 10.0); + + controls.stepTimeSeconds(-5); + expect(playback.seekTimeline).toHaveBeenCalledWith(0); + + controls.stepTimeSeconds(20); + expect(playback.seekTimeline).toHaveBeenCalledWith(10.0); + }); +}); diff --git a/src/components/video-editor/hooks/useEditorPlaybackControls.ts b/src/components/video-editor/hooks/useEditorPlaybackControls.ts index 31b4f878e..1f957a163 100644 --- a/src/components/video-editor/hooks/useEditorPlaybackControls.ts +++ b/src/components/video-editor/hooks/useEditorPlaybackControls.ts @@ -69,6 +69,35 @@ export function useEditorPlaybackControls({ handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); }, [handleSeek, timelineDuration, timelinePlayheadTime, timelineRef]); + const stepFrameForward = useCallback( + (fps = 60) => { + const delta = 1 / Math.max(1, fps); + const targetTime = Math.min(timelineDuration, timelinePlayheadTime + delta); + handleSeek(targetTime, { pause: true }); + }, + [handleSeek, timelineDuration, timelinePlayheadTime], + ); + + const stepFrameBackward = useCallback( + (fps = 60) => { + const delta = 1 / Math.max(1, fps); + const targetTime = Math.max(0, timelinePlayheadTime - delta); + handleSeek(targetTime, { pause: true }); + }, + [handleSeek, timelinePlayheadTime], + ); + + const stepTimeSeconds = useCallback( + (seconds: number) => { + const targetTime = Math.max( + 0, + Math.min(timelineDuration, timelinePlayheadTime + seconds), + ); + handleSeek(targetTime, { pause: true }); + }, + [handleSeek, timelineDuration, timelinePlayheadTime], + ); + return { startPlayback, togglePlayPause, @@ -76,5 +105,8 @@ export function useEditorPlaybackControls({ handleTimelineSeek, handlePreviewSkipBack, handlePreviewSkipForward, + stepFrameBackward, + stepFrameForward, + stepTimeSeconds, }; } diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts index ffaa6938d..43f73f3d4 100644 --- a/src/components/video-editor/hooks/useTimelineEditingController.ts +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -221,6 +221,11 @@ export function useTimelineEditingController(input: Input) { handleUndo: input.handleUndo, handleRedo: input.handleRedo, startPlayback: playback.startPlayback, + stepFrameBackward: playback.stepFrameBackward, + stepFrameForward: playback.stepFrameForward, + stepTimeSeconds: playback.stepTimeSeconds, + handlePreviewSkipBack: playback.handlePreviewSkipBack, + handlePreviewSkipForward: playback.handlePreviewSkipForward, }); return { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 045be2fd7..7c425d223 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -150,7 +150,9 @@ "panTimeline": "Zeitleiste verschieben", "zoomTimeline": "Zeitleiste zoomen", "cycleAnnotations": "Anmerkungen durchlaufen", - "tab": "Registerkarte" + "tab": "Registerkarte", + "stepFrame": "1 Einzelbild weiter/zurück", + "stepSecond": "1 Sekunde weiter/zurück" }, "actions": { "saveAgain": "Erneut speichern", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 98200e9d3..8096ae2c5 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Pan Timeline", "zoomTimeline": "Zoom Timeline", "cycleAnnotations": "Cycle Annotations", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "Step 1 Frame", + "stepSecond": "Step 1 Second" }, "actions": { "saveAgain": "Save Again", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index fea01b37c..1e06017b9 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Desplazar línea de tiempo", "zoomTimeline": "Zoom en línea de tiempo", "cycleAnnotations": "Recorrer anotaciones", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "Avanzar 1 fotograma", + "stepSecond": "Avanzar 1 segundo" }, "actions": { "saveAgain": "Guardar de nuevo", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 8ff965d96..b3e1620d5 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Déplacer la timeline", "zoomTimeline": "Zoomer la timeline", "cycleAnnotations": "Parcourir les annotations", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "Avancer d'une image", + "stepSecond": "Avancer d'une seconde" }, "actions": { "saveAgain": "Enregistrer à nouveau", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 3e3e63441..a93c020af 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Sposta timeline", "zoomTimeline": "Zoom timeline", "cycleAnnotations": "Scorri annotazioni", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "Avanza di 1 fotogramma", + "stepSecond": "Avanza di 1 secondo" }, "actions": { "saveAgain": "Salva di nuovo", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 908ab3415..4f4692bf6 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -152,7 +152,9 @@ "panTimeline": "타임라인 이동", "zoomTimeline": "타임라인 확대/축소", "cycleAnnotations": "주석 순환", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "1프레임 이동", + "stepSecond": "1초 이동" }, "actions": { "saveAgain": "다시 저장", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index 7348ca083..adcb42be6 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -152,7 +152,9 @@ "panTimeline": "Tijdlijn verschuiven", "zoomTimeline": "Tijdlijn zoomen", "cycleAnnotations": "Annotaties doorlopen", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "1 frame vooruit", + "stepSecond": "1 seconde vooruit" }, "actions": { "saveAgain": "Opnieuw opslaan", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index ea318d81e..927a777cd 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Mover linha do tempo", "zoomTimeline": "Zoom da linha do tempo", "cycleAnnotations": "Alternar anotações", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "Avançar 1 quadro", + "stepSecond": "Avançar 1 segundo" }, "actions": { "saveAgain": "Salvar novamente", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index a9b89aeb0..b633ac404 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -151,7 +151,9 @@ "panTimeline": "Перемещение по таймлайну", "zoomTimeline": "Увеличение таймлайна", "cycleAnnotations": "Переключение аннотаций", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "На 1 кадр вперед", + "stepSecond": "На 1 секунду вперед" }, "actions": { "saveAgain": "Сохранить ещё раз", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 0b33ee4e3..07b92ed5c 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -151,7 +151,9 @@ "panTimeline": "平移时间线", "zoomTimeline": "缩放时间线", "cycleAnnotations": "循环切换注释", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "移动 1 帧", + "stepSecond": "移动 1 秒" }, "actions": { "saveAgain": "再次保存", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 18173561b..411a3fb28 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -151,7 +151,9 @@ "panTimeline": "平移時間線", "zoomTimeline": "縮放時間線", "cycleAnnotations": "循環切換註釋", - "tab": "Tab" + "tab": "Tab", + "stepFrame": "移動 1 幀", + "stepSecond": "移動 1 秒" }, "actions": { "saveAgain": "再次保存", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d43846930..923d4c071 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -37,6 +37,36 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [ display: "Del / ⌫", bindings: [{ key: "delete" }, { key: "backspace" }], }, + { + label: "Step Backward 1 Frame", + display: ", / ←", + bindings: [{ key: "," }, { key: "arrowleft" }], + }, + { + label: "Step Forward 1 Frame", + display: ". / →", + bindings: [{ key: "." }, { key: "arrowright" }], + }, + { + label: "Step Backward 1s", + display: "Shift + ←", + bindings: [{ key: "arrowleft", shift: true }], + }, + { + label: "Step Forward 1s", + display: "Shift + →", + bindings: [{ key: "arrowright", shift: true }], + }, + { + label: "Jump to Previous Keyframe", + display: "Alt + ←", + bindings: [{ key: "arrowleft", alt: true }], + }, + { + label: "Jump to Next Keyframe", + display: "Alt + →", + bindings: [{ key: "arrowright", alt: true }], + }, { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, ]; From 6f6a7dcf1b6f8f8955da780d59cfa1bf997ba539 Mon Sep 17 00:00:00 2001 From: Franco Rodriguez Date: Wed, 23 Sep 2026 23:34:46 +0200 Subject: [PATCH 2/3] refactor(editor): address review feedback on shortcuts and rapid frame stepping --- .../video-editor/KeyboardShortcutsHelp.tsx | 8 +++- .../hooks/useEditorPlaybackControls.test.ts | 38 +++++++++-------- .../hooks/useEditorPlaybackControls.ts | 41 +++++++++++-------- src/i18n/locales/de/editor.json | 3 +- src/i18n/locales/en/editor.json | 3 +- src/i18n/locales/es/editor.json | 5 ++- src/i18n/locales/fr/editor.json | 5 ++- src/i18n/locales/it/editor.json | 5 ++- src/i18n/locales/ko/editor.json | 3 +- src/i18n/locales/nl/editor.json | 5 ++- src/i18n/locales/pt-BR/editor.json | 5 ++- src/i18n/locales/ru/editor.json | 5 ++- src/i18n/locales/zh-CN/editor.json | 3 +- src/i18n/locales/zh-TW/editor.json | 3 +- src/lib/shortcuts.test.ts | 40 ++++++++++++++++++ src/lib/shortcuts.ts | 10 ++++- 16 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 src/lib/shortcuts.test.ts diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index dfba6a9f3..30eef1e0f 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -79,7 +79,7 @@ export function KeyboardShortcutsHelp() { {t("keyboardShortcuts.stepFrame")} - , / . + , / . / ← / →
@@ -87,6 +87,12 @@ export function KeyboardShortcutsHelp() { ⇧ + ← / →
+
+ + {t("keyboardShortcuts.jumpKeyframe")} + + ⌥ + ← / → +
diff --git a/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts b/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts index a388685e6..ae9c4346b 100644 --- a/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts +++ b/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts @@ -3,6 +3,7 @@ import { useEditorPlaybackControls } from "./useEditorPlaybackControls"; vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void) => effect(), useRef: (current: unknown) => ({ current }), })); @@ -65,13 +66,13 @@ describe("useEditorPlaybackControls frame stepping", () => { }); it("supports custom fps (e.g. 30fps) for frame stepping", () => { - const { controls, playback } = setup(2.0, 10.0); - - controls.stepFrameForward(30); - expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 + 1 / 30, 5)); + const { controls: c1, playback: p1 } = setup(2.0, 10.0); + c1.stepFrameForward(30); + expect(p1.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 + 1 / 30, 5)); - controls.stepFrameBackward(30); - expect(playback.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 - 1 / 30, 5)); + const { controls: c2, playback: p2 } = setup(2.0, 10.0); + c2.stepFrameBackward(30); + expect(p2.seekTimeline).toHaveBeenCalledWith(expect.closeTo(2.0 - 1 / 30, 5)); }); it("clamps frame stepping at 0 when stepping backward near start", () => { @@ -91,22 +92,25 @@ describe("useEditorPlaybackControls frame stepping", () => { }); it("steps time by custom seconds (e.g. +1s and -1s)", () => { - const { controls, playback } = setup(5.0, 10.0); - - controls.stepTimeSeconds(1); - expect(playback.seekTimeline).toHaveBeenCalledWith(6.0); + const { controls: c1, playback: p1 } = setup(5.0, 10.0); + c1.stepTimeSeconds(1); + expect(p1.seekTimeline).toHaveBeenCalledWith(6.0); - controls.stepTimeSeconds(-2.5); - expect(playback.seekTimeline).toHaveBeenCalledWith(2.5); + const { controls: c2, playback: p2 } = setup(5.0, 10.0); + c2.stepTimeSeconds(-2.5); + expect(p2.seekTimeline).toHaveBeenCalledWith(2.5); }); - it("clamps stepTimeSeconds within [0, duration]", () => { + it("accumulates rapid repeated frame steps correctly across keydowns", () => { const { controls, playback } = setup(1.0, 10.0); - controls.stepTimeSeconds(-5); - expect(playback.seekTimeline).toHaveBeenCalledWith(0); + controls.stepFrameForward(60); + expect(playback.seekTimeline).toHaveBeenLastCalledWith(expect.closeTo(1.0 + 1 / 60, 5)); - controls.stepTimeSeconds(20); - expect(playback.seekTimeline).toHaveBeenCalledWith(10.0); + controls.stepFrameForward(60); + expect(playback.seekTimeline).toHaveBeenLastCalledWith(expect.closeTo(1.0 + 2 / 60, 5)); + + controls.stepFrameForward(60); + expect(playback.seekTimeline).toHaveBeenLastCalledWith(expect.closeTo(1.0 + 3 / 60, 5)); }); }); diff --git a/src/components/video-editor/hooks/useEditorPlaybackControls.ts b/src/components/video-editor/hooks/useEditorPlaybackControls.ts index 1f957a163..677b00035 100644 --- a/src/components/video-editor/hooks/useEditorPlaybackControls.ts +++ b/src/components/video-editor/hooks/useEditorPlaybackControls.ts @@ -1,4 +1,4 @@ -import { type RefObject, useCallback } from "react"; +import { type RefObject, useCallback, useEffect, useRef } from "react"; import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; import type { VideoPlaybackRef } from "../VideoPlayback"; @@ -17,6 +17,12 @@ export function useEditorPlaybackControls({ timelinePlayheadTime, timelineDuration, }: UseEditorPlaybackControlsParams) { + const lastSeekTargetRef = useRef(timelinePlayheadTime); + + useEffect(() => { + lastSeekTargetRef.current = timelinePlayheadTime; + }, [timelinePlayheadTime]); + const getActivePlayback = useCallback(() => videoPlaybackRef.current, [videoPlaybackRef]); const startPlayback = useCallback(() => { @@ -42,6 +48,7 @@ export function useEditorPlaybackControls({ const video = playback?.video; if (!video) return; + lastSeekTargetRef.current = time; if (options.pause) playback.pause(); playback.seekTimeline(time); }, @@ -54,48 +61,50 @@ export function useEditorPlaybackControls({ ); const handlePreviewSkipBack = useCallback(() => { - const currentMs = timelinePlayheadTime * 1000; + const currentMs = lastSeekTargetRef.current * 1000; const keyframes = timelineRef.current?.keyframes ?? []; const previous = [...keyframes] .reverse() .find((keyframe) => keyframe.time < currentMs - 50); - handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); - }, [handleSeek, timelinePlayheadTime, timelineRef]); + handleSeek(previous ? previous.time / 1000 : Math.max(0, lastSeekTargetRef.current - 5)); + }, [handleSeek, timelineRef]); const handlePreviewSkipForward = useCallback(() => { - const currentMs = timelinePlayheadTime * 1000; + const currentMs = lastSeekTargetRef.current * 1000; const keyframes = timelineRef.current?.keyframes ?? []; const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); - handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); - }, [handleSeek, timelineDuration, timelinePlayheadTime, timelineRef]); + handleSeek( + next ? next.time / 1000 : Math.min(timelineDuration, lastSeekTargetRef.current + 5), + ); + }, [handleSeek, timelineDuration, timelineRef]); const stepFrameForward = useCallback( (fps = 60) => { const delta = 1 / Math.max(1, fps); - const targetTime = Math.min(timelineDuration, timelinePlayheadTime + delta); + const baseTime = lastSeekTargetRef.current; + const targetTime = Math.min(timelineDuration, baseTime + delta); handleSeek(targetTime, { pause: true }); }, - [handleSeek, timelineDuration, timelinePlayheadTime], + [handleSeek, timelineDuration], ); const stepFrameBackward = useCallback( (fps = 60) => { const delta = 1 / Math.max(1, fps); - const targetTime = Math.max(0, timelinePlayheadTime - delta); + const baseTime = lastSeekTargetRef.current; + const targetTime = Math.max(0, baseTime - delta); handleSeek(targetTime, { pause: true }); }, - [handleSeek, timelinePlayheadTime], + [handleSeek], ); const stepTimeSeconds = useCallback( (seconds: number) => { - const targetTime = Math.max( - 0, - Math.min(timelineDuration, timelinePlayheadTime + seconds), - ); + const baseTime = lastSeekTargetRef.current; + const targetTime = Math.max(0, Math.min(timelineDuration, baseTime + seconds)); handleSeek(targetTime, { pause: true }); }, - [handleSeek, timelineDuration, timelinePlayheadTime], + [handleSeek, timelineDuration], ); return { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 7c425d223..102d45554 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -152,7 +152,8 @@ "cycleAnnotations": "Anmerkungen durchlaufen", "tab": "Registerkarte", "stepFrame": "1 Einzelbild weiter/zurück", - "stepSecond": "1 Sekunde weiter/zurück" + "stepSecond": "1 Sekunde weiter/zurück", + "jumpKeyframe": "Zum Keyframe springen" }, "actions": { "saveAgain": "Erneut speichern", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 8096ae2c5..9d3dbc0a8 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -153,7 +153,8 @@ "cycleAnnotations": "Cycle Annotations", "tab": "Tab", "stepFrame": "Step 1 Frame", - "stepSecond": "Step 1 Second" + "stepSecond": "Step 1 Second", + "jumpKeyframe": "Jump to Keyframe" }, "actions": { "saveAgain": "Save Again", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 1e06017b9..5219ed439 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -152,8 +152,9 @@ "zoomTimeline": "Zoom en línea de tiempo", "cycleAnnotations": "Recorrer anotaciones", "tab": "Tab", - "stepFrame": "Avanzar 1 fotograma", - "stepSecond": "Avanzar 1 segundo" + "stepFrame": "Mover 1 fotograma", + "stepSecond": "Mover 1 segundo", + "jumpKeyframe": "Saltar al keyframe" }, "actions": { "saveAgain": "Guardar de nuevo", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index b3e1620d5..81e1a471d 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -152,8 +152,9 @@ "zoomTimeline": "Zoomer la timeline", "cycleAnnotations": "Parcourir les annotations", "tab": "Tab", - "stepFrame": "Avancer d'une image", - "stepSecond": "Avancer d'une seconde" + "stepFrame": "Déplacer d'une image", + "stepSecond": "Déplacer d'une seconde", + "jumpKeyframe": "Aller à l'image clé" }, "actions": { "saveAgain": "Enregistrer à nouveau", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index a93c020af..391754bc5 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -152,8 +152,9 @@ "zoomTimeline": "Zoom timeline", "cycleAnnotations": "Scorri annotazioni", "tab": "Tab", - "stepFrame": "Avanza di 1 fotogramma", - "stepSecond": "Avanza di 1 secondo" + "stepFrame": "Sposta di 1 fotogramma", + "stepSecond": "Sposta di 1 secondo", + "jumpKeyframe": "Vai al fotogramma chiave" }, "actions": { "saveAgain": "Salva di nuovo", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 4f4692bf6..5234ef075 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -154,7 +154,8 @@ "cycleAnnotations": "주석 순환", "tab": "Tab", "stepFrame": "1프레임 이동", - "stepSecond": "1초 이동" + "stepSecond": "1초 이동", + "jumpKeyframe": "키프레임으로 이동" }, "actions": { "saveAgain": "다시 저장", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index adcb42be6..37ac5498f 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -153,8 +153,9 @@ "zoomTimeline": "Tijdlijn zoomen", "cycleAnnotations": "Annotaties doorlopen", "tab": "Tab", - "stepFrame": "1 frame vooruit", - "stepSecond": "1 seconde vooruit" + "stepFrame": "1 frame verplaatsen", + "stepSecond": "1 seconde verplaatsen", + "jumpKeyframe": "Naar keyframe springen" }, "actions": { "saveAgain": "Opnieuw opslaan", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 927a777cd..f1039d8fb 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -152,8 +152,9 @@ "zoomTimeline": "Zoom da linha do tempo", "cycleAnnotations": "Alternar anotações", "tab": "Tab", - "stepFrame": "Avançar 1 quadro", - "stepSecond": "Avançar 1 segundo" + "stepFrame": "Mover 1 quadro", + "stepSecond": "Mover 1 segundo", + "jumpKeyframe": "Ir para o quadro-chave" }, "actions": { "saveAgain": "Salvar novamente", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index b633ac404..a9700043b 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -152,8 +152,9 @@ "zoomTimeline": "Увеличение таймлайна", "cycleAnnotations": "Переключение аннотаций", "tab": "Tab", - "stepFrame": "На 1 кадр вперед", - "stepSecond": "На 1 секунду вперед" + "stepFrame": "Сдвиг на 1 кадр", + "stepSecond": "Сдвиг на 1 секунду", + "jumpKeyframe": "Перейти к ключевому кадру" }, "actions": { "saveAgain": "Сохранить ещё раз", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 07b92ed5c..39abb4d8a 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -153,7 +153,8 @@ "cycleAnnotations": "循环切换注释", "tab": "Tab", "stepFrame": "移动 1 帧", - "stepSecond": "移动 1 秒" + "stepSecond": "移动 1 秒", + "jumpKeyframe": "跳转到关键帧" }, "actions": { "saveAgain": "再次保存", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 411a3fb28..df4e98907 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -153,7 +153,8 @@ "cycleAnnotations": "循環切換註釋", "tab": "Tab", "stepFrame": "移動 1 幀", - "stepSecond": "移動 1 秒" + "stepSecond": "移動 1 秒", + "jumpKeyframe": "跳轉至關鍵影格" }, "actions": { "saveAgain": "再次保存", diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts new file mode 100644 index 000000000..39f989d29 --- /dev/null +++ b/src/lib/shortcuts.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SHORTCUTS, + FIXED_SHORTCUTS, + mergeWithDefaults, + type ShortcutsConfig, +} from "./shortcuts"; + +describe("shortcuts configuration and mergeWithDefaults", () => { + it("preserves valid saved shortcuts", () => { + const saved: Partial = { + splitClip: { key: "x" }, + }; + const merged = mergeWithDefaults(saved); + expect(merged.splitClip).toEqual({ key: "x" }); + expect(merged.addZoom).toEqual(DEFAULT_SHORTCUTS.addZoom); + }); + + it("rejects saved shortcuts that conflict with FIXED_SHORTCUTS", () => { + const saved: Partial = { + // Comma is a fixed shortcut for Step Backward 1 Frame + splitClip: { key: "," }, + // Period is a fixed shortcut for Step Forward 1 Frame + addAnnotation: { key: "." }, + }; + const merged = mergeWithDefaults(saved); + expect(merged.splitClip).toEqual(DEFAULT_SHORTCUTS.splitClip); + expect(merged.addAnnotation).toEqual(DEFAULT_SHORTCUTS.addAnnotation); + }); + + it("contains frame stepping and timeline jump in FIXED_SHORTCUTS", () => { + const labels = FIXED_SHORTCUTS.map((s) => s.label); + expect(labels).toContain("Step Backward 1 Frame"); + expect(labels).toContain("Step Forward 1 Frame"); + expect(labels).toContain("Step Backward 1s"); + expect(labels).toContain("Step Forward 1s"); + expect(labels).toContain("Jump to Previous Keyframe"); + expect(labels).toContain("Jump to Next Keyframe"); + }); +}); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 923d4c071..a5068b214 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -158,8 +158,14 @@ export function formatBinding(binding: ShortcutBinding, isMac: boolean): string export function mergeWithDefaults(partial: Partial): ShortcutsConfig { const merged = { ...DEFAULT_SHORTCUTS }; for (const action of SHORTCUT_ACTIONS) { - if (partial[action]) { - merged[action] = partial[action] as ShortcutBinding; + const candidate = partial[action]; + if (candidate && typeof candidate.key === "string") { + const conflictsWithFixed = FIXED_SHORTCUTS.some((fixed) => + fixed.bindings.some((b) => bindingsEqual(b, candidate)), + ); + if (!conflictsWithFixed) { + merged[action] = candidate; + } } } return merged; From ba868774f40d15b54f136279c1cf343105014de2 Mon Sep 17 00:00:00 2001 From: Franco Rodriguez Date: Wed, 23 Sep 2026 23:48:44 +0200 Subject: [PATCH 3/3] fix(editor): make modifier symbols in shortcuts help platform-aware --- src/components/video-editor/KeyboardShortcutsHelp.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 30eef1e0f..f08132ba1 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -85,13 +85,13 @@ export function KeyboardShortcutsHelp() { {t("keyboardShortcuts.stepSecond")} - ⇧ + ← / → + {isMac ? "⇧" : "Shift"} + ← / →
{t("keyboardShortcuts.jumpKeyframe")} - ⌥ + ← / → + {isMac ? "⌥" : "Alt"} + ← / →