Handy/src/components/settings/GlobalShortcutInput.tsx

315 lines
10 KiB
TypeScript

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<GlobalShortcutInputProps> = ({
descriptionMode = "tooltip",
grouped = false,
shortcutId,
disabled = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
useSettings();
const [keyPressed, setKeyPressed] = useState<string[]>([]);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
null,
);
const [originalBinding, setOriginalBinding] = useState<string>("");
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(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 (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.loading")}
</div>
</SettingContainer>
);
}
// If no bindings are loaded, show empty state
if (Object.keys(bindings).length === 0) {
return (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
const binding = bindings[shortcutId];
if (!binding) {
return (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.notFound")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
// 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 (
<SettingContainer
title={translatedName}
description={translatedDescription}
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}
layout="horizontal"
>
<div className="flex items-center space-x-1">
{editingShortcutId === shortcutId ? (
<div
ref={(ref) => setShortcutRef(shortcutId, ref)}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded"
>
{formatCurrentKeys()}
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
onClick={() => startRecording(shortcutId)}
>
{formatKeyCombination(binding.current_binding, osType)}
</div>
)}
<ResetButton
onClick={() => resetBinding(shortcutId)}
disabled={isUpdating(`binding_${shortcutId}`)}
/>
</div>
</SettingContainer>
);
};