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";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 = ({
shortcut,
recordingKey,
isRecording,
onStartRecording,
}: {
shortcut: KeyboardShortcut;
recordingKey: string | null;
onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void;
isRecording: boolean;
onStartRecording: (shortcut: KeyboardShortcut) => void;
}) => {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
@ -66,20 +49,17 @@ const ShortcutItem = ({
</div>
<div className="flex items-center gap-1">
{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">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
return (
<EditableShortcutKey
key={partIndex}
keyId={keyId}
originalKey={key}
shortcut={shortcut}
isRecording={recordingKey === keyId}
onStartRecording={() => onStartRecording(keyId, shortcut)}
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording(shortcut)}
>
{getKeyWithModifier(keyPart)}
{keyPart}
</EditableShortcutKey>
);
})}
@ -96,16 +76,10 @@ const ShortcutItem = ({
const EditableShortcutKey = ({
children,
keyId,
originalKey,
shortcut,
isRecording,
onStartRecording,
}: {
children: React.ReactNode;
keyId: string;
originalKey: string;
shortcut: KeyboardShortcut;
isRecording: boolean;
onStartRecording: () => void;
}) => {
@ -116,7 +90,9 @@ const EditableShortcutKey = ({
};
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 ${
isRecording
? "border-primary bg-primary/10"
@ -128,13 +104,12 @@ const EditableShortcutKey = ({
}
>
{children}
</kbd>
</Button>
);
};
export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false);
const [recordingKey, setRecordingKey] = useState<string | null>(null);
const [recordingShortcut, setRecordingShortcut] =
useState<KeyboardShortcut | null>(null);
@ -144,6 +119,9 @@ export const KeyboardShortcutsHelp = () => {
getKeybindingString,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
resetToDefaults,
isRecording,
} = useKeybindingsStore();
// Get shortcuts from centralized hook
@ -152,7 +130,7 @@ export const KeyboardShortcutsHelp = () => {
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
useEffect(() => {
if (!recordingKey || !recordingShortcut) return;
if (!isRecording || !recordingShortcut) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
@ -169,7 +147,6 @@ export const KeyboardShortcutsHelp = () => {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`
);
setRecordingKey(null);
setRecordingShortcut(null);
return;
}
@ -181,14 +158,14 @@ export const KeyboardShortcutsHelp = () => {
// Add new keybinding
updateKeybinding(keyString, recordingShortcut.action);
setRecordingKey(null);
setIsRecording(false);
setRecordingShortcut(null);
}
};
const handleClickOutside = (e: MouseEvent) => {
setRecordingKey(null);
const handleClickOutside = () => {
setRecordingShortcut(null);
setIsRecording(false);
};
document.addEventListener("keydown", handleKeyDown);
@ -199,18 +176,19 @@ export const KeyboardShortcutsHelp = () => {
document.removeEventListener("click", handleClickOutside);
};
}, [
recordingKey,
recordingShortcut,
getKeybindingString,
updateKeybinding,
removeKeybinding,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
isRecording,
]);
const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => {
setRecordingKey(keyId);
const handleStartRecording = (shortcut: KeyboardShortcut) => {
setRecordingShortcut(shortcut);
setIsRecording(true);
};
return (
@ -221,7 +199,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
@ -242,11 +220,13 @@ export const KeyboardShortcutsHelp = () => {
<div className="space-y-0.5">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => (
.map((shortcut) => (
<ShortcutItem
key={index}
key={shortcut.action}
shortcut={shortcut}
recordingKey={recordingKey}
isRecording={
shortcut.action === recordingShortcut?.action
}
onStartRecording={handleStartRecording}
/>
))}
@ -254,6 +234,16 @@ export const KeyboardShortcutsHelp = () => {
</div>
))}
</div>
<DialogFooter>
<Button
size="sm"
className="mt-4"
variant="destructive"
onClick={resetToDefaults}
>
Reset to Default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
}}
{...props}
>
<ScrollArea className="max-h-[85vh]">
<ScrollArea className="max-h-[75vh]">
<div className="p-6 space-y-4">{children}</div>
</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">

View File

@ -8,13 +8,15 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
const { keybindings, getKeybindingString, keybindingsEnabled } =
const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
useKeybindingsStore();
useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => {
// Do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return;
// ignore key events if user is changing keybindings
if (isRecording) return;
const binding = getKeybindingString(ev);
if (!binding) return;
@ -45,7 +47,7 @@ export function useKeybindingsListener() {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [keybindings, getKeybindingString, keybindingsEnabled]);
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
}
/**

View File

@ -3,6 +3,7 @@
import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Action } from "@/constants/actions";
import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/utils";
export interface KeyboardShortcut {
id: string;
@ -67,13 +68,13 @@ const actionDescriptions: Record<
// Convert key binding format to display format
const formatKey = (key: string): string => {
return key
.replace("ctrl", "Cmd")
.replace("alt", "Alt")
.replace("ctrl", getPlatformSpecialKey())
.replace("alt", getPlatformAlternateKey())
.replace("shift", "Shift")
.replace("left", "ArrowLeft")
.replace("right", "ArrowRight")
.replace("up", "ArrowUp")
.replace("down", "ArrowDown")
.replace("left", "")
.replace("right", "")
.replace("up", "")
.replace("down", "")
.replace("space", "Space")
.replace("home", "Home")
.replace("end", "End")

View File

@ -39,6 +39,7 @@ interface KeybindingsState {
keybindings: KeybindingConfig;
isCustomized: boolean;
keybindingsEnabled: boolean;
isRecording: boolean;
// Actions
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
@ -48,6 +49,7 @@ interface KeybindingsState {
exportKeybindings: () => KeybindingConfig;
enableKeybindings: () => void;
disableKeybindings: () => void;
setIsRecording: (isRecording: boolean) => void;
// Validation
validateKeybinding: (
@ -66,6 +68,7 @@ export const useKeybindingsStore = create<KeybindingsState>()(
keybindings: { ...defaultKeybindings },
isCustomized: false,
keybindingsEnabled: true,
isRecording: false,
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
set((state) => {
@ -141,6 +144,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
return null;
},
setIsRecording: (isRecording: boolean) => {
set({ isRecording });
},
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
const { keybindings } = get();
@ -211,8 +217,12 @@ function getPressedKey(ev: KeyboardEvent): string | null {
if (key === "backspace") return "backspace";
// Check letter keys
const isLetter = key.length === 1 && key >= "a" && key <= "z";
if (isLetter) return key;
if (code.startsWith("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
if (code.startsWith("Digit")) {