mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'feat/shortcut-actions' into staging
This commit is contained in:
@@ -3,7 +3,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import {
|
||||||
|
useKeybindingsListener,
|
||||||
|
useKeybindingDisabler,
|
||||||
|
} from "@/hooks/use-keybindings";
|
||||||
|
import { useEditorActions } from "@/hooks/use-editor-actions";
|
||||||
|
|
||||||
interface EditorProviderProps {
|
interface EditorProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -11,11 +15,22 @@ interface EditorProviderProps {
|
|||||||
|
|
||||||
export function EditorProvider({ children }: EditorProviderProps) {
|
export function EditorProvider({ children }: EditorProviderProps) {
|
||||||
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
|
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
|
||||||
|
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
// Set up action handlers
|
||||||
enabled: !isInitializing && isPanelsReady,
|
useEditorActions();
|
||||||
context: "editor",
|
|
||||||
});
|
// Set up keybinding listener
|
||||||
|
useKeybindingsListener();
|
||||||
|
|
||||||
|
// Disable keybindings when initializing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializing || !isPanelsReady) {
|
||||||
|
disableKeybindings();
|
||||||
|
} else {
|
||||||
|
enableKeybindings();
|
||||||
|
}
|
||||||
|
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeApp();
|
initializeApp();
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
|
import { Action } from "@/constants/actions";
|
||||||
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts-help";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
RotateCcw,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface KeybindingEditorProps {
|
||||||
|
shortcuts: KeyboardShortcut[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyRecorderProps {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyRecorder = ({ value, onValueChange, onCancel }: KeyRecorderProps) => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [recordedKey, setRecordedKey] = useState("");
|
||||||
|
const { getKeybindingString } = useKeybindingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecording) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const keyString = getKeybindingString(e);
|
||||||
|
if (keyString) {
|
||||||
|
setRecordedKey(keyString);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isRecording, getKeybindingString]);
|
||||||
|
|
||||||
|
const handleStartRecording = () => {
|
||||||
|
setIsRecording(true);
|
||||||
|
setRecordedKey("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onValueChange(recordedKey);
|
||||||
|
setIsRecording(false);
|
||||||
|
setRecordedKey("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsRecording(false);
|
||||||
|
setRecordedKey("");
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayKey = recordedKey || value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={displayKey}
|
||||||
|
readOnly
|
||||||
|
placeholder={isRecording ? "Press keys..." : "Click to record"}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
onClick={handleStartRecording}
|
||||||
|
/>
|
||||||
|
{isRecording ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!recordedKey}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleCancel}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleStartRecording}>
|
||||||
|
Record
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KeybindingEditor = ({
|
||||||
|
shortcuts,
|
||||||
|
onClose,
|
||||||
|
}: KeybindingEditorProps) => {
|
||||||
|
const {
|
||||||
|
keybindings,
|
||||||
|
updateKeybinding,
|
||||||
|
removeKeybinding,
|
||||||
|
resetToDefaults,
|
||||||
|
isCustomized,
|
||||||
|
validateKeybinding,
|
||||||
|
getKeybindingsForAction,
|
||||||
|
exportKeybindings,
|
||||||
|
importKeybindings,
|
||||||
|
} = useKeybindingsStore();
|
||||||
|
|
||||||
|
const [editingShortcut, setEditingShortcut] = useState<string | null>(null);
|
||||||
|
const [newKeyBinding, setNewKeyBinding] = useState("");
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"all",
|
||||||
|
...Array.from(new Set(shortcuts.map((s) => s.category))),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredShortcuts = shortcuts.filter((shortcut) => {
|
||||||
|
const matchesSearch =
|
||||||
|
shortcut.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
shortcut.keys.some((key) =>
|
||||||
|
key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
const matchesCategory =
|
||||||
|
selectedCategory === "all" || shortcut.category === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditShortcut = (shortcut: KeyboardShortcut) => {
|
||||||
|
setEditingShortcut(shortcut.id);
|
||||||
|
setNewKeyBinding(shortcut.keys[0] || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveShortcut = () => {
|
||||||
|
if (!editingShortcut || !newKeyBinding) return;
|
||||||
|
|
||||||
|
const shortcut = shortcuts.find((s) => s.id === editingShortcut);
|
||||||
|
if (!shortcut) return;
|
||||||
|
|
||||||
|
// Validate the new keybinding
|
||||||
|
const conflict = validateKeybinding(newKeyBinding, shortcut.action);
|
||||||
|
if (conflict) {
|
||||||
|
toast.error(
|
||||||
|
`Key "${newKeyBinding}" is already bound to "${conflict.existingAction}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old keybindings for this action
|
||||||
|
const oldKeys = getKeybindingsForAction(shortcut.action);
|
||||||
|
oldKeys.forEach((key) => removeKeybinding(key));
|
||||||
|
|
||||||
|
// Add new keybinding
|
||||||
|
updateKeybinding(newKeyBinding, shortcut.action);
|
||||||
|
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setNewKeyBinding("");
|
||||||
|
toast.success("Keybinding updated successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setNewKeyBinding("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveShortcut = (shortcut: KeyboardShortcut) => {
|
||||||
|
const keys = getKeybindingsForAction(shortcut.action);
|
||||||
|
keys.forEach((key) => removeKeybinding(key));
|
||||||
|
toast.success("Keybinding removed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetToDefaults = () => {
|
||||||
|
resetToDefaults();
|
||||||
|
setShowResetDialog(false);
|
||||||
|
toast.success("Keybindings reset to defaults");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportKeybindings = () => {
|
||||||
|
const config = exportKeybindings();
|
||||||
|
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "opencut-keybindings.json";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Keybindings exported");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportKeybindings = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(e.target?.result as string);
|
||||||
|
// Validate config structure
|
||||||
|
if (!config || typeof config !== "object") {
|
||||||
|
throw new Error("Invalid configuration format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each keybinding
|
||||||
|
for (const [key, action] of Object.entries(config)) {
|
||||||
|
if (typeof key !== "string" || typeof action !== "string") {
|
||||||
|
throw new Error(`Invalid keybinding: ${key} -> ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
importKeybindings(config);
|
||||||
|
toast.success("Keybindings imported successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to import keybindings: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
Customize Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
{isCustomized && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
Modified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportKeybindings}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleImportKeybindings}
|
||||||
|
className="hidden"
|
||||||
|
id="import-keybindings"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById("import-keybindings")?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
disabled={!isCustomized}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="Search shortcuts..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||||
|
<TabsList>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<TabsTrigger key={category} value={category}>
|
||||||
|
{category === "all" ? "All" : category}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredShortcuts.map((shortcut) => (
|
||||||
|
<Card key={shortcut.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{shortcut.description}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{shortcut.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingShortcut === shortcut.id ? (
|
||||||
|
<KeyRecorder
|
||||||
|
value={newKeyBinding}
|
||||||
|
onValueChange={setNewKeyBinding}
|
||||||
|
onCancel={handleCancelEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{shortcut.keys.map((key, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{shortcut.keys.length === 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
No binding
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingShortcut === shortcut.id ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={handleSaveShortcut}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEditShortcut(shortcut)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRemoveShortcut(shortcut)}
|
||||||
|
disabled={shortcut.keys.length === 0}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
Reset Keyboard Shortcuts?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will reset all keyboard shortcuts to their default values.
|
||||||
|
Any custom keybindings will be lost.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleResetToDefaults}>
|
||||||
|
Reset to Defaults
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { Keyboard } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +12,13 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
import { getPlatformSpecialKey } from "@/lib/utils";
|
import { getPlatformSpecialKey } from "@/lib/utils";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Keyboard, Settings } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useKeyboardShortcutsHelp,
|
||||||
|
KeyboardShortcut,
|
||||||
|
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||||
|
import { KeybindingEditor } from "./keybinding-editor";
|
||||||
|
|
||||||
const modifier: {
|
const modifier: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
@@ -31,7 +37,7 @@ function getKeyWithModifier(key: string) {
|
|||||||
return modifier[key] || key;
|
return modifier[key] || key;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
if (
|
if (
|
||||||
@@ -73,12 +79,32 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
|
|||||||
|
|
||||||
export const KeyboardShortcutsHelp = () => {
|
export const KeyboardShortcutsHelp = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showEditor, setShowEditor] = 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)));
|
||||||
|
|
||||||
|
if (showEditor) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="text" size="sm" className="gap-2">
|
||||||
|
<Keyboard className="w-4 h-4" />
|
||||||
|
Shortcuts
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<KeybindingEditor
|
||||||
|
shortcuts={shortcuts}
|
||||||
|
onClose={() => setShowEditor(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -87,7 +113,7 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
Shortcuts
|
Shortcuts
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-hidden flex">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Keyboard className="w-5 h-5" />
|
<Keyboard className="w-5 h-5" />
|
||||||
@@ -99,6 +125,17 @@ export const KeyboardShortcutsHelp = () => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowEditor(true)}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category} className="flex flex-col gap-1">
|
<div key={category} className="flex flex-col gap-1">
|
||||||
@@ -126,10 +163,10 @@ function ShortcutKey({ children }: { children: React.ReactNode }) {
|
|||||||
className="inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1"
|
className="inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
||||||
borderColor: "rgba(255, 255, 255, 0.1)"
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</kbd>
|
</kbd>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
|
||||||
|
* For example, toggling playback or seeking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
MutableRefObject,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
// Simple event emitter for action changes
|
||||||
|
class ActionEmitter {
|
||||||
|
private listeners: Array<(actions: Action[]) => void> = [];
|
||||||
|
|
||||||
|
subscribe(listener: (actions: Action[]) => void) {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(actions: Action[]) {
|
||||||
|
this.listeners.forEach((listener) => listener(actions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionEmitter = new ActionEmitter();
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "toggle-play" // Toggle play/pause state
|
||||||
|
| "stop-playback" // Stop playback
|
||||||
|
| "seek-forward" // Seek forward in playback
|
||||||
|
| "seek-backward" // Seek backward in playback
|
||||||
|
| "frame-step-forward" // Step forward by one frame
|
||||||
|
| "frame-step-backward" // Step backward by one frame
|
||||||
|
| "jump-forward" // Jump forward by 5 seconds
|
||||||
|
| "jump-backward" // Jump backward by 5 seconds
|
||||||
|
| "goto-start" // Go to timeline start
|
||||||
|
| "goto-end" // Go to timeline end
|
||||||
|
| "split-element" // Split element at current time
|
||||||
|
| "delete-selected" // Delete selected elements
|
||||||
|
| "select-all" // Select all elements
|
||||||
|
| "duplicate-selected" // Duplicate selected element
|
||||||
|
| "toggle-snapping" // Toggle snapping
|
||||||
|
| "undo" // Undo last action
|
||||||
|
| "redo"; // Redo last undone action
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the arguments, if present for a given type that is required to be passed on
|
||||||
|
* invocation and will be passed to action handlers.
|
||||||
|
*
|
||||||
|
* This type is supposed to be an object with the key being one of the actions mentioned above.
|
||||||
|
* The value to the key can be anything.
|
||||||
|
* If an action has no argument, you do not need to add it to this type.
|
||||||
|
*
|
||||||
|
* NOTE: We can't enforce type checks to make sure the key is Action, you
|
||||||
|
* will know if you got something wrong if there is a type error in this file
|
||||||
|
*/
|
||||||
|
type ActionArgsMap = {
|
||||||
|
"seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
|
||||||
|
"seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
|
||||||
|
"jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
|
||||||
|
"jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
type KeysWithValueUndefined<T> = {
|
||||||
|
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions which require arguments for their invocation
|
||||||
|
*/
|
||||||
|
export type ActionWithArgs = keyof ActionArgsMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions which optionally takes in arguments for their invocation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ActionWithOptionalArgs =
|
||||||
|
| ActionWithNoArgs
|
||||||
|
| KeysWithValueUndefined<ActionArgsMap>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions which do not require arguments for their invocation
|
||||||
|
*/
|
||||||
|
export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the argument type for a given Action
|
||||||
|
*/
|
||||||
|
type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
|
||||||
|
? ActionArgsMap[A]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the action function for a given Action, used by action handler function defs
|
||||||
|
*/
|
||||||
|
type ActionFunc<A extends Action> = A extends ActionWithArgs
|
||||||
|
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
|
||||||
|
: (_?: undefined, trigger?: InvocationTriggers) => void;
|
||||||
|
|
||||||
|
type BoundActionList = {
|
||||||
|
[A in Action]?: Array<ActionFunc<A>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const boundActions: BoundActionList = {};
|
||||||
|
|
||||||
|
let currentActiveActions: Action[] = [];
|
||||||
|
|
||||||
|
function updateActiveActions() {
|
||||||
|
const newActions = Object.keys(boundActions) as Action[];
|
||||||
|
currentActiveActions = newActions;
|
||||||
|
actionEmitter.emit(newActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindAction<A extends Action>(
|
||||||
|
action: A,
|
||||||
|
handler: ActionFunc<A>
|
||||||
|
) {
|
||||||
|
if (boundActions[action]) {
|
||||||
|
boundActions[action]?.push(handler);
|
||||||
|
} else {
|
||||||
|
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
|
||||||
|
boundActions[action] = [handler] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvocationTriggers = "keypress" | "mouseclick";
|
||||||
|
|
||||||
|
type InvokeActionFunc = {
|
||||||
|
(
|
||||||
|
action: ActionWithOptionalArgs,
|
||||||
|
args?: undefined,
|
||||||
|
trigger?: InvocationTriggers
|
||||||
|
): void;
|
||||||
|
<A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes an action, triggering action handlers if any registered.
|
||||||
|
* The second and third arguments are optional
|
||||||
|
* @param action The action to fire
|
||||||
|
* @param args The argument passed to the action handler. Optional if action has no args required
|
||||||
|
* @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
|
||||||
|
*/
|
||||||
|
export const invokeAction: InvokeActionFunc = <A extends Action>(
|
||||||
|
action: A,
|
||||||
|
args?: ArgOfHoppAction<A>,
|
||||||
|
trigger?: InvocationTriggers
|
||||||
|
) => {
|
||||||
|
boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unbindAction<A extends Action>(
|
||||||
|
action: A,
|
||||||
|
handler: ActionFunc<A>
|
||||||
|
) {
|
||||||
|
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
|
||||||
|
boundActions[action] = boundActions[action]?.filter(
|
||||||
|
(x) => x !== handler
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (boundActions[action]?.length === 0) {
|
||||||
|
delete boundActions[action];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a given action is bound at a given time
|
||||||
|
*
|
||||||
|
* @param action The action to check
|
||||||
|
*/
|
||||||
|
export function isActionBound(action: Action): boolean {
|
||||||
|
return !!boundActions[action];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that defines a component can handle a given
|
||||||
|
* Action. The handler will be bound when the component is mounted
|
||||||
|
* and unbound when the component is unmounted.
|
||||||
|
* @param action The action to be bound
|
||||||
|
* @param handler The function to be called when the action is invoked
|
||||||
|
* @param isActive A ref that indicates whether the action is active
|
||||||
|
*/
|
||||||
|
export function useActionHandler<A extends Action>(
|
||||||
|
action: A,
|
||||||
|
handler: ActionFunc<A>,
|
||||||
|
isActive: MutableRefObject<boolean> | boolean | undefined = undefined
|
||||||
|
) {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
const [isBound, setIsBound] = useState(false);
|
||||||
|
|
||||||
|
// Update handler ref when handler changes
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
// Create a stable handler wrapper
|
||||||
|
const stableHandler = useCallback(
|
||||||
|
(args: any, trigger?: InvocationTriggers) => {
|
||||||
|
(handlerRef.current as any)(args, trigger);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
) as ActionFunc<A>;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldBind =
|
||||||
|
isActive === undefined ||
|
||||||
|
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||||
|
|
||||||
|
if (shouldBind && !isBound) {
|
||||||
|
bindAction(action, stableHandler);
|
||||||
|
setIsBound(true);
|
||||||
|
} else if (!shouldBind && isBound) {
|
||||||
|
unbindAction(action, stableHandler);
|
||||||
|
setIsBound(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (isBound) {
|
||||||
|
unbindAction(action, stableHandler);
|
||||||
|
setIsBound(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [action, stableHandler, isActive, isBound]);
|
||||||
|
|
||||||
|
// Handle ref-based isActive changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && typeof isActive === "object" && "current" in isActive) {
|
||||||
|
// Poll for ref changes
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const shouldBind = isActive.current;
|
||||||
|
if (shouldBind !== isBound) {
|
||||||
|
if (shouldBind) {
|
||||||
|
bindAction(action, stableHandler);
|
||||||
|
} else {
|
||||||
|
unbindAction(action, stableHandler);
|
||||||
|
}
|
||||||
|
setIsBound(shouldBind);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [action, stableHandler, isActive, isBound]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that returns the current list of active actions
|
||||||
|
* and re-renders when the list changes
|
||||||
|
*/
|
||||||
|
export function useActiveActions(): Action[] {
|
||||||
|
const [activeActions, setActiveActions] = useState<Action[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial value
|
||||||
|
setActiveActions(currentActiveActions);
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
const unsubscribe = actionEmitter.subscribe(setActiveActions);
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return activeActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that returns whether a specific action is currently bound
|
||||||
|
* and re-renders when the binding state changes
|
||||||
|
*/
|
||||||
|
export function useIsActionBound(action: Action): boolean {
|
||||||
|
const [isBound, setIsBound] = useState(() => isActionBound(action));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateBoundState = () => {
|
||||||
|
setIsBound(isActionBound(action));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
updateBoundState();
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
const unsubscribe = actionEmitter.subscribe(updateBoundState);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [action]);
|
||||||
|
|
||||||
|
return isBound;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -44,6 +44,25 @@ export function generateUUID(): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDOMElement(el: any): el is HTMLElement {
|
||||||
|
return !!el && (el instanceof Element || el instanceof HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTypableElement(el: HTMLElement): boolean {
|
||||||
|
// If content editable, then it is editable
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
|
||||||
|
// If element is an input and the input is enabled, then it is typable
|
||||||
|
if (el.tagName === "INPUT") {
|
||||||
|
return !(el as HTMLInputElement).disabled;
|
||||||
|
}
|
||||||
|
// If element is a textarea and the input is enabled, then it is typable
|
||||||
|
if (el.tagName === "TEXTAREA") {
|
||||||
|
return !(el as HTMLTextAreaElement).disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
export function isAppleDevice() {
|
export function isAppleDevice() {
|
||||||
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { ActionWithOptionalArgs } from "@/constants/actions";
|
||||||
|
import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
|
||||||
|
import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||||
|
|
||||||
|
// Default keybindings configuration
|
||||||
|
export const defaultKeybindings: KeybindingConfig = {
|
||||||
|
space: "toggle-play",
|
||||||
|
j: "seek-backward",
|
||||||
|
k: "toggle-play",
|
||||||
|
l: "seek-forward",
|
||||||
|
left: "frame-step-backward",
|
||||||
|
right: "frame-step-forward",
|
||||||
|
"shift+left": "jump-backward",
|
||||||
|
"shift+right": "jump-forward",
|
||||||
|
home: "goto-start",
|
||||||
|
end: "goto-end",
|
||||||
|
s: "split-element",
|
||||||
|
n: "toggle-snapping",
|
||||||
|
"ctrl+a": "select-all",
|
||||||
|
"ctrl+d": "duplicate-selected",
|
||||||
|
"ctrl+z": "undo",
|
||||||
|
"ctrl+shift+z": "redo",
|
||||||
|
"ctrl+y": "redo",
|
||||||
|
delete: "delete-selected",
|
||||||
|
backspace: "delete-selected",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface KeybindingConflict {
|
||||||
|
key: ShortcutKey;
|
||||||
|
existingAction: ActionWithOptionalArgs;
|
||||||
|
newAction: ActionWithOptionalArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeybindingsState {
|
||||||
|
keybindings: KeybindingConfig;
|
||||||
|
isCustomized: boolean;
|
||||||
|
keybindingsEnabled: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
|
||||||
|
removeKeybinding: (key: ShortcutKey) => void;
|
||||||
|
resetToDefaults: () => void;
|
||||||
|
importKeybindings: (config: KeybindingConfig) => void;
|
||||||
|
exportKeybindings: () => KeybindingConfig;
|
||||||
|
enableKeybindings: () => void;
|
||||||
|
disableKeybindings: () => void;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validateKeybinding: (
|
||||||
|
key: ShortcutKey,
|
||||||
|
action: ActionWithOptionalArgs
|
||||||
|
) => KeybindingConflict | null;
|
||||||
|
getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
keybindings: { ...defaultKeybindings },
|
||||||
|
isCustomized: false,
|
||||||
|
keybindingsEnabled: true,
|
||||||
|
|
||||||
|
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
|
||||||
|
set((state) => {
|
||||||
|
const newKeybindings = { ...state.keybindings };
|
||||||
|
newKeybindings[key] = action;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keybindings: newKeybindings,
|
||||||
|
isCustomized: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeKeybinding: (key: ShortcutKey) => {
|
||||||
|
set((state) => {
|
||||||
|
const newKeybindings = { ...state.keybindings };
|
||||||
|
delete newKeybindings[key];
|
||||||
|
|
||||||
|
return {
|
||||||
|
keybindings: newKeybindings,
|
||||||
|
isCustomized: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetToDefaults: () => {
|
||||||
|
set({
|
||||||
|
keybindings: { ...defaultKeybindings },
|
||||||
|
isCustomized: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
enableKeybindings: () => {
|
||||||
|
set({ keybindingsEnabled: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
disableKeybindings: () => {
|
||||||
|
set({ keybindingsEnabled: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
importKeybindings: (config: KeybindingConfig) => {
|
||||||
|
// Validate all keys and actions
|
||||||
|
for (const [key, action] of Object.entries(config)) {
|
||||||
|
// Validate the key format
|
||||||
|
if (typeof key !== "string" || key.length === 0) {
|
||||||
|
throw new Error(`Invalid key format: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
keybindings: { ...config },
|
||||||
|
isCustomized: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exportKeybindings: () => {
|
||||||
|
return get().keybindings;
|
||||||
|
},
|
||||||
|
|
||||||
|
validateKeybinding: (
|
||||||
|
key: ShortcutKey,
|
||||||
|
action: ActionWithOptionalArgs
|
||||||
|
) => {
|
||||||
|
const { keybindings } = get();
|
||||||
|
const existingAction = keybindings[key];
|
||||||
|
|
||||||
|
if (existingAction && existingAction !== action) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
existingAction,
|
||||||
|
newAction: action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
|
||||||
|
const { keybindings } = get();
|
||||||
|
return Object.keys(keybindings).filter(
|
||||||
|
(key) => keybindings[key as ShortcutKey] === action
|
||||||
|
) as ShortcutKey[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeybindingString: (ev: KeyboardEvent) => {
|
||||||
|
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "opencut-keybindings",
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||||
|
const target = ev.target;
|
||||||
|
|
||||||
|
// We may or may not have a modifier key
|
||||||
|
const modifierKey = getActiveModifier(ev);
|
||||||
|
|
||||||
|
// We will always have a non-modifier key
|
||||||
|
const key = getPressedKey(ev);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||||
|
if (modifierKey) {
|
||||||
|
// If the modifier is shift and the target is an input, we ignore
|
||||||
|
if (
|
||||||
|
modifierKey === "shift" &&
|
||||||
|
isDOMElement(target) &&
|
||||||
|
isTypableElement(target)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${modifierKey}+${key}` as ShortcutKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no modifier key here then we do not do anything while on input
|
||||||
|
if (isDOMElement(target) && isTypableElement(target)) return null;
|
||||||
|
|
||||||
|
// single key while not input
|
||||||
|
return `${key}` as ShortcutKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||||
|
// Sometimes the property code is not available on the KeyboardEvent object
|
||||||
|
const key = (ev.key ?? "").toLowerCase();
|
||||||
|
|
||||||
|
// Check arrow keys
|
||||||
|
if (key.startsWith("arrow")) {
|
||||||
|
return key.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for special keys
|
||||||
|
if (key === "tab") return "tab";
|
||||||
|
if (key === " " || key === "space") return "space";
|
||||||
|
if (key === "home") return "home";
|
||||||
|
if (key === "end") return "end";
|
||||||
|
if (key === "delete") return "delete";
|
||||||
|
if (key === "backspace") return "backspace";
|
||||||
|
|
||||||
|
// Check letter keys
|
||||||
|
const isLetter = key.length === 1 && key >= "a" && key <= "z";
|
||||||
|
if (isLetter) return key;
|
||||||
|
|
||||||
|
// Check if number keys
|
||||||
|
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||||
|
if (isDigit) return key;
|
||||||
|
|
||||||
|
// Check if slash, period or enter
|
||||||
|
if (key === "/" || key === "." || key === "enter") return key;
|
||||||
|
|
||||||
|
// If no other cases match, this is not a valid key
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||||
|
const modifierKeys = {
|
||||||
|
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||||
|
alt: ev.altKey,
|
||||||
|
shift: ev.shiftKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
|
||||||
|
// modiferKeys object's keys are sorted to match the above order
|
||||||
|
const activeModifier = Object.keys(modifierKeys)
|
||||||
|
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||||
|
.join("+");
|
||||||
|
|
||||||
|
return activeModifier === "" ? null : activeModifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ActionWithOptionalArgs } from "@/constants/actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt is also regarded as macOS OPTION (⌥) key
|
||||||
|
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||||
|
*/
|
||||||
|
export type ModifierKeys =
|
||||||
|
| "ctrl"
|
||||||
|
| "alt"
|
||||||
|
| "shift"
|
||||||
|
| "ctrl+shift"
|
||||||
|
| "alt+shift"
|
||||||
|
| "ctrl+alt"
|
||||||
|
| "ctrl+alt+shift";
|
||||||
|
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
// prettier-ignore
|
||||||
|
export type Key =
|
||||||
|
| "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
|
||||||
|
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
|
||||||
|
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
|
||||||
|
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
|
||||||
|
| "right" | "/" | "?" | "." | "enter" | "tab" | "space"
|
||||||
|
| "escape" | "esc" | "backspace" | "delete" | "home" | "end"
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
||||||
|
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||||
|
export type SingleCharacterShortcutKey = `${Key}`;
|
||||||
|
|
||||||
|
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||||
|
|
||||||
|
export type KeybindingConfig = {
|
||||||
|
[key in ShortcutKey]?: ActionWithOptionalArgs;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user