mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'main' into feature/optimize-timeline-ruler
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: () => void;
|
||||
enabled?: boolean;
|
||||
requiresSelection?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UseKeyboardShortcutsOptions {
|
||||
enabled?: boolean;
|
||||
context?: "global" | "timeline" | "editor";
|
||||
}
|
||||
|
||||
export const useKeyboardShortcuts = (
|
||||
options: UseKeyboardShortcutsOptions = {}
|
||||
) => {
|
||||
const { enabled = true, context = "editor" } = options;
|
||||
|
||||
const {
|
||||
tracks,
|
||||
selectedElements,
|
||||
clearSelectedElements,
|
||||
setSelectedElements,
|
||||
removeElementFromTrack,
|
||||
splitElement,
|
||||
addElementToTrack,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
undo,
|
||||
redo,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Check if user is typing in an input field
|
||||
const isInputFocused = useCallback(() => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.contentEditable === "true")
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Define all shortcuts in one place
|
||||
const shortcuts: KeyboardShortcut[] = [
|
||||
// Playback Controls
|
||||
{
|
||||
id: "play-pause",
|
||||
keys: ["Space"],
|
||||
description: "Play/Pause",
|
||||
category: "Playback",
|
||||
action: () => {
|
||||
toggle();
|
||||
toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rewind",
|
||||
keys: ["j", "J"],
|
||||
description: "Rewind 1 second",
|
||||
category: "Playback",
|
||||
action: () => {
|
||||
seek(Math.max(0, currentTime - 1));
|
||||
toast.info("Rewind 1s", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "play-pause-alt",
|
||||
keys: ["k", "K"],
|
||||
description: "Play/Pause (alternative)",
|
||||
category: "Playback",
|
||||
action: () => {
|
||||
toggle();
|
||||
toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fast-forward",
|
||||
keys: ["l", "L"],
|
||||
description: "Fast forward 1 second",
|
||||
category: "Playback",
|
||||
action: () => {
|
||||
seek(Math.min(duration, currentTime + 1));
|
||||
toast.info("Forward 1s", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
|
||||
// Navigation
|
||||
{
|
||||
id: "frame-backward",
|
||||
keys: ["ArrowLeft"],
|
||||
description: "Frame step backward",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
seek(Math.max(0, currentTime - 1 / projectFps));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "frame-forward",
|
||||
keys: ["ArrowRight"],
|
||||
description: "Frame step forward",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
seek(Math.min(duration, currentTime + 1 / projectFps));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "jump-backward",
|
||||
keys: ["Shift+ArrowLeft"],
|
||||
description: "Jump back 5 seconds",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
seek(Math.max(0, currentTime - 5));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "jump-forward",
|
||||
keys: ["Shift+ArrowRight"],
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
seek(Math.min(duration, currentTime + 5));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goto-start",
|
||||
keys: ["Home"],
|
||||
description: "Go to timeline start",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
seek(0);
|
||||
toast.info("Start of timeline", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goto-end",
|
||||
keys: ["End"],
|
||||
description: "Go to timeline end",
|
||||
category: "Navigation",
|
||||
action: () => {
|
||||
seek(duration);
|
||||
toast.info("End of timeline", { duration: 1000 });
|
||||
},
|
||||
},
|
||||
|
||||
// Editing
|
||||
{
|
||||
id: "split-element",
|
||||
keys: ["s", "S"],
|
||||
description: "Split element at playhead",
|
||||
category: "Editing",
|
||||
requiresSelection: true,
|
||||
action: () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||
splitElement(trackId, elementId, currentTime);
|
||||
toast.success("Element split at playhead");
|
||||
} else {
|
||||
toast.error("Playhead must be within selected element");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete-elements",
|
||||
keys: ["Delete", "Backspace"],
|
||||
description: "Delete selected elements",
|
||||
category: "Editing",
|
||||
requiresSelection: true,
|
||||
action: () => {
|
||||
if (selectedElements.length === 0) {
|
||||
toast.error("No elements selected");
|
||||
return;
|
||||
}
|
||||
selectedElements.forEach(
|
||||
({ trackId, elementId }: { trackId: string; elementId: string }) => {
|
||||
removeElementFromTrack(trackId, elementId);
|
||||
}
|
||||
);
|
||||
clearSelectedElements();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "toggle-snapping",
|
||||
keys: ["n", "N"],
|
||||
description: "Toggle snapping",
|
||||
category: "Editing",
|
||||
action: () => {
|
||||
toggleSnapping();
|
||||
toast.info(`Snapping ${snappingEnabled ? "disabled" : "enabled"}`, {
|
||||
duration: 1000,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Selection & Organization
|
||||
{
|
||||
id: "select-all",
|
||||
keys: ["Cmd+a", "Ctrl+a"],
|
||||
description: "Select all elements",
|
||||
category: "Selection",
|
||||
action: () => {
|
||||
const allElements = tracks.flatMap((track: any) =>
|
||||
track.elements.map((element: any) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
}))
|
||||
);
|
||||
setSelectedElements(allElements);
|
||||
toast.info(`Selected ${allElements.length} elements`, {
|
||||
duration: 1000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "duplicate-element",
|
||||
keys: ["Cmd+d", "Ctrl+d"],
|
||||
description: "Duplicate selected element",
|
||||
category: "Selection",
|
||||
requiresSelection: true,
|
||||
action: () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to duplicate");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
|
||||
toast.success("Element duplicated");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
{
|
||||
id: "undo",
|
||||
keys: ["Cmd+z", "Ctrl+z"],
|
||||
description: "Undo",
|
||||
category: "History",
|
||||
action: () => {
|
||||
undo();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
keys: ["Cmd+Shift+z", "Ctrl+Shift+z", "Cmd+y", "Ctrl+y"],
|
||||
description: "Redo",
|
||||
category: "History",
|
||||
action: () => {
|
||||
redo();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Parse keyboard event to match against shortcuts
|
||||
const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
|
||||
parts.push(e.key);
|
||||
|
||||
return parts.join("+");
|
||||
}, []);
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!enabled || isInputFocused()) return;
|
||||
|
||||
const keyCombo = parseKeyboardEvent(e);
|
||||
const shortcut = shortcuts.find((s) =>
|
||||
s.keys.some((key) => key === keyCombo || key === e.key)
|
||||
);
|
||||
|
||||
if (shortcut) {
|
||||
// Check if shortcut requires selection
|
||||
if (shortcut.requiresSelection && selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
shortcut.action();
|
||||
}
|
||||
},
|
||||
[enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused]
|
||||
);
|
||||
|
||||
// Set up event listener
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, handleKeyDown]);
|
||||
|
||||
// Return shortcuts for help component
|
||||
return {
|
||||
shortcuts: shortcuts.filter((s) => s.enabled !== false),
|
||||
enabled,
|
||||
};
|
||||
};
|
||||
@@ -106,68 +106,4 @@ export const usePlaybackControls = () => {
|
||||
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitSelectedElement();
|
||||
}
|
||||
break;
|
||||
|
||||
case "q":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepLeftCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "w":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepRightCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "d":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSeparateAudioCallback();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
handleSplitSelectedElement,
|
||||
handleSplitAndKeepLeftCallback,
|
||||
handleSplitAndKeepRightCallback,
|
||||
handleSeparateAudioCallback,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
return () => document.removeEventListener("keydown", handleKeyPress);
|
||||
}, [handleKeyPress]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user