This commit is contained in:
Maze Winther
2025-07-26 12:54:50 +02:00
parent 043bf7d70a
commit 6dfc80cd70
5 changed files with 72 additions and 69 deletions
@@ -1,49 +1,32 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { Keyboard } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
type KeyboardShortcut,
useKeyboardShortcutsHelp,
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "./ui/dialog"; } from "./ui/dialog";
import { getPlatformSpecialKey } from "@/lib/utils";
import { Keyboard } from "lucide-react";
import {
useKeyboardShortcutsHelp,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { toast } from "sonner";
const modifier: {
[key: string]: string;
} = {
Shift: "Shift",
Alt: "Alt",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
Space: "Space",
};
function getKeyWithModifier(key: string) {
if (key === "Ctrl") return getPlatformSpecialKey();
return modifier[key] || key;
}
const ShortcutItem = ({ const ShortcutItem = ({
shortcut, shortcut,
recordingKey, isRecording,
onStartRecording, onStartRecording,
}: { }: {
shortcut: KeyboardShortcut; shortcut: KeyboardShortcut;
recordingKey: string | null; isRecording: boolean;
onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; onStartRecording: (shortcut: KeyboardShortcut) => void;
}) => { }) => {
// 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) => {
@@ -66,20 +49,17 @@ const ShortcutItem = ({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{displayKeys.map((key: string, index: number) => ( {displayKeys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1"> <div key={key} className="flex items-center gap-1">
<div className="flex items-center"> <div className="flex items-center">
{key.split("+").map((keyPart: string, partIndex: number) => { {key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`; const keyId = `${shortcut.id}-${index}-${partIndex}`;
return ( return (
<EditableShortcutKey <EditableShortcutKey
key={partIndex} key={keyId}
keyId={keyId} isRecording={isRecording}
originalKey={key} onStartRecording={() => onStartRecording(shortcut)}
shortcut={shortcut}
isRecording={recordingKey === keyId}
onStartRecording={() => onStartRecording(keyId, shortcut)}
> >
{getKeyWithModifier(keyPart)} {keyPart}
</EditableShortcutKey> </EditableShortcutKey>
); );
})} })}
@@ -96,16 +76,10 @@ const ShortcutItem = ({
const EditableShortcutKey = ({ const EditableShortcutKey = ({
children, children,
keyId,
originalKey,
shortcut,
isRecording, isRecording,
onStartRecording, onStartRecording,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
keyId: string;
originalKey: string;
shortcut: KeyboardShortcut;
isRecording: boolean; isRecording: boolean;
onStartRecording: () => void; onStartRecording: () => void;
}) => { }) => {
@@ -116,7 +90,9 @@ const EditableShortcutKey = ({
}; };
return ( return (
<kbd <Button
variant="text"
size="sm"
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 cursor-pointer hover:bg-opacity-80 ${ 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 cursor-pointer hover:bg-opacity-80 ${
isRecording isRecording
? "border-primary bg-primary/10" ? "border-primary bg-primary/10"
@@ -128,13 +104,12 @@ const EditableShortcutKey = ({
} }
> >
{children} {children}
</kbd> </Button>
); );
}; };
export const KeyboardShortcutsHelp = () => { export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [recordingKey, setRecordingKey] = useState<string | null>(null);
const [recordingShortcut, setRecordingShortcut] = const [recordingShortcut, setRecordingShortcut] =
useState<KeyboardShortcut | null>(null); useState<KeyboardShortcut | null>(null);
@@ -144,6 +119,9 @@ export const KeyboardShortcutsHelp = () => {
getKeybindingString, getKeybindingString,
validateKeybinding, validateKeybinding,
getKeybindingsForAction, getKeybindingsForAction,
setIsRecording,
resetToDefaults,
isRecording,
} = useKeybindingsStore(); } = useKeybindingsStore();
// Get shortcuts from centralized hook // Get shortcuts from centralized hook
@@ -152,7 +130,7 @@ export const KeyboardShortcutsHelp = () => {
const categories = Array.from(new Set(shortcuts.map((s) => s.category))); const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
useEffect(() => { useEffect(() => {
if (!recordingKey || !recordingShortcut) return; if (!isRecording || !recordingShortcut) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
@@ -169,7 +147,6 @@ export const KeyboardShortcutsHelp = () => {
toast.error( toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"` `Key "${keyString}" is already bound to "${conflict.existingAction}"`
); );
setRecordingKey(null);
setRecordingShortcut(null); setRecordingShortcut(null);
return; return;
} }
@@ -181,14 +158,14 @@ export const KeyboardShortcutsHelp = () => {
// Add new keybinding // Add new keybinding
updateKeybinding(keyString, recordingShortcut.action); updateKeybinding(keyString, recordingShortcut.action);
setRecordingKey(null); setIsRecording(false);
setRecordingShortcut(null); setRecordingShortcut(null);
} }
}; };
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = () => {
setRecordingKey(null);
setRecordingShortcut(null); setRecordingShortcut(null);
setIsRecording(false);
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
@@ -199,18 +176,19 @@ export const KeyboardShortcutsHelp = () => {
document.removeEventListener("click", handleClickOutside); document.removeEventListener("click", handleClickOutside);
}; };
}, [ }, [
recordingKey,
recordingShortcut, recordingShortcut,
getKeybindingString, getKeybindingString,
updateKeybinding, updateKeybinding,
removeKeybinding, removeKeybinding,
validateKeybinding, validateKeybinding,
getKeybindingsForAction, getKeybindingsForAction,
setIsRecording,
isRecording,
]); ]);
const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { const handleStartRecording = (shortcut: KeyboardShortcut) => {
setRecordingKey(keyId);
setRecordingShortcut(shortcut); setRecordingShortcut(shortcut);
setIsRecording(true);
}; };
return ( return (
@@ -221,7 +199,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts Shortcuts
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-2xl 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" />
@@ -242,11 +220,13 @@ export const KeyboardShortcutsHelp = () => {
<div className="space-y-0.5"> <div className="space-y-0.5">
{shortcuts {shortcuts
.filter((shortcut) => shortcut.category === category) .filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => ( .map((shortcut) => (
<ShortcutItem <ShortcutItem
key={index} key={shortcut.action}
shortcut={shortcut} shortcut={shortcut}
recordingKey={recordingKey} isRecording={
shortcut.action === recordingShortcut?.action
}
onStartRecording={handleStartRecording} onStartRecording={handleStartRecording}
/> />
))} ))}
@@ -254,6 +234,16 @@ export const KeyboardShortcutsHelp = () => {
</div> </div>
))} ))}
</div> </div>
<DialogFooter>
<Button
size="sm"
className="mt-4"
variant="destructive"
onClick={resetToDefaults}
>
Reset to Default
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+1 -1
View File
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
}} }}
{...props} {...props}
> >
<ScrollArea className="max-h-[85vh]"> <ScrollArea className="max-h-[75vh]">
<div className="p-6 space-y-4">{children}</div> <div className="p-6 space-y-4">{children}</div>
</ScrollArea> </ScrollArea>
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+4 -2
View File
@@ -8,13 +8,15 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings * the appropriate actions based on keybindings
*/ */
export function useKeybindingsListener() { export function useKeybindingsListener() {
const { keybindings, getKeybindingString, keybindingsEnabled } = const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
useKeybindingsStore(); useKeybindingsStore();
useEffect(() => { useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => { const handleKeyDown = (ev: KeyboardEvent) => {
// Do not check keybinds if the mode is disabled // Do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return; if (!keybindingsEnabled) return;
// ignore key events if user is changing keybindings
if (isRecording) return;
const binding = getKeybindingString(ev); const binding = getKeybindingString(ev);
if (!binding) return; if (!binding) return;
@@ -45,7 +47,7 @@ export function useKeybindingsListener() {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [keybindings, getKeybindingString, keybindingsEnabled]); }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
} }
/** /**
@@ -3,6 +3,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store"; import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Action } from "@/constants/actions"; import { Action } from "@/constants/actions";
import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/utils";
export interface KeyboardShortcut { export interface KeyboardShortcut {
id: string; id: string;
@@ -67,13 +68,13 @@ const actionDescriptions: Record<
// Convert key binding format to display format // Convert key binding format to display format
const formatKey = (key: string): string => { const formatKey = (key: string): string => {
return key return key
.replace("ctrl", "Cmd") .replace("ctrl", getPlatformSpecialKey())
.replace("alt", "Alt") .replace("alt", getPlatformAlternateKey())
.replace("shift", "Shift") .replace("shift", "Shift")
.replace("left", "ArrowLeft") .replace("left", "")
.replace("right", "ArrowRight") .replace("right", "")
.replace("up", "ArrowUp") .replace("up", "")
.replace("down", "ArrowDown") .replace("down", "")
.replace("space", "Space") .replace("space", "Space")
.replace("home", "Home") .replace("home", "Home")
.replace("end", "End") .replace("end", "End")
+12 -2
View File
@@ -39,6 +39,7 @@ interface KeybindingsState {
keybindings: KeybindingConfig; keybindings: KeybindingConfig;
isCustomized: boolean; isCustomized: boolean;
keybindingsEnabled: boolean; keybindingsEnabled: boolean;
isRecording: boolean;
// Actions // Actions
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
@@ -48,6 +49,7 @@ interface KeybindingsState {
exportKeybindings: () => KeybindingConfig; exportKeybindings: () => KeybindingConfig;
enableKeybindings: () => void; enableKeybindings: () => void;
disableKeybindings: () => void; disableKeybindings: () => void;
setIsRecording: (isRecording: boolean) => void;
// Validation // Validation
validateKeybinding: ( validateKeybinding: (
@@ -66,6 +68,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
keybindings: { ...defaultKeybindings }, keybindings: { ...defaultKeybindings },
isCustomized: false, isCustomized: false,
keybindingsEnabled: true, keybindingsEnabled: true,
isRecording: false,
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
set((state) => { set((state) => {
@@ -141,6 +144,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
return null; return null;
}, },
setIsRecording: (isRecording: boolean) => {
set({ isRecording });
},
getKeybindingsForAction: (action: ActionWithOptionalArgs) => { getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
const { keybindings } = get(); const { keybindings } = get();
@@ -211,8 +217,12 @@ function getPressedKey(ev: KeyboardEvent): string | null {
if (key === "backspace") return "backspace"; if (key === "backspace") return "backspace";
// Check letter keys // Check letter keys
const isLetter = key.length === 1 && key >= "a" && key <= "z"; if (code.startsWith("Key")) {
if (isLetter) return key; const letter = code.slice(3).toLowerCase();
if (letter.length === 1 && letter >= "a" && letter <= "z") {
return letter;
}
}
// Check number keys using physical position for AZERTY support // Check number keys using physical position for AZERTY support
if (code.startsWith("Digit")) { if (code.startsWith("Digit")) {