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

View File

@ -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>
); );

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">

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]);
} }
/** /**

View File

@ -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")

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")) {