diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx
index dc1e3c2d1..f08132ba1 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,24 @@ export function KeyboardShortcutsHelp() {
{t("keyboardShortcuts.tab")}
+
+
+ {t("keyboardShortcuts.stepFrame")}
+
+ , / . / ← / →
+
+
+
+ {t("keyboardShortcuts.stepSecond")}
+
+ {isMac ? "⇧" : "Shift"} + ← / →
+
+
+
+ {t("keyboardShortcuts.jumpKeyframe")}
+
+ {isMac ? "⌥" : "Alt"} + ← / →
+
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..ae9c4346b
--- /dev/null
+++ b/src/components/video-editor/hooks/useEditorPlaybackControls.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi } from "vitest";
+import { useEditorPlaybackControls } from "./useEditorPlaybackControls";
+
+vi.mock("react", () => ({
+ useCallback: (callback: unknown) => callback,
+ useEffect: (effect: () => void) => effect(),
+ 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: c1, playback: p1 } = setup(2.0, 10.0);
+ c1.stepFrameForward(30);
+ expect(p1.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", () => {
+ 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: c1, playback: p1 } = setup(5.0, 10.0);
+ c1.stepTimeSeconds(1);
+ expect(p1.seekTimeline).toHaveBeenCalledWith(6.0);
+
+ const { controls: c2, playback: p2 } = setup(5.0, 10.0);
+ c2.stepTimeSeconds(-2.5);
+ expect(p2.seekTimeline).toHaveBeenCalledWith(2.5);
+ });
+
+ it("accumulates rapid repeated frame steps correctly across keydowns", () => {
+ const { controls, playback } = setup(1.0, 10.0);
+
+ controls.stepFrameForward(60);
+ expect(playback.seekTimeline).toHaveBeenLastCalledWith(expect.closeTo(1.0 + 1 / 60, 5));
+
+ 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 31b4f878e..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,20 +61,51 @@ 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 baseTime = lastSeekTargetRef.current;
+ const targetTime = Math.min(timelineDuration, baseTime + delta);
+ handleSeek(targetTime, { pause: true });
+ },
+ [handleSeek, timelineDuration],
+ );
+
+ const stepFrameBackward = useCallback(
+ (fps = 60) => {
+ const delta = 1 / Math.max(1, fps);
+ const baseTime = lastSeekTargetRef.current;
+ const targetTime = Math.max(0, baseTime - delta);
+ handleSeek(targetTime, { pause: true });
+ },
+ [handleSeek],
+ );
+
+ const stepTimeSeconds = useCallback(
+ (seconds: number) => {
+ const baseTime = lastSeekTargetRef.current;
+ const targetTime = Math.max(0, Math.min(timelineDuration, baseTime + seconds));
+ handleSeek(targetTime, { pause: true });
+ },
+ [handleSeek, timelineDuration],
+ );
return {
startPlayback,
@@ -76,5 +114,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..102d45554 100644
--- a/src/i18n/locales/de/editor.json
+++ b/src/i18n/locales/de/editor.json
@@ -150,7 +150,10 @@
"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",
+ "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 98200e9d3..9d3dbc0a8 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline",
"cycleAnnotations": "Cycle Annotations",
- "tab": "Tab"
+ "tab": "Tab",
+ "stepFrame": "Step 1 Frame",
+ "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 fea01b37c..5219ed439 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo",
"cycleAnnotations": "Recorrer anotaciones",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 8ff965d96..81e1a471d 100644
--- a/src/i18n/locales/fr/editor.json
+++ b/src/i18n/locales/fr/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Déplacer la timeline",
"zoomTimeline": "Zoomer la timeline",
"cycleAnnotations": "Parcourir les annotations",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 3e3e63441..391754bc5 100644
--- a/src/i18n/locales/it/editor.json
+++ b/src/i18n/locales/it/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Sposta timeline",
"zoomTimeline": "Zoom timeline",
"cycleAnnotations": "Scorri annotazioni",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 908ab3415..5234ef075 100644
--- a/src/i18n/locales/ko/editor.json
+++ b/src/i18n/locales/ko/editor.json
@@ -152,7 +152,10 @@
"panTimeline": "타임라인 이동",
"zoomTimeline": "타임라인 확대/축소",
"cycleAnnotations": "주석 순환",
- "tab": "Tab"
+ "tab": "Tab",
+ "stepFrame": "1프레임 이동",
+ "stepSecond": "1초 이동",
+ "jumpKeyframe": "키프레임으로 이동"
},
"actions": {
"saveAgain": "다시 저장",
diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json
index 7348ca083..37ac5498f 100644
--- a/src/i18n/locales/nl/editor.json
+++ b/src/i18n/locales/nl/editor.json
@@ -152,7 +152,10 @@
"panTimeline": "Tijdlijn verschuiven",
"zoomTimeline": "Tijdlijn zoomen",
"cycleAnnotations": "Annotaties doorlopen",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 ea318d81e..f1039d8fb 100644
--- a/src/i18n/locales/pt-BR/editor.json
+++ b/src/i18n/locales/pt-BR/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Mover linha do tempo",
"zoomTimeline": "Zoom da linha do tempo",
"cycleAnnotations": "Alternar anotações",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 a9b89aeb0..a9700043b 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "Перемещение по таймлайну",
"zoomTimeline": "Увеличение таймлайна",
"cycleAnnotations": "Переключение аннотаций",
- "tab": "Tab"
+ "tab": "Tab",
+ "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 0b33ee4e3..39abb4d8a 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "平移时间线",
"zoomTimeline": "缩放时间线",
"cycleAnnotations": "循环切换注释",
- "tab": "Tab"
+ "tab": "Tab",
+ "stepFrame": "移动 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 18173561b..df4e98907 100644
--- a/src/i18n/locales/zh-TW/editor.json
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -151,7 +151,10 @@
"panTimeline": "平移時間線",
"zoomTimeline": "縮放時間線",
"cycleAnnotations": "循環切換註釋",
- "tab": "Tab"
+ "tab": "Tab",
+ "stepFrame": "移動 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 d43846930..a5068b214 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: [] },
];
@@ -128,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;