mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Refactor keyboard shortcuts management: introduce useKeyboardShortcutsHelp hook and update KeyBadge and ShortcutItem components
This commit is contained in:
@@ -12,7 +12,10 @@ import {
|
|||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Keyboard } from "lucide-react";
|
import { Keyboard } from "lucide-react";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import {
|
||||||
|
useKeyboardShortcutsHelp,
|
||||||
|
KeyboardShortcut,
|
||||||
|
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||||
|
|
||||||
const KeyBadge = ({ keyName }: { keyName: string }) => {
|
const KeyBadge = ({ keyName }: { keyName: string }) => {
|
||||||
// Replace common key names with symbols or friendly names
|
// Replace common key names with symbols or friendly names
|
||||||
@@ -34,7 +37,7 @@ const KeyBadge = ({ keyName }: { keyName: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
|
const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => {
|
||||||
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
|
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
|
||||||
const displayKeys = shortcut.keys.filter((key: string) => {
|
const displayKeys = shortcut.keys.filter((key: string) => {
|
||||||
const lowerKey = key.toLowerCase();
|
const lowerKey = key.toLowerCase();
|
||||||
@@ -86,8 +89,8 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
|
|||||||
export const KeyboardShortcutsHelp = () => {
|
export const KeyboardShortcutsHelp = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Get shortcuts from centralized hook (disabled so it doesn't add event listeners)
|
// Get shortcuts from centralized hook
|
||||||
const { shortcuts } = useKeyboardShortcuts({ enabled: false });
|
const { shortcuts } = useKeyboardShortcutsHelp();
|
||||||
|
|
||||||
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { bindings } from "@/constants/keybindings";
|
||||||
|
import { Action } from "@/constants/actions";
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
id: string;
|
||||||
|
keys: string[];
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
action: Action;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map actions to their descriptions and categories
|
||||||
|
const actionDescriptions: Record<
|
||||||
|
Action,
|
||||||
|
{ description: string; category: string }
|
||||||
|
> = {
|
||||||
|
"toggle-play": { description: "Play/Pause", category: "Playback" },
|
||||||
|
"stop-playback": { description: "Stop playback", category: "Playback" },
|
||||||
|
"seek-forward": {
|
||||||
|
description: "Seek forward 1 second",
|
||||||
|
category: "Playback",
|
||||||
|
},
|
||||||
|
"seek-backward": {
|
||||||
|
description: "Seek backward 1 second",
|
||||||
|
category: "Playback",
|
||||||
|
},
|
||||||
|
"frame-step-forward": {
|
||||||
|
description: "Frame step forward",
|
||||||
|
category: "Navigation",
|
||||||
|
},
|
||||||
|
"frame-step-backward": {
|
||||||
|
description: "Frame step backward",
|
||||||
|
category: "Navigation",
|
||||||
|
},
|
||||||
|
"jump-forward": {
|
||||||
|
description: "Jump forward 5 seconds",
|
||||||
|
category: "Navigation",
|
||||||
|
},
|
||||||
|
"jump-backward": {
|
||||||
|
description: "Jump backward 5 seconds",
|
||||||
|
category: "Navigation",
|
||||||
|
},
|
||||||
|
"goto-start": { description: "Go to timeline start", category: "Navigation" },
|
||||||
|
"goto-end": { description: "Go to timeline end", category: "Navigation" },
|
||||||
|
"split-element": {
|
||||||
|
description: "Split element at playhead",
|
||||||
|
category: "Editing",
|
||||||
|
},
|
||||||
|
"delete-selected": {
|
||||||
|
description: "Delete selected elements",
|
||||||
|
category: "Editing",
|
||||||
|
},
|
||||||
|
"select-all": { description: "Select all elements", category: "Selection" },
|
||||||
|
"duplicate-selected": {
|
||||||
|
description: "Duplicate selected element",
|
||||||
|
category: "Selection",
|
||||||
|
},
|
||||||
|
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
||||||
|
undo: { description: "Undo", category: "History" },
|
||||||
|
redo: { description: "Redo", category: "History" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert key binding format to display format
|
||||||
|
const formatKey = (key: string): string => {
|
||||||
|
return key
|
||||||
|
.replace("ctrl", "Cmd")
|
||||||
|
.replace("alt", "Alt")
|
||||||
|
.replace("shift", "Shift")
|
||||||
|
.replace("left", "ArrowLeft")
|
||||||
|
.replace("right", "ArrowRight")
|
||||||
|
.replace("up", "ArrowUp")
|
||||||
|
.replace("down", "ArrowDown")
|
||||||
|
.replace("space", "Space")
|
||||||
|
.replace("home", "Home")
|
||||||
|
.replace("end", "End")
|
||||||
|
.replace("delete", "Delete")
|
||||||
|
.replace("backspace", "Backspace")
|
||||||
|
.replace("-", "+");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useKeyboardShortcutsHelp = () => {
|
||||||
|
const shortcuts = useMemo(() => {
|
||||||
|
const result: KeyboardShortcut[] = [];
|
||||||
|
|
||||||
|
// Group keybindings by action
|
||||||
|
const actionToKeys: Record<Action, string[]> = {} as any;
|
||||||
|
|
||||||
|
Object.entries(bindings).forEach(([key, action]) => {
|
||||||
|
if (action) {
|
||||||
|
if (!actionToKeys[action]) {
|
||||||
|
actionToKeys[action] = [];
|
||||||
|
}
|
||||||
|
actionToKeys[action].push(formatKey(key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to shortcuts format
|
||||||
|
Object.entries(actionToKeys).forEach(([action, keys]) => {
|
||||||
|
const actionInfo = actionDescriptions[action as Action];
|
||||||
|
if (actionInfo) {
|
||||||
|
result.push({
|
||||||
|
id: action,
|
||||||
|
keys,
|
||||||
|
description: actionInfo.description,
|
||||||
|
category: actionInfo.category,
|
||||||
|
action: action as Action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shortcuts,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
"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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rewind",
|
|
||||||
keys: ["j", "J"],
|
|
||||||
description: "Rewind 1 second",
|
|
||||||
category: "Playback",
|
|
||||||
action: () => {
|
|
||||||
seek(Math.max(0, currentTime - 1));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "play-pause-alt",
|
|
||||||
keys: ["k", "K"],
|
|
||||||
description: "Play/Pause (alternative)",
|
|
||||||
category: "Playback",
|
|
||||||
action: () => {
|
|
||||||
toggle();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fast-forward",
|
|
||||||
keys: ["l", "L"],
|
|
||||||
description: "Fast forward 1 second",
|
|
||||||
category: "Playback",
|
|
||||||
action: () => {
|
|
||||||
seek(Math.min(duration, currentTime + 1));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goto-end",
|
|
||||||
keys: ["End"],
|
|
||||||
description: "Go to timeline end",
|
|
||||||
category: "Navigation",
|
|
||||||
action: () => {
|
|
||||||
seek(duration);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
} 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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user