diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx index e537567a..74ebc598 100644 --- a/apps/desktop/src/components/shortcut-input.tsx +++ b/apps/desktop/src/components/shortcut-input.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from "react"; +import { Undo2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Pencil, X } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { usePreviousShortcut } from "@/hooks/usePreviousShortcut"; import { api } from "@/trpc/react"; -import { toast } from "sonner"; import { getKeyFromKeycode } from "@/utils/keycode-map"; interface ShortcutInputProps { @@ -11,6 +12,7 @@ interface ShortcutInputProps { onChange: (value: number[]) => void; isRecordingShortcut?: boolean; onRecordingShortcutChange: (recording: boolean) => void; + shortcutId: string; } const MODIFIER_KEYS = new Set([ @@ -99,6 +101,7 @@ function RecordingDisplay({ size="sm" className="h-6 w-6 p-0" onClick={onCancel} + aria-label="Cancel recording" > @@ -109,9 +112,11 @@ function RecordingDisplay({ function ShortcutDisplay({ value, onEdit, + onClear, }: { value?: number[]; onEdit: () => void; + onClear: () => void; }) { // Format array as display string (e.g., ["Fn", "Space"] -> "Fn+Space") const displayValue = value?.length @@ -119,7 +124,7 @@ function ShortcutDisplay({ : undefined; return ( - <> +
{displayValue && ( - + - +
+ ); +} + +function NoneDisplay({ + previousKeys, + onEdit, + onRestore, +}: { + previousKeys?: number[]; + onEdit: () => void; + onRestore: () => void; +}) { + return ( +
+ + {previousKeys && previousKeys.length > 0 && ( + + )} +
); } @@ -145,11 +180,17 @@ export function ShortcutInput({ onChange, isRecordingShortcut = false, onRecordingShortcutChange, + shortcutId, }: ShortcutInputProps) { const [activeKeys, setActiveKeys] = useState([]); + const activeKeysRef = useRef([]); + const { previousKeys, savePrevious, clearPrevious } = + usePreviousShortcut(shortcutId); const setRecordingStateMutation = api.settings.setShortcutRecordingState.useMutation(); + const hasShortcut = value && value.length > 0; + const handleStartRecording = () => { onRecordingShortcutChange(true); setRecordingStateMutation.mutate(true); @@ -161,23 +202,39 @@ export function ShortcutInput({ setRecordingStateMutation.mutate(false); }; + const handleClearRecording = () => { + if (value && value.length > 0) { + savePrevious(value); + } + onChange([]); + }; + + const handleRestorePrevious = () => { + if (previousKeys.length > 0) { + onChange(previousKeys); + clearPrevious(); + } + }; + + // Keep ref in sync with state for use in subscription callback + useEffect(() => { + activeKeysRef.current = activeKeys; + }, [activeKeys]); + // Subscribe to key events when recording - // Note: activeKeys closure is fresh on each render because useSubscription - // updates its callback reference, so previousKeys correctly captures the - // previous state value when onData fires. api.settings.activeKeysUpdates.useSubscription(undefined, { enabled: isRecordingShortcut, onData: (keys: number[]) => { - const previousKeys = activeKeys; + const prevActiveKeys = activeKeysRef.current; setActiveKeys(keys); // When any key is released, validate the combination - if (previousKeys.length > 0 && keys.length < previousKeys.length) { - const result = validateShortcutFormat(previousKeys); + if (prevActiveKeys.length > 0 && keys.length < prevActiveKeys.length) { + const result = validateShortcutFormat(prevActiveKeys); if (result.valid && result.shortcut) { - // Basic format is valid - let parent handle backend validation onChange(result.shortcut); + clearPrevious(); } else { toast.error(result.error || "Invalid key combination"); } @@ -198,6 +255,10 @@ export function ShortcutInput({ } }, [isRecordingShortcut]); + if (value === undefined) { + return null; + } + return (
@@ -206,8 +267,18 @@ export function ShortcutInput({ activeKeys={activeKeys} onCancel={handleCancelRecording} /> + ) : hasShortcut ? ( + ) : ( - + )}
diff --git a/apps/desktop/src/hooks/useLocalStorage.ts b/apps/desktop/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..47cf2b3b --- /dev/null +++ b/apps/desktop/src/hooks/useLocalStorage.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; + +// Safe localStorage utilities that never throw +export const safeStorage = { + getItem(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + setItem(key: string, value: string): boolean { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } + }, + removeItem(key: string): boolean { + try { + localStorage.removeItem(key); + return true; + } catch { + return false; + } + }, +}; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, React.Dispatch>] { + const [value, setValue] = useState(() => { + const item = safeStorage.getItem(key); + if (item === null) return initialValue; + try { + return JSON.parse(item); + } catch { + safeStorage.removeItem(key); + return initialValue; + } + }); + + useEffect(() => { + safeStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/apps/desktop/src/hooks/usePreviousShortcut.ts b/apps/desktop/src/hooks/usePreviousShortcut.ts new file mode 100644 index 00000000..e422269d --- /dev/null +++ b/apps/desktop/src/hooks/usePreviousShortcut.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import { useLocalStorage } from "./useLocalStorage"; + +export function usePreviousShortcut(shortcutId: string) { + const storageKey = `previous-shortcut-${shortcutId}`; + const [previousKeys, setPreviousKeys] = useLocalStorage( + storageKey, + [], + ); + + const savePrevious = useCallback( + (keys: number[]) => { + if (keys.length > 0) { + setPreviousKeys(keys); + } + }, + [setPreviousKeys], + ); + + const restorePrevious = useCallback(() => { + const keys = previousKeys; + setPreviousKeys([]); + return keys; + }, [previousKeys, setPreviousKeys]); + + const clearPrevious = useCallback(() => { + setPreviousKeys([]); + }, [setPreviousKeys]); + + return { + previousKeys, + savePrevious, + restorePrevious, + clearPrevious, + hasPrevious: previousKeys.length > 0, + }; +} diff --git a/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx b/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx index 4be71fe4..ebe1d7bf 100644 --- a/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx @@ -7,12 +7,14 @@ import { api } from "@/trpc/react"; import { toast } from "sonner"; export function ShortcutsSettingsPage() { - const [pushToTalkShortcut, setPushToTalkShortcut] = useState([]); + const [pushToTalkShortcut, setPushToTalkShortcut] = useState< + number[] | undefined + >(); const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState< - number[] - >([]); + number[] | undefined + >(); const [pasteLastTranscriptShortcut, setPasteLastTranscriptShortcut] = - useState([]); + useState(); const [recordingShortcut, setRecordingShortcut] = useState< "pushToTalk" | "toggleRecording" | "pasteLastTranscript" | null >(null); @@ -113,6 +115,7 @@ export function ShortcutsSettingsPage() { onRecordingShortcutChange={(recording) => setRecordingShortcut(recording ? "pushToTalk" : null) } + shortcutId="pushToTalk" /> @@ -140,6 +143,7 @@ export function ShortcutsSettingsPage() { onRecordingShortcutChange={(recording) => setRecordingShortcut(recording ? "toggleRecording" : null) } + shortcutId="toggleRecording" /> @@ -168,6 +172,7 @@ export function ShortcutsSettingsPage() { recording ? "pasteLastTranscript" : null, ) } + shortcutId="pasteLastTranscript" /> diff --git a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx index f70e9d08..1e05b8ec 100644 --- a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx +++ b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx @@ -56,6 +56,7 @@ export function OnboardingShortcutInput() { onChange={handleShortcutChange} isRecordingShortcut={isRecording} onRecordingShortcutChange={setIsRecording} + shortcutId="pushToTalk" /> diff --git a/apps/desktop/src/utils/shortcut-validation.ts b/apps/desktop/src/utils/shortcut-validation.ts index be36e504..d59b9010 100644 --- a/apps/desktop/src/utils/shortcut-validation.ts +++ b/apps/desktop/src/utils/shortcut-validation.ts @@ -220,6 +220,11 @@ export function validateShortcutComprehensive( const { candidateShortcut, candidateType, shortcutsByType, platform } = context; + // Allow empty shortcut (user intentionally disabling) + if (candidateShortcut.length === 0) { + return { valid: true }; + } + const otherShortcuts = Object.entries(shortcutsByType) .filter(([shortcutType]) => shortcutType !== candidateType) .map(([, shortcutKeys]) => shortcutKeys);