From 8a446ebacdd30f25b58d2f1f7fe832f13992158f Mon Sep 17 00:00:00 2001 From: joshistoast Date: Wed, 17 Dec 2025 17:31:01 -0700 Subject: [PATCH 1/3] feat(hotkeys): :sparkles: overhaul hotkeys modal UI --- invokeai/frontend/web/public/locales/en.json | 2 + .../components/HotkeysModal/HotkeyEditor.tsx | 376 ----------- .../HotkeysModal/HotkeyListItem.tsx | 602 ++++++++++++++++-- .../components/HotkeysModal/HotkeysModal.tsx | 66 +- .../components/HotkeysModal/useHotkeyData.ts | 60 +- 5 files changed, 652 insertions(+), 454 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a50b0cb9efd..0835ad916d5 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -431,8 +431,10 @@ "editMode": "Edit Mode", "viewMode": "View Mode", "editHotkey": "Edit Hotkey", + "addHotkey": "Add Hotkey", "resetToDefault": "Reset to Default", "resetAll": "Reset All to Default", + "resetAllConfirmation": "Are you sure you want to reset all hotkeys to their default values? This cannot be undone.", "enterHotkeys": "Enter hotkey(s), separated by commas", "save": "Save", "cancel": "Cancel", diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx deleted file mode 100644 index 5873a299ae6..00000000000 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { Button, Flex, IconButton, Kbd, Text, Tooltip } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { useHotkeyData } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { hotkeyChanged, hotkeyReset } from 'features/system/store/hotkeysSlice'; -import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiArrowCounterClockwiseBold, PiCheckBold, PiPencilBold, PiTrashBold, PiXBold } from 'react-icons/pi'; - -type HotkeyEditorProps = { - hotkey: Hotkey; -}; - -const formatHotkeyForDisplay = (keys: string[]): string => { - return keys.join(', '); -}; - -// Normalize key names for consistent storage -const normalizeKey = (key: string): string => { - const keyMap: Record = { - Control: 'ctrl', - Meta: 'mod', - Command: 'mod', - Alt: 'alt', - Shift: 'shift', - ' ': 'space', - }; - return keyMap[key] || key.toLowerCase(); -}; - -// Order of modifiers for consistent output -const MODIFIER_ORDER = ['mod', 'ctrl', 'shift', 'alt']; - -const isModifierKey = (key: string): boolean => { - return ['mod', 'ctrl', 'shift', 'alt', 'control', 'meta', 'command'].includes(key.toLowerCase()); -}; - -// Build hotkey string from pressed keys -const buildHotkeyString = (keys: Set): string | null => { - const normalizedKeys = Array.from(keys).map(normalizeKey); - const modifiers = normalizedKeys.filter((k) => MODIFIER_ORDER.includes(k)); - const regularKeys = normalizedKeys.filter((k) => !MODIFIER_ORDER.includes(k)); - - // Must have at least one non-modifier key - if (regularKeys.length === 0) { - return null; - } - - // Sort modifiers in consistent order - const sortedModifiers = modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b)); - - // Combine modifiers + regular key (only use first regular key) - return [...sortedModifiers, regularKeys[0]].join('+'); -}; - -export const HotkeyEditor = memo(({ hotkey }: HotkeyEditorProps) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const allHotkeysData = useHotkeyData(); - const [isEditing, setIsEditing] = useState(false); - const [isRecording, setIsRecording] = useState(false); - const [recordedHotkeys, setRecordedHotkeys] = useState([]); - const [pressedKeys, setPressedKeys] = useState>(new Set()); - const [duplicateWarning, setDuplicateWarning] = useState<{ hotkeyString: string; conflictTitle: string } | null>( - null - ); - - const isCustomized = hotkey.hotkeys.join(',') !== hotkey.defaultHotkeys.join(','); - - // Build a flat map of all hotkeys for conflict detection - const allHotkeysMap = useMemo(() => { - const map = new Map(); - Object.entries(allHotkeysData).forEach(([category, categoryData]) => { - Object.entries(categoryData.hotkeys).forEach(([id, hotkeyData]) => { - hotkeyData.hotkeys.forEach((hotkeyString) => { - map.set(hotkeyString, { category, id, title: hotkeyData.title }); - }); - }); - }); - return map; - }, [allHotkeysData]); - - // Check if a hotkey conflicts with another hotkey (not the current one) - const findConflict = useCallback( - (hotkeyString: string): { title: string } | null => { - const conflict = allHotkeysMap.get(hotkeyString); - if (!conflict) { - return null; - } - // Check if it's the same hotkey we're editing - const currentHotkeyId = `${hotkey.category}.${hotkey.id}`; - const conflictId = `${conflict.category}.${conflict.id}`; - if (currentHotkeyId === conflictId) { - // It's the same hotkey, check if it's already in recordedHotkeys - return null; - } - return { title: conflict.title }; - }, - [allHotkeysMap, hotkey.category, hotkey.id] - ); - - const handleEdit = useCallback(() => { - setRecordedHotkeys([...hotkey.hotkeys]); - setIsEditing(true); - setIsRecording(false); - }, [hotkey.hotkeys]); - - const handleCancel = useCallback(() => { - setIsEditing(false); - setIsRecording(false); - setRecordedHotkeys([]); - setPressedKeys(new Set()); - }, []); - - const handleSave = useCallback(() => { - if (recordedHotkeys.length > 0) { - const hotkeyId = `${hotkey.category}.${hotkey.id}`; - dispatch(hotkeyChanged({ id: hotkeyId, hotkeys: recordedHotkeys })); - setIsEditing(false); - setIsRecording(false); - setRecordedHotkeys([]); - setPressedKeys(new Set()); - } - }, [dispatch, recordedHotkeys, hotkey.category, hotkey.id]); - - const handleReset = useCallback(() => { - const hotkeyId = `${hotkey.category}.${hotkey.id}`; - dispatch(hotkeyReset(hotkeyId)); - }, [dispatch, hotkey.category, hotkey.id]); - - const startRecording = useCallback(() => { - setIsRecording(true); - setPressedKeys(new Set()); - setDuplicateWarning(null); - }, []); - - const clearLastRecorded = useCallback(() => { - setRecordedHotkeys((prev) => prev.slice(0, -1)); - }, []); - - const clearAllRecorded = useCallback(() => { - setRecordedHotkeys([]); - }, []); - - // Handle keyboard events during recording - useEffect(() => { - if (!isRecording) { - return; - } - - const handleKeyDown = (e: globalThis.KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Ignore pure modifier keys being pressed - if (isModifierKey(e.key)) { - setPressedKeys((prev) => new Set(prev).add(e.key)); - return; - } - - // Build the complete key combination - const keys = new Set(); - if (e.ctrlKey) { - keys.add('Control'); - } - if (e.shiftKey) { - keys.add('Shift'); - } - if (e.altKey) { - keys.add('Alt'); - } - if (e.metaKey) { - keys.add('Meta'); - } - keys.add(e.key); - - setPressedKeys(keys); - - // Build hotkey string - const hotkeyString = buildHotkeyString(keys); - if (hotkeyString) { - // Check for duplicates in current recorded hotkeys - setRecordedHotkeys((prev) => { - if (prev.includes(hotkeyString)) { - setDuplicateWarning({ hotkeyString, conflictTitle: t('hotkeys.thisHotkey') }); - setIsRecording(false); - setPressedKeys(new Set()); - return prev; - } - - // Check for conflicts with other hotkeys in the system - const conflict = findConflict(hotkeyString); - if (conflict) { - setDuplicateWarning({ hotkeyString, conflictTitle: conflict.title }); - setIsRecording(false); - setPressedKeys(new Set()); - return prev; - } - - setDuplicateWarning(null); - setIsRecording(false); - setPressedKeys(new Set()); - return [...prev, hotkeyString]; - }); - } - }; - - const handleKeyUp = (e: globalThis.KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - window.addEventListener('keydown', handleKeyDown, true); - window.addEventListener('keyup', handleKeyUp, true); - - return () => { - window.removeEventListener('keydown', handleKeyDown, true); - window.removeEventListener('keyup', handleKeyUp, true); - }; - }, [isRecording, findConflict, t]); - - if (isEditing) { - return ( - - {/* Recorded hotkeys display */} - - - {recordedHotkeys.length > 0 ? ( - <> - {recordedHotkeys.map((key, i) => ( - - - {key.split('+').map((part, j) => ( - - - {part} - - {j !== key.split('+').length - 1 && ( - - + - - )} - - ))} - - {i !== recordedHotkeys.length - 1 && ( - - {t('common.or')} - - )} - - ))} - - ) : ( - - {t('hotkeys.noHotkeysRecorded')} - - )} - - - - {/* Recording indicator */} - {isRecording && ( - - - {t('hotkeys.pressKeys')} - - {pressedKeys.size > 0 && ( - - {Array.from(pressedKeys).map((key) => ( - - {normalizeKey(key)} - - ))} - - )} - - )} - - {/* Duplicate/Conflict warning */} - {duplicateWarning && ( - - - - {duplicateWarning.hotkeyString} - {' '} - {t('hotkeys.conflictWarning', { hotkeyTitle: duplicateWarning.conflictTitle })} - - - )} - - {/* Action buttons */} - - {!isRecording && ( - <> - - {recordedHotkeys.length > 0 && ( - <> - - } - onClick={clearLastRecorded} - size="sm" - variant="ghost" - /> - - - - )} - - )} - - - {/* Save/Cancel buttons */} - - - - - - ); - } - - return ( - - - {formatHotkeyForDisplay(hotkey.hotkeys)} - - - } - onClick={handleEdit} - size="sm" - variant="ghost" - /> - - {isCustomized && ( - - } - onClick={handleReset} - size="sm" - variant="ghost" - /> - - )} - - ); -}); - -HotkeyEditor.displayName = 'HotkeyEditor'; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx index 48b6f90ceec..a2db79d570d 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx @@ -1,53 +1,571 @@ -import { Flex, Kbd, Spacer, Text } from '@invoke-ai/ui-library'; -import { HotkeyEditor } from 'features/system/components/HotkeysModal/HotkeyEditor'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Button, Flex, IconButton, Kbd, Text, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { Fragment, memo } from 'react'; +import { IS_MAC_OS, useHotkeyConflictMap } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { hotkeyChanged, hotkeyReset } from 'features/system/store/hotkeysSlice'; +import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + PiArrowCounterClockwiseBold, + PiCheckBold, + PiPencilSimpleBold, + PiPlusBold, + PiTrashBold, + PiXBold, +} from 'react-icons/pi'; -interface Props { +// Normalize key names for consistent storage +const normalizeKey = (key: string): string => { + const keyMap: Record = { + Control: 'ctrl', + Meta: 'mod', + Command: 'mod', + Alt: 'alt', + Shift: 'shift', + ' ': 'space', + }; + return keyMap[key] || key.toLowerCase(); +}; + +// Order of modifiers for consistent output +const MODIFIER_ORDER = ['mod', 'ctrl', 'shift', 'alt']; + +const isModifierKey = (key: string): boolean => { + return ['mod', 'ctrl', 'shift', 'alt', 'control', 'meta', 'command'].includes(key.toLowerCase()); +}; + +// Build hotkey string from pressed keys +const buildHotkeyString = (keys: Set): string | null => { + const normalizedKeys = Array.from(keys).map(normalizeKey); + const modifiers = normalizedKeys.filter((k) => MODIFIER_ORDER.includes(k)); + const regularKeys = normalizedKeys.filter((k) => !MODIFIER_ORDER.includes(k)); + + // Must have at least one non-modifier key + if (regularKeys.length === 0) { + return null; + } + + // Sort modifiers in consistent order + const sortedModifiers = modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b)); + + // Combine modifiers + regular key (only use first regular key) + return [...sortedModifiers, regularKeys[0]].join('+'); +}; + +// Format key for display (platform-aware) +const formatKeyForDisplay = (key: string): string => { + if (IS_MAC_OS) { + return key.replaceAll('mod', 'cmd').replaceAll('alt', 'option'); + } + return key.replaceAll('mod', 'ctrl'); +}; + +type HotkeyEditProps = { + onEditStart?: (index: number) => void; + onEditCancel: () => void; + onEditSave: (newHotkey: string, index: number) => void; + onEditDelete?: (index: number) => void; +}; + +type HotkeyConflictInfo = { category: string; id: string; title: string; fullId: string }; + +type HotkeyItemProps = HotkeyEditProps & { + sx?: SystemStyleObject; + keyString: string; + isEditing?: boolean; + hotkeyIndex: number; + currentHotkeyId: string; + isNewHotkey?: boolean; + conflictMap: Map; +}; + +const HotkeyItemSx: SystemStyleObject = { + gap: 1, + alignItems: 'center', +}; + +const HotkeyRecorderSx: SystemStyleObject = { + px: 2, + py: 1, + bg: 'base.700', + borderRadius: 'base', + borderWidth: 1, + borderColor: 'invokeBlue.400', + minW: '100px', + justifyContent: 'center', + alignItems: 'center', + cursor: 'pointer', +}; + +const HotkeyRecorderConflictSx: SystemStyleObject = { + ...HotkeyRecorderSx, + borderColor: 'error.400', + bg: 'error.900', +}; + +export const HotkeyItem = memo( + ({ + hotkeyIndex, + keyString, + sx, + isEditing, + onEditCancel, + onEditSave, + onEditStart, + onEditDelete, + currentHotkeyId, + isNewHotkey, + conflictMap, + }: HotkeyItemProps) => { + const { t } = useTranslation(); + const [recordedKey, setRecordedKey] = useState(null); + const [isRecording, setIsRecording] = useState(false); + + // Memoize key parts to avoid repeated split calls + const keyParts = useMemo(() => keyString.split('+'), [keyString]); + const displayKeyParts = useMemo(() => keyParts.map(formatKeyForDisplay), [keyParts]); + + // Check if the recorded key conflicts with another hotkey + const conflict = useMemo(() => { + if (!recordedKey) { + return null; + } + const existingHotkey = conflictMap.get(recordedKey); + if (!existingHotkey) { + return null; + } + // Don't flag conflict if it's the same hotkey we're editing + if (existingHotkey.fullId === currentHotkeyId) { + return null; + } + return existingHotkey; + }, [recordedKey, conflictMap, currentHotkeyId]); + + // Start recording when entering edit mode + useEffect(() => { + if (isEditing) { + setRecordedKey(null); + setIsRecording(true); + } else { + setIsRecording(false); + setRecordedKey(null); + } + }, [isEditing]); + + // Handle keyboard events during recording + useEffect(() => { + if (!isRecording) { + return; + } + + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Escape cancels editing + if (e.key === 'Escape') { + onEditCancel(); + return; + } + + // Ignore pure modifier key presses + if (isModifierKey(e.key)) { + return; + } + + // Build the complete key combination + const keys = new Set(); + if (e.ctrlKey) { + keys.add('Control'); + } + if (e.shiftKey) { + keys.add('Shift'); + } + if (e.altKey) { + keys.add('Alt'); + } + if (e.metaKey) { + keys.add('Meta'); + } + keys.add(e.key); + + const hotkeyString = buildHotkeyString(keys); + if (hotkeyString) { + setRecordedKey(hotkeyString); + setIsRecording(false); + } + }; + + const handleKeyUp = (e: globalThis.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('keyup', handleKeyUp, true); + + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keyup', handleKeyUp, true); + }; + }, [isRecording, onEditCancel]); + + const onCancelEdit = useCallback(() => { + setRecordedKey(null); + onEditCancel(); + }, [onEditCancel]); + + const onStartEdit = useCallback(() => { + onEditStart?.(hotkeyIndex); + }, [onEditStart, hotkeyIndex]); + + const onSaveEdit = useCallback(() => { + // Use recorded key, or fall back to original normalized key string + const keyToSave = recordedKey ?? keyString; + onEditSave(keyToSave, hotkeyIndex); + }, [onEditSave, recordedKey, keyString, hotkeyIndex]); + + const onDeleteEdit = useCallback(() => { + onEditDelete?.(hotkeyIndex); + }, [onEditDelete, hotkeyIndex]); + + const startRecording = useCallback(() => { + setIsRecording(true); + setRecordedKey(null); + }, []); + + const canSaveEdit = useMemo(() => { + // Cannot save if no key recorded + if (!recordedKey) { + return false; + } + // Cannot save if there is a conflict + if (conflict) { + return false; + } + return true; + }, [recordedKey, conflict]); + + // Render the hotkey display or editor + const renderHotkeyKeys = () => { + if (isEditing) { + const displayKey = recordedKey ?? keyString; + const parts = displayKey.split('+').map(formatKeyForDisplay); + const hasConflict = conflict !== null; + + return ( + + + {isRecording ? ( + + {t('hotkeys.pressKeys')} + + ) : displayKey ? ( + parts.map((part, j) => ( + + + {part} + + {j !== parts.length - 1 && ( + + + + + )} + + )) + ) : ( + + {t('hotkeys.pressKeys')} + + )} + + + ); + } + + return ( + + ); + }; + + return ( + + {renderHotkeyKeys()} + + {isEditing && ( + <> + {!isNewHotkey && ( + + } + size="sm" + variant="ghost" + colorScheme="error" + onClick={onDeleteEdit} + /> + + )} + + } + size="sm" + variant="ghost" + onClick={onCancelEdit} + /> + + + } + size="sm" + variant="ghost" + onClick={onSaveEdit} + disabled={!canSaveEdit} + /> + + + )} + + + ); + } +); + +HotkeyItem.displayName = 'HotkeyItem'; + +type HotkeyItemsDisplayProps = HotkeyEditProps & { + sx?: SystemStyleObject; + hotkeys: string[]; // Original normalized hotkeys (not platform-formatted) + editingIndex?: number | null; + onAddHotkey?: () => void; + currentHotkeyId: string; + conflictMap: Map; + isCustomized?: boolean; + onReset?: () => void; +}; + +const HotkeyItemsDisplaySx: SystemStyleObject = { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + gap: 1, +}; + +export const HotkeyItemsDisplay = memo( + ({ + hotkeys, + sx, + editingIndex, + onEditStart, + onEditCancel, + onEditSave, + onEditDelete, + onAddHotkey, + currentHotkeyId, + conflictMap, + isCustomized, + onReset, + }: HotkeyItemsDisplayProps) => { + const { t } = useTranslation(); + const isAddingNew = editingIndex === hotkeys.length; + + return ( + + {hotkeys.length > 0 + ? hotkeys.map((keyString, i) => ( + + )) + : !isAddingNew && ( + + {t('hotkeys.noHotkeysRecorded')} + + )} + {isAddingNew && ( + + )} + + {isCustomized && ( + + } + size="sm" + variant="ghost" + colorScheme="warning" + onClick={onReset} + /> + + )} + {isAddingNew ? null : ( + + )} + + + ); + } +); + +HotkeyItemsDisplay.displayName = 'HotkeyItemsDisplay'; + +type HotkeyListItemProps = { hotkey: Hotkey; - showEditor?: boolean; -} + sx?: SystemStyleObject; +}; + +const HotkeyListItemSx: SystemStyleObject = { + gap: 2, + alignItems: 'start', + justifyContent: 'space-between', + width: '100%', +}; + +export const HotkeyListItem = memo(({ hotkey, sx }: HotkeyListItemProps) => { + const { title, desc, hotkeys: hotkeyKeys, defaultHotkeys } = hotkey; + + const dispatch = useAppDispatch(); + const [editingIndex, setEditingIndex] = useState(null); + const conflictMap = useHotkeyConflictMap(); + + // Check if hotkeys have been customized + const isCustomized = useMemo(() => { + if (hotkeyKeys.length !== defaultHotkeys.length) { + return true; + } + return hotkeyKeys.some((key, i) => key !== defaultHotkeys[i]); + }, [hotkeyKeys, defaultHotkeys]); + + const currentHotkeyId = `${hotkey.category}.${hotkey.id}`; + + const handleStartEdit = useCallback((index: number) => { + setEditingIndex(index); + }, []); + + const handleCancel = useCallback(() => { + setEditingIndex(null); + }, []); + + const handleSave = useCallback( + (newHotkey: string, index: number) => { + // Skip saving empty hotkeys + if (!newHotkey) { + setEditingIndex(null); + return; + } + + // Skip saving hotkey if it already exists in the list + if (hotkeyKeys.includes(newHotkey)) { + setEditingIndex(null); + return; + } + + const updatedHotkeys = [...hotkeyKeys]; + if (index >= updatedHotkeys.length) { + // Adding a new hotkey + updatedHotkeys.push(newHotkey); + } else { + // Updating an existing hotkey + updatedHotkeys[index] = newHotkey; + } + + dispatch(hotkeyChanged({ id: currentHotkeyId, hotkeys: updatedHotkeys })); + setEditingIndex(null); + }, + [dispatch, currentHotkeyId, hotkeyKeys] + ); + + const handleAddHotkey = useCallback(() => { + // Set editing index to the next available slot + setEditingIndex(hotkeyKeys.length); + }, [hotkeyKeys.length]); + + const handleDelete = useCallback( + (index: number) => { + const updatedHotkeys = hotkeyKeys.filter((_, i) => i !== index); + dispatch(hotkeyChanged({ id: currentHotkeyId, hotkeys: updatedHotkeys })); + setEditingIndex(null); + }, + [dispatch, currentHotkeyId, hotkeyKeys] + ); + + const handleReset = useCallback(() => { + dispatch(hotkeyReset(currentHotkeyId)); + }, [dispatch, currentHotkeyId]); -const HotkeyListItem = ({ hotkey, showEditor = false }: Props) => { - const { t } = useTranslation(); - const { id, platformKeys, title, desc } = hotkey; return ( - - + + {title} - - {showEditor ? ( - - ) : ( - <> - {platformKeys.map((hotkey, i1) => { - return ( - - {hotkey.map((key, i2) => ( - - {key} - {i2 !== hotkey.length - 1 && ( - - + - - )} - - ))} - {i1 !== platformKeys.length - 1 && ( - - {t('common.or')} - - )} - - ); - })} - - )} + {desc} + + + - {desc} ); -}; +}); -export default memo(HotkeyListItem); +HotkeyListItem.displayName = 'HotkeyListItem'; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx index 65498465485..e0db7661fcd 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx @@ -1,5 +1,6 @@ import { Button, + ConfirmationAlertDialog, Divider, Flex, IconButton, @@ -13,13 +14,14 @@ import { ModalFooter, ModalHeader, ModalOverlay, + Text, useDisclosure, } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { useHotkeyData } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { isHotkeysModified, useHotkeyData } from 'features/system/components/HotkeysModal/useHotkeyData'; import { StickyScrollable } from 'features/system/components/StickyScrollable'; import { allHotkeysReset } from 'features/system/store/hotkeysSlice'; import type { ChangeEventHandler, ReactElement } from 'react'; @@ -27,7 +29,7 @@ import { cloneElement, Fragment, memo, useCallback, useMemo, useRef, useState } import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -import HotkeyListItem from './HotkeyListItem'; +import { HotkeyListItem } from './HotkeyListItem'; type HotkeysModalProps = { /* The button to open the Settings Modal */ @@ -39,53 +41,52 @@ type TransformedHotkeysCategoryData = { hotkeys: Hotkey[]; }; +// Helper to check if a hotkey matches the search filter +const matchesFilter = (item: Hotkey, filter: string): boolean => { + return [item.title, item.desc, item.category, ...item.platformKeys.flat()].some((text) => + text.toLowerCase().includes(filter) + ); +}; + const HotkeysModal = ({ children }: HotkeysModalProps) => { const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen: isResetDialogOpen, onOpen: onResetDialogOpen, onClose: onResetDialogClose } = useDisclosure(); const { t } = useTranslation(); const dispatch = useAppDispatch(); const [hotkeyFilter, setHotkeyFilter] = useState(''); - const [isEditMode, setIsEditMode] = useState(false); const inputRef = useRef(null); const clearHotkeyFilter = useCallback(() => setHotkeyFilter(''), []); const onChange = useCallback>((e) => setHotkeyFilter(e.target.value), []); - const toggleEditMode = useCallback(() => setIsEditMode((prev) => !prev), []); const handleResetAll = useCallback(() => { dispatch(allHotkeysReset()); - }, [dispatch]); + onResetDialogClose(); + }, [dispatch, onResetDialogClose]); const hotkeysData = useHotkeyData(); const filteredHotkeys = useMemo(() => { const trimmedHotkeyFilter = hotkeyFilter.trim().toLowerCase(); const filteredCategories: TransformedHotkeysCategoryData[] = []; - Object.values(hotkeysData).forEach((category) => { + for (const category of Object.values(hotkeysData)) { const filteredGroup: TransformedHotkeysCategoryData = { title: category.title, hotkeys: [], }; - Object.values(category.hotkeys).forEach((item) => { + for (const item of Object.values(category.hotkeys)) { if (!item.isEnabled) { - return; + continue; } - if (!trimmedHotkeyFilter.length) { - filteredGroup.hotkeys.push(item); - } else if (item.title.toLowerCase().includes(trimmedHotkeyFilter)) { - filteredGroup.hotkeys.push(item); - } else if (item.desc.toLowerCase().includes(trimmedHotkeyFilter)) { - filteredGroup.hotkeys.push(item); - } else if (item.category.toLowerCase().includes(trimmedHotkeyFilter)) { - filteredGroup.hotkeys.push(item); - } else if ( - item.platformKeys.some((hotkey) => hotkey.some((key) => key.toLowerCase().includes(trimmedHotkeyFilter))) - ) { + if (!trimmedHotkeyFilter.length || matchesFilter(item, trimmedHotkeyFilter)) { filteredGroup.hotkeys.push(item); } - }); + } if (filteredGroup.hotkeys.length) { filteredCategories.push(filteredGroup); } - }); + } return filteredCategories; }, [hotkeysData, hotkeyFilter]); + const canResetHotkeys = useMemo(() => isHotkeysModified(hotkeysData), [hotkeysData]); + return ( <> {cloneElement(children, { @@ -119,7 +120,7 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => { {category.hotkeys.map((hotkey, i) => ( - + {i < category.hotkeys.length - 1 && } ))} @@ -131,18 +132,25 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => { - - {isEditMode && ( - - )} + + + {t('hotkeys.resetAllConfirmation')} + + ); }; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index b52563faf3c..8f09df844e7 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -7,6 +7,17 @@ import { assert } from 'tsafe'; type HotkeyCategory = 'app' | 'canvas' | 'viewer' | 'gallery' | 'workflows'; +// Centralized platform detection - computed once +export const IS_MAC_OS = + typeof navigator !== 'undefined' && + ( + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + '' + ) + .toLowerCase() + .includes('mac'); + export type Hotkey = { id: string; category: string; @@ -22,9 +33,9 @@ type HotkeyCategoryData = { title: string; hotkeys: Record }; type HotkeysData = Record; -const formatKeysForPlatform = (keys: string[], isMacOS: boolean): string[][] => { +const formatKeysForPlatform = (keys: string[]): string[][] => { return keys.map((k) => { - if (isMacOS) { + if (IS_MAC_OS) { return k.split('+').map((i) => i.replaceAll('mod', 'cmd').replaceAll('alt', 'option')); } else { return k.split('+').map((i) => i.replaceAll('mod', 'ctrl')); @@ -35,9 +46,6 @@ const formatKeysForPlatform = (keys: string[], isMacOS: boolean): string[][] => export const useHotkeyData = (): HotkeysData => { const { t } = useTranslation(); const customHotkeys = useAppSelector(selectCustomHotkeys); - const isMacOS = useMemo(() => { - return navigator.userAgent.toLowerCase().includes('mac'); - }, []); const hotkeysData = useMemo(() => { const data: HotkeysData = { @@ -73,7 +81,7 @@ export const useHotkeyData = (): HotkeysData => { desc: t(`hotkeys.${category}.${id}.desc`), hotkeys: effectiveKeys, defaultHotkeys: keys, - platformKeys: formatKeysForPlatform(effectiveKeys, isMacOS), + platformKeys: formatKeysForPlatform(effectiveKeys), isEnabled, }; }; @@ -177,11 +185,32 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('gallery', 'starImage', ['.']); return data; - }, [customHotkeys, isMacOS, t]); + }, [customHotkeys, t]); return hotkeysData; }; +type HotkeyConflictInfo = { category: string; id: string; title: string; fullId: string }; + +/** + * Returns a map of all registered hotkeys for conflict detection. + * Computed once and shared across all hotkey items. + */ +export const useHotkeyConflictMap = (): Map => { + const hotkeysData = useHotkeyData(); + return useMemo(() => { + const map = new Map(); + for (const [category, categoryData] of Object.entries(hotkeysData)) { + for (const [id, hotkeyData] of Object.entries(categoryData.hotkeys)) { + for (const hotkeyString of hotkeyData.hotkeys) { + map.set(hotkeyString, { category, id, title: hotkeyData.title, fullId: `${category}.${id}` }); + } + } + } + return map; + }, [hotkeysData]); +}; + type UseRegisteredHotkeysArg = { /** * The unique identifier for the hotkey. If `title` and `description` are omitted, the `id` will be used to look up @@ -234,3 +263,20 @@ export const useRegisteredHotkeys = ({ id, category, callback, options, dependen return useHotkeys(data.hotkeys, callback, _options, dependencies); }; + +/* + * Returns true if any hotkeys have been modified from their default values. + */ +export const isHotkeysModified = (hotkeysData: HotkeysData): boolean => { + for (const categoryData of Object.values(hotkeysData)) { + for (const hotkeyData of Object.values(categoryData.hotkeys)) { + if ( + hotkeyData.hotkeys.length !== hotkeyData.defaultHotkeys.length || + !hotkeyData.hotkeys.every((key, index) => key === hotkeyData.defaultHotkeys[index]) + ) { + return true; + } + } + } + return false; +}; From f8831178cdf53784d90f0c1f6ca5879eb7667073 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Wed, 17 Dec 2025 22:12:09 -0700 Subject: [PATCH 2/3] fix(model manager): :adhesive_bandage: improved check for hotkey search clear button --- .../features/system/components/HotkeysModal/HotkeysModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx index e0db7661fcd..138dcfa9a01 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx @@ -100,7 +100,7 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => { - {hotkeyFilter.length && ( + {hotkeyFilter.length > 0 ? ( { icon={} /> - )} + ) : null} From 379863d760e97db24c82965bd522107b059c4737 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Wed, 17 Dec 2025 22:16:13 -0700 Subject: [PATCH 3/3] fix(model manager): :adhesive_bandage: remove unused exports --- .../system/components/HotkeysModal/HotkeyListItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx index a2db79d570d..b9e8446e02d 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx @@ -104,7 +104,7 @@ const HotkeyRecorderConflictSx: SystemStyleObject = { bg: 'error.900', }; -export const HotkeyItem = memo( +const HotkeyItem = memo( ({ hotkeyIndex, keyString, @@ -379,7 +379,7 @@ const HotkeyItemsDisplaySx: SystemStyleObject = { gap: 1, }; -export const HotkeyItemsDisplay = memo( +const HotkeyItemsDisplay = memo( ({ hotkeys, sx,