import React, { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { getKeyName, formatKeyCombination, normalizeKey, } from "../../lib/utils/keyboard"; import { ResetButton } from "../ui/ResetButton"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; interface GlobalShortcutInputProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; shortcutId: string; disabled?: boolean; } export const GlobalShortcutInput: React.FC = ({ descriptionMode = "tooltip", grouped = false, shortcutId, disabled = false, }) => { const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = useSettings(); const [keyPressed, setKeyPressed] = useState([]); const [recordedKeys, setRecordedKeys] = useState([]); const [editingShortcutId, setEditingShortcutId] = useState( null, ); const [originalBinding, setOriginalBinding] = useState(""); const shortcutRefs = useRef>(new Map()); const osType = useOsType(); const bindings = getSetting("bindings") || {}; useEffect(() => { // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; let cleanup = false; // Keyboard event listeners const handleKeyDown = async (e: KeyboardEvent) => { if (cleanup) return; if (e.repeat) return; // ignore auto-repeat if (e.key === "Escape") { // Cancel recording and restore original binding if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (error) { console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { await commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); return; } e.preventDefault(); // Get the key with OS-specific naming and normalize it const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); if (!keyPressed.includes(key)) { setKeyPressed((prev) => [...prev, key]); // Also add to recorded keys if not already there if (!recordedKeys.includes(key)) { setRecordedKeys((prev) => [...prev, key]); } } }; const handleKeyUp = async (e: KeyboardEvent) => { if (cleanup) return; e.preventDefault(); // Get the key with OS-specific naming and normalize it const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); // Remove from currently pressed keys setKeyPressed((prev) => prev.filter((k) => k !== key)); // If no keys are pressed anymore, commit the shortcut const updatedKeyPressed = keyPressed.filter((k) => k !== key); if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) { // Create the shortcut string from all recorded keys // Sort keys so modifiers come first, then the main key const modifiers = [ "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", "windows", ]; const sortedKeys = recordedKeys.sort((a, b) => { const aIsModifier = modifiers.includes(a.toLowerCase()); const bIsModifier = modifiers.includes(b.toLowerCase()); if (aIsModifier && !bIsModifier) return -1; if (!aIsModifier && bIsModifier) return 1; return 0; }); const newShortcut = sortedKeys.join("+"); if (editingShortcutId && bindings[editingShortcutId]) { try { await updateBinding(editingShortcutId, newShortcut); } catch (error) { console.error("Failed to change binding:", error); toast.error( t("settings.general.shortcut.errors.set", { error: String(error), }), ); // Reset to original binding on error if (originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (resetError) { console.error("Failed to reset binding:", resetError); toast.error(t("settings.general.shortcut.errors.reset")); } } } // Exit editing mode and reset states setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); } } }; // Add click outside handler const handleClickOutside = async (e: MouseEvent) => { if (cleanup) return; const activeElement = shortcutRefs.current.get(editingShortcutId); if (activeElement && !activeElement.contains(e.target as Node)) { // Cancel shortcut recording and restore original binding if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (error) { console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("click", handleClickOutside); return () => { cleanup = true; window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("click", handleClickOutside); }; }, [ keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding, updateBinding, osType, ]); // Start recording a new shortcut const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut // Suspend current binding to avoid firing while recording await commands.suspendBinding(id).catch(console.error); // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); setEditingShortcutId(id); setKeyPressed([]); setRecordedKeys([]); }; // Format the current shortcut keys being recorded const formatCurrentKeys = (): string => { if (recordedKeys.length === 0) return t("settings.general.shortcut.pressKeys"); // Use the same formatting as the display to ensure consistency return formatKeyCombination(recordedKeys.join("+"), osType); }; // Store references to shortcut elements const setShortcutRef = (id: string, ref: HTMLDivElement | null) => { shortcutRefs.current.set(id, ref); }; // If still loading, show loading state if (isLoading) { return (
{t("settings.general.shortcut.loading")}
); } // If no bindings are loaded, show empty state if (Object.keys(bindings).length === 0) { return (
{t("settings.general.shortcut.none")}
); } const binding = bindings[shortcutId]; if (!binding) { return (
{t("settings.general.shortcut.none")}
); } // Get translated name and description for the binding const translatedName = t( `settings.general.shortcut.bindings.${shortcutId}.name`, binding.name, ); const translatedDescription = t( `settings.general.shortcut.bindings.${shortcutId}.description`, binding.description, ); return (
{editingShortcutId === shortcutId ? (
setShortcutRef(shortcutId, ref)} className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded" > {formatCurrentKeys()}
) : (
startRecording(shortcutId)} > {formatKeyCombination(binding.current_binding, osType)}
)} resetBinding(shortcutId)} disabled={isUpdating(`binding_${shortcutId}`)} />
); };