Handy/src/components/settings/HandyShortcut.tsx
Piyush Gupta 951283ca8a fix: tooltip overflow (#269)
* fix: tooltip overflow

* Adds more responsive Css

* StartHidden has tooltip bottom

* in debug setting too

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
2025-10-30 08:06:44 -07:00

314 lines
10 KiB
TypeScript

import React, { useEffect, useState, useRef } from "react";
import { type } from "@tauri-apps/plugin-os";
import {
getKeyName,
formatKeyCombination,
normalizeKey,
type OSType,
} from "../../lib/utils/keyboard";
import { ResetButton } from "../ui/ResetButton";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
interface HandyShortcutProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const HandyShortcut: React.FC<HandyShortcutProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
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 [osType, setOsType] = useState<OSType>("unknown");
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const bindings = getSetting("bindings") || {};
// Detect and store OS type
useEffect(() => {
const detectOsType = async () => {
try {
const detectedType = type();
let normalizedType: OSType;
switch (detectedType) {
case "macos":
normalizedType = "macos";
break;
case "windows":
normalizedType = "windows";
break;
case "linux":
normalizedType = "linux";
break;
default:
normalizedType = "unknown";
}
setOsType(normalizedType);
} catch (error) {
console.error("Error detecting OS type:", error);
setOsType("unknown");
}
};
detectOsType();
}, []);
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);
await invoke("resume_binding", { id: editingShortcutId }).catch(
console.error,
);
} catch (error) {
console.error("Failed to restore original binding:", error);
toast.error("Failed to restore original shortcut");
}
} else if (editingShortcutId) {
await invoke("resume_binding", { id: 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
const newShortcut = recordedKeys.join("+");
if (editingShortcutId && bindings[editingShortcutId]) {
try {
await updateBinding(editingShortcutId, newShortcut);
// Re-register the shortcut now that recording is finished
await invoke("resume_binding", { id: editingShortcutId }).catch(
console.error,
);
} catch (error) {
console.error("Failed to change binding:", error);
toast.error(`Failed to set shortcut: ${error}`);
// Reset to original binding on error
if (originalBinding) {
try {
await updateBinding(editingShortcutId, originalBinding);
await invoke("resume_binding", { id: editingShortcutId }).catch(
console.error,
);
} catch (resetError) {
console.error("Failed to reset binding:", resetError);
toast.error("Failed to reset shortcut to original value");
}
}
}
// 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);
await invoke("resume_binding", { id: editingShortcutId }).catch(
console.error,
);
} catch (error) {
console.error("Failed to restore original binding:", error);
toast.error("Failed to restore original shortcut");
}
} else if (editingShortcutId) {
invoke("resume_binding", { id: 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 invoke("suspend_binding", { 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 "Press keys...";
// 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="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">Loading shortcuts...</div>
</SettingContainer>
);
}
// If no bindings are loaded, show empty state
if (Object.keys(bindings).length === 0) {
return (
<SettingContainer
title="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">No shortcuts configured</div>
</SettingContainer>
);
}
return (
<SettingContainer
title="Handy Shortcut"
description="Set the keyboard shortcut to start and stop speech-to-text recording"
descriptionMode={descriptionMode}
grouped={grouped}
tooltipPosition="bottom"
>
{(() => {
const primaryBinding = Object.values(bindings)[0];
const primaryId = Object.keys(bindings)[0];
if (!primaryBinding) {
return (
<div className="text-sm text-mid-gray">No shortcuts configured</div>
);
}
return (
<div className="flex items-center space-x-1">
{editingShortcutId === primaryId ? (
<div
ref={(ref) => setShortcutRef(primaryId, ref)}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
>
{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(primaryId)}
>
{formatKeyCombination(primaryBinding.current_binding, osType)}
</div>
)}
<ResetButton
onClick={() => resetBinding(primaryId)}
disabled={isUpdating(`binding_${primaryId}`)}
/>
</div>
);
})()}
</SettingContainer>
);
};