Merge branch 'feat/shortcut-actions' into staging

This commit is contained in:
Maze Winther
2025-07-18 11:37:48 +02:00
12 changed files with 1486 additions and 351 deletions
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useEffect } from "react";
import { useActionHandler } from "@/constants/actions";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
import { toast } from "sonner";
export function useEditorActions() {
const {
tracks,
selectedElements,
clearSelectedElements,
setSelectedElements,
removeElementFromTrack,
splitElement,
addElementToTrack,
snappingEnabled,
toggleSnapping,
undo,
redo,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { activeProject } = useProjectStore();
// Playback actions
useActionHandler("toggle-play", () => {
toggle();
});
useActionHandler("stop-playback", () => {
if (isPlaying) {
toggle();
}
seek(0);
});
useActionHandler("seek-forward", (args) => {
const seconds = args?.seconds ?? 1;
seek(Math.min(duration, currentTime + seconds));
});
useActionHandler("seek-backward", (args) => {
const seconds = args?.seconds ?? 1;
seek(Math.max(0, currentTime - seconds));
});
useActionHandler("frame-step-forward", () => {
const projectFps = activeProject?.fps || 30;
seek(Math.min(duration, currentTime + 1 / projectFps));
});
useActionHandler("frame-step-backward", () => {
const projectFps = activeProject?.fps || 30;
seek(Math.max(0, currentTime - 1 / projectFps));
});
useActionHandler("jump-forward", (args) => {
const seconds = args?.seconds ?? 5;
seek(Math.min(duration, currentTime + seconds));
});
useActionHandler("jump-backward", (args) => {
const seconds = args?.seconds ?? 5;
seek(Math.max(0, currentTime - seconds));
});
useActionHandler("goto-start", () => {
seek(0);
});
useActionHandler("goto-end", () => {
seek(duration);
});
// Timeline editing actions
useActionHandler("split-element", () => {
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");
}
}
});
useActionHandler("delete-selected", () => {
if (selectedElements.length === 0) {
toast.error("No elements selected");
return;
}
selectedElements.forEach(
({ trackId, elementId }: { trackId: string; elementId: string }) => {
removeElementFromTrack(trackId, elementId);
}
);
clearSelectedElements();
});
useActionHandler("select-all", () => {
const allElements = tracks.flatMap((track: any) =>
track.elements.map((element: any) => ({
trackId: track.id,
elementId: element.id,
}))
);
setSelectedElements(allElements);
});
useActionHandler("duplicate-selected", () => {
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,
});
}
});
useActionHandler("toggle-snapping", () => {
toggleSnapping();
});
// History actions
useActionHandler("undo", () => {
undo();
});
useActionHandler("redo", () => {
redo();
});
}
@@ -0,0 +1,59 @@
"use client";
import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { ActionWithOptionalArgs } from "@/constants/actions";
export interface KeybindingConflictInfo {
key: string;
actions: ActionWithOptionalArgs[];
isConflict: boolean;
}
export const useKeybindingConflicts = () => {
const { keybindings } = useKeybindingsStore();
const conflicts = useMemo(() => {
const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
const conflictList: KeybindingConflictInfo[] = [];
// Group actions by key
Object.entries(keybindings).forEach(([key, action]) => {
if (!keyToActions[key]) {
keyToActions[key] = [];
}
keyToActions[key].push(action);
});
// Find conflicts
Object.entries(keyToActions).forEach(([key, actions]) => {
const uniqueActions = [...new Set(actions)];
conflictList.push({
key,
actions: uniqueActions,
isConflict: uniqueActions.length > 1,
});
});
return conflictList.filter((item) => item.isConflict);
}, [keybindings]);
const hasConflicts = conflicts.length > 0;
const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
return conflicts.find((conflict) => conflict.key === key) || null;
};
const getConflictsForAction = (
action: ActionWithOptionalArgs
): KeybindingConflictInfo[] => {
return conflicts.filter((conflict) => conflict.actions.includes(action));
};
return {
conflicts,
hasConflicts,
getConflictsForKey,
getConflictsForAction,
};
};
+64
View File
@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { invokeAction } from "../constants/actions";
import { useKeybindingsStore } from "@/stores/keybindings-store";
/**
* A composable that hooks to the caller component's
* lifecycle and hooks to the keyboard events to fire
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
const { keybindings, getKeybindingString, keybindingsEnabled } =
useKeybindingsStore();
useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => {
// Do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return;
const binding = getKeybindingString(ev);
if (!binding) return;
const boundAction = keybindings[binding];
if (!boundAction) return;
ev.preventDefault();
// Handle actions with default arguments
let actionArgs: any = undefined;
if (boundAction === "seek-forward") {
actionArgs = { seconds: 1 };
} else if (boundAction === "seek-backward") {
actionArgs = { seconds: 1 };
} else if (boundAction === "jump-forward") {
actionArgs = { seconds: 5 };
} else if (boundAction === "jump-backward") {
actionArgs = { seconds: 5 };
}
invokeAction(boundAction, actionArgs, "keypress");
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [keybindings, getKeybindingString, keybindingsEnabled]);
}
/**
* This composable allows for the UI component to be disabled if the component in question is mounted
*/
export function useKeybindingDisabler() {
const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
return {
disableKeybindings,
enableKeybindings,
};
}
// Export the bindings for backward compatibility
export const bindings = {};
@@ -0,0 +1,123 @@
"use client";
import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store";
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 { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => {
const result: KeyboardShortcut[] = [];
// Group keybindings by action
const actionToKeys: Record<Action, string[]> = {} as any;
Object.entries(keybindings).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;
}, [keybindings]);
return {
shortcuts,
};
};
@@ -1,338 +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"],
description: "Rewind 1 second",
category: "Playback",
action: () => {
seek(Math.max(0, currentTime - 1));
},
},
{
id: "play-pause-alt",
keys: ["K"],
description: "Play/Pause (alternative)",
category: "Playback",
action: () => {
toggle();
},
},
{
id: "fast-forward",
keys: ["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"],
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"],
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("+").toLowerCase();
}, []);
// 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.toLowerCase() === keyCombo ||
key.toLowerCase() === e.key.toLowerCase()
)
);
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,
};
};