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 6f2aa8d97f8..bdb6536eae8 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx @@ -1,8 +1,16 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Flex, IconButton, Kbd, Text, Tooltip } from '@invoke-ai/ui-library'; import type { AppThunkDispatch } from 'app/store/store'; +import { + areHotkeyStringsEquivalent, + formatHotkeyStringForPlatform, + getHotkeyKeyFromEvent, + getHotkeyStringAliases, + IS_MAC_OS, + normalizeHotkeyKey, +} from 'features/system/components/HotkeysModal/hotkeyStrings'; import type { Hotkey, HotkeyConflictInfo } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { IS_MAC_OS } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useKeyboardLayoutMap } from 'features/system/components/HotkeysModal/useKeyboardLayoutMap'; import { hotkeyChanged, hotkeyReset } from 'features/system/store/hotkeysSlice'; import type { TFunction } from 'i18next'; import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'; @@ -15,30 +23,6 @@ import { PiXBold, } from 'react-icons/pi'; -// Normalize key names for consistent storage -// Maps platform-specific modifier keys to the cross-platform 'mod' format used by react-hotkeys-hook -// On Mac: Meta (Command) → mod -// On Linux/Windows: Control → mod -const normalizeKey = (key: string): string => { - const keyMap: Record = IS_MAC_OS - ? { - Meta: 'mod', - Command: 'mod', - Control: 'ctrl', - Alt: 'alt', - Shift: 'shift', - ' ': 'space', - } - : { - Control: 'mod', // On non-Mac, Ctrl is the primary modifier (mapped to 'mod') - Meta: 'meta', // Windows key - rarely used for hotkeys - Alt: 'alt', - Shift: 'shift', - ' ': 'space', - }; - return keyMap[key] || key.toLowerCase(); -}; - // Order of modifiers for consistent output // 'mod' is the cross-platform primary modifier (Cmd on Mac, Ctrl on Linux/Windows) // 'ctrl' is only used on Mac (when Ctrl is pressed separately from Cmd) @@ -51,7 +35,7 @@ const isModifierKey = (key: string): boolean => { // Build hotkey string from pressed keys const buildHotkeyString = (keys: Set): string | null => { - const normalizedKeys = Array.from(keys).map(normalizeKey); + const normalizedKeys = Array.from(keys).map((key) => normalizeHotkeyKey(key)); const modifiers = normalizedKeys.filter((k) => MODIFIER_ORDER.includes(k)); const regularKeys = normalizedKeys.filter((k) => !MODIFIER_ORDER.includes(k)); @@ -67,14 +51,6 @@ const buildHotkeyString = (keys: Set): string | null => { 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; @@ -129,25 +105,30 @@ const HotkeyItem = memo( }: HotkeyItemProps) => { const [recordedKey, setRecordedKey] = useState(null); const [isRecording, setIsRecording] = useState(false); + const keyboardLayoutMap = useKeyboardLayoutMap(); - // Memoize key parts to avoid repeated split calls - const keyParts = useMemo(() => keyString.split('+'), [keyString]); - const displayKeyParts = useMemo(() => keyParts.map(formatKeyForDisplay), [keyParts]); + const displayKeyParts = useMemo( + () => formatHotkeyStringForPlatform(keyString, IS_MAC_OS, keyboardLayoutMap), + [keyboardLayoutMap, keyString] + ); // 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; + for (const recordedKeyAlias of getHotkeyStringAliases(recordedKey)) { + const existingHotkey = conflictMap.get(recordedKeyAlias); + if (!existingHotkey) { + continue; + } + // Don't flag conflict if it's the same hotkey we're editing + if (existingHotkey.fullId === currentHotkeyId) { + continue; + } + return existingHotkey; } - return existingHotkey; + return null; }, [recordedKey, conflictMap, currentHotkeyId]); // Start recording when entering edit mode @@ -196,7 +177,7 @@ const HotkeyItem = memo( if (e.metaKey) { keys.add('Meta'); } - keys.add(e.key); + keys.add(getHotkeyKeyFromEvent(e.key, e.code)); const hotkeyString = buildHotkeyString(keys); if (hotkeyString) { @@ -259,7 +240,9 @@ const HotkeyItem = memo( const renderHotkeyKeys = () => { if (isEditing) { const displayKey = recordedKey ?? keyString; - const parts = displayKey.split('+').map(formatKeyForDisplay); + const parts = recordedKey + ? formatHotkeyStringForPlatform(displayKey, IS_MAC_OS, keyboardLayoutMap) + : displayKeyParts; const hasConflict = conflict !== null; return ( @@ -527,8 +510,8 @@ export const HotkeyListItem = memo(({ lastItem, hotkey, sx, conflictMap, t, disp return; } - // Skip saving hotkey if it already exists in the list - if (hotkeyKeys.includes(newHotkey)) { + // Skip saving hotkey if it already exists in the list, including legacy glyph aliases. + if (hotkeyKeys.some((hotkeyKey, i) => i !== index && areHotkeyStringsEquivalent(hotkeyKey, newHotkey))) { setEditingIndex(null); return; } diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.test.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.test.ts new file mode 100644 index 00000000000..b3661f9da94 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + areHotkeyStringsEquivalent, + canonicalizeHotkeyString, + formatHotkeyKeyForDisplay, + formatHotkeyStringForPlatform, + getHotkeyKeyFromEvent, + getHotkeyStringAliases, +} from './hotkeyStrings'; + +describe('hotkeyStrings', () => { + it('maps bracket key events to layout-independent physical-key tokens', () => { + expect(getHotkeyKeyFromEvent('[', 'BracketLeft')).toBe('bracketleft'); + expect(getHotkeyKeyFromEvent(']', 'BracketRight')).toBe('bracketright'); + expect(getHotkeyKeyFromEvent('х', 'BracketLeft')).toBe('bracketleft'); + expect(getHotkeyKeyFromEvent('ъ', 'BracketRight')).toBe('bracketright'); + }); + + it('does not infer physical punctuation keys without matching event codes', () => { + expect(getHotkeyKeyFromEvent('[', 'Digit8')).toBe('['); + expect(getHotkeyKeyFromEvent('[', undefined)).toBe('['); + expect(canonicalizeHotkeyString('[')).toBe('['); + expect(canonicalizeHotkeyString(']')).toBe(']'); + expect(canonicalizeHotkeyString('.')).toBe('.'); + expect(canonicalizeHotkeyString('Control+[', false)).toBe('mod+['); + expect(canonicalizeHotkeyString('Meta+[', true)).toBe('mod+['); + }); + + it('formats physical bracket keys back to readable glyphs for display', () => { + expect(formatHotkeyKeyForDisplay('bracketleft', false)).toBe('['); + expect(formatHotkeyKeyForDisplay('bracketright', false)).toBe(']'); + expect(formatHotkeyKeyForDisplay('period', false)).toBe('.'); + expect(formatHotkeyStringForPlatform('mod+bracketleft', false)).toEqual(['ctrl', '[']); + expect(formatHotkeyStringForPlatform('mod+bracketright', false)).toEqual(['ctrl', ']']); + expect(formatHotkeyStringForPlatform('period', false)).toEqual(['.']); + }); + + it('uses the browser keyboard layout map when available', () => { + const keyboardLayoutMap = new Map([ + ['BracketLeft', 'х'], + ['BracketRight', 'ъ'], + ]); + + expect(formatHotkeyKeyForDisplay('bracketleft', false, keyboardLayoutMap)).toBe('х'); + expect(formatHotkeyStringForPlatform('mod+bracketright', false, keyboardLayoutMap)).toEqual(['ctrl', 'ъ']); + }); + + it('expands physical and legacy glyph aliases for conflict detection', () => { + expect(getHotkeyStringAliases('bracketleft', false)).toEqual(['bracketleft', '[']); + expect(getHotkeyStringAliases('[', false)).toEqual(['[', 'bracketleft']); + expect(getHotkeyStringAliases('shift+period', false)).toEqual(['shift+period', 'shift+.', 'shift+>']); + expect(getHotkeyStringAliases('shift+>', false)).toEqual(['shift+>', 'shift+period', 'shift+.']); + expect(areHotkeyStringsEquivalent('shift+>', 'shift+period', false)).toBe(true); + }); +}); diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.ts new file mode 100644 index 00000000000..0e19f0b4c67 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.ts @@ -0,0 +1,158 @@ +export const IS_MAC_OS = + typeof navigator !== 'undefined' && + ( + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + '' + ) + .toLowerCase() + .includes('mac'); + +export type HotkeyKeyboardLayoutMap = Pick, 'get'>; + +const HOTKEY_PHYSICAL_KEY_ALIASES = [ + { code: 'Backquote', token: 'backquote', display: '`', shiftedDisplay: '~' }, + { code: 'Minus', token: 'minus', display: '-', shiftedDisplay: '_' }, + { code: 'Equal', token: 'equal', display: '=', shiftedDisplay: '+' }, + { code: 'BracketLeft', token: 'bracketleft', display: '[', shiftedDisplay: '{' }, + { code: 'BracketRight', token: 'bracketright', display: ']', shiftedDisplay: '}' }, + { code: 'Backslash', token: 'backslash', display: '\\', shiftedDisplay: '|' }, + { code: 'Semicolon', token: 'semicolon', display: ';', shiftedDisplay: ':' }, + { code: 'Quote', token: 'quote', display: "'", shiftedDisplay: '"' }, + { code: 'Comma', token: 'comma', display: ',', shiftedDisplay: '<' }, + { code: 'Period', token: 'period', display: '.', shiftedDisplay: '>' }, + { code: 'Slash', token: 'slash', display: '/', shiftedDisplay: '?' }, +] as const; + +const HOTKEY_KEY_ALIASES_BY_CODE = Object.fromEntries( + HOTKEY_PHYSICAL_KEY_ALIASES.map(({ code, token }) => [code, token]) +) as Record; + +const HOTKEY_KEY_CODES_BY_ALIAS = Object.fromEntries( + HOTKEY_PHYSICAL_KEY_ALIASES.map(({ code, token }) => [token, code]) +) as Record; + +const HOTKEY_KEY_DISPLAY_ALIASES = Object.fromEntries( + HOTKEY_PHYSICAL_KEY_ALIASES.map(({ token, display }) => [token, display]) +) as Record; + +const HOTKEY_PHYSICAL_KEY_ALIASES_BY_KEY = Object.fromEntries( + HOTKEY_PHYSICAL_KEY_ALIASES.flatMap((alias) => [ + [alias.token, alias], + [alias.display, alias], + [alias.shiftedDisplay, alias], + ]) +) as Record; + +const HOTKEY_MODIFIER_KEYS = new Set(['mod', 'ctrl', 'meta', 'shift', 'alt']); + +export const getHotkeyKeyFromEvent = (key: string, code?: string): string => { + const codeAlias = code ? HOTKEY_KEY_ALIASES_BY_CODE[code] : undefined; + if (codeAlias) { + return codeAlias; + } + + return key; +}; + +export const normalizeHotkeyKey = (key: string, isMac: boolean = IS_MAC_OS): string => { + const keyMap: Record = isMac + ? { + Meta: 'mod', + meta: 'mod', + Command: 'mod', + command: 'mod', + Control: 'ctrl', + control: 'ctrl', + Alt: 'alt', + alt: 'alt', + Shift: 'shift', + shift: 'shift', + ' ': 'space', + Spacebar: 'space', + spacebar: 'space', + } + : { + Control: 'mod', + control: 'mod', + Meta: 'meta', + meta: 'meta', + Alt: 'alt', + alt: 'alt', + Shift: 'shift', + shift: 'shift', + ' ': 'space', + Spacebar: 'space', + spacebar: 'space', + }; + + return keyMap[key] || key.toLowerCase(); +}; + +export const canonicalizeHotkeyString = (hotkey: string, isMac: boolean = IS_MAC_OS): string => { + return hotkey + .split('+') + .map((key) => normalizeHotkeyKey(key.trim() || key, isMac)) + .join('+'); +}; + +export const getHotkeyStringAliases = (hotkey: string, isMac: boolean = IS_MAC_OS): string[] => { + const parts = canonicalizeHotkeyString(hotkey, isMac).split('+'); + const regularKeyIndex = parts.findIndex((part) => !HOTKEY_MODIFIER_KEYS.has(part)); + + if (regularKeyIndex === -1) { + return [parts.join('+')]; + } + + const regularKey = parts[regularKeyIndex]; + if (!regularKey) { + return [parts.join('+')]; + } + + const modifiers = parts.filter((_, index) => index !== regularKeyIndex); + const physicalKeyAlias = HOTKEY_PHYSICAL_KEY_ALIASES_BY_KEY[regularKey]; + + if (!physicalKeyAlias) { + return [parts.join('+')]; + } + + const regularKeyAliases = new Set([regularKey, physicalKeyAlias.token, physicalKeyAlias.display]); + if (modifiers.includes('shift')) { + regularKeyAliases.add(physicalKeyAlias.shiftedDisplay); + } + + return [...regularKeyAliases].map((alias) => [...modifiers, alias].join('+')); +}; + +export const areHotkeyStringsEquivalent = ( + firstHotkey: string, + secondHotkey: string, + isMac: boolean = IS_MAC_OS +): boolean => { + const firstAliases = new Set(getHotkeyStringAliases(firstHotkey, isMac)); + return getHotkeyStringAliases(secondHotkey, isMac).some((alias) => firstAliases.has(alias)); +}; + +export const formatHotkeyKeyForDisplay = ( + key: string, + isMac: boolean = IS_MAC_OS, + keyboardLayoutMap?: HotkeyKeyboardLayoutMap | null +): string => { + const normalizedKey = key.toLowerCase(); + const layoutMapKey = keyboardLayoutMap?.get(HOTKEY_KEY_CODES_BY_ALIAS[normalizedKey] ?? ''); + const displayKey = layoutMapKey || HOTKEY_KEY_DISPLAY_ALIASES[normalizedKey] || normalizedKey; + + if (isMac) { + return displayKey.replaceAll('mod', 'cmd').replaceAll('alt', 'option'); + } + + return displayKey.replaceAll('mod', 'ctrl'); +}; + +export const formatHotkeyStringForPlatform = ( + hotkey: string, + isMac: boolean = IS_MAC_OS, + keyboardLayoutMap?: HotkeyKeyboardLayoutMap | null +): string[] => { + return hotkey.split('+').map((key) => formatHotkeyKeyForDisplay(key, isMac, keyboardLayoutMap)); +}; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.test.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.test.ts index ad3a22d1c87..528612c3903 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.test.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.test.ts @@ -41,4 +41,49 @@ describe('buildHotkeysData', () => { expect(mergeVisible.defaultHotkeys).toEqual(['mod+shift+e']); expect(mergeVisible.hotkeys).toEqual(['alt+shift+m']); }); + + it('registers bracket tool-width hotkeys as layout-independent physical keys', () => { + const hotkeysData = buildHotkeysData(t, {}); + const decrementToolWidth = hotkeysData.canvas.hotkeys.decrementToolWidth; + const incrementToolWidth = hotkeysData.canvas.hotkeys.incrementToolWidth; + const starImage = hotkeysData.gallery.hotkeys.starImage; + + expect(decrementToolWidth).toBeDefined(); + expect(incrementToolWidth).toBeDefined(); + expect(starImage).toBeDefined(); + if (!decrementToolWidth || !incrementToolWidth || !starImage) { + throw new Error('Expected layout-sensitive punctuation hotkeys to be registered'); + } + + expect(decrementToolWidth.defaultHotkeys).toEqual(['bracketleft']); + expect(decrementToolWidth.hotkeys).toEqual(['bracketleft']); + expect(incrementToolWidth.defaultHotkeys).toEqual(['bracketright']); + expect(incrementToolWidth.hotkeys).toEqual(['bracketright']); + expect(starImage.defaultHotkeys).toEqual(['period']); + expect(starImage.hotkeys).toEqual(['period']); + }); + + it('preserves custom punctuation glyph hotkeys without retargeting them to physical keys', () => { + const hotkeysData = buildHotkeysData(t, { + 'canvas.decrementToolWidth': ['['], + 'canvas.incrementToolWidth': [']'], + }); + + expect(hotkeysData.canvas.hotkeys.decrementToolWidth?.hotkeys).toEqual(['[']); + expect(hotkeysData.canvas.hotkeys.incrementToolWidth?.hotkeys).toEqual([']']); + }); + + it('formats physical hotkeys with the browser keyboard layout map when available', () => { + const hotkeysData = buildHotkeysData( + t, + {}, + new Map([ + ['BracketLeft', 'х'], + ['BracketRight', 'ъ'], + ]) + ); + + expect(hotkeysData.canvas.hotkeys.decrementToolWidth?.platformKeys).toEqual([['х']]); + expect(hotkeysData.canvas.hotkeys.incrementToolWidth?.platformKeys).toEqual([['ъ']]); + }); }); 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 21a54f17edd..c31bf418eba 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -1,5 +1,13 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useIsUncommittedCanvasTextSessionActive } from 'features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive'; +import { + canonicalizeHotkeyString, + formatHotkeyStringForPlatform, + getHotkeyStringAliases, + type HotkeyKeyboardLayoutMap, + IS_MAC_OS, +} from 'features/system/components/HotkeysModal/hotkeyStrings'; +import { useKeyboardLayoutMap } from 'features/system/components/HotkeysModal/useKeyboardLayoutMap'; import { selectCustomHotkeys } from 'features/system/store/hotkeysSlice'; import { useMemo } from 'react'; import { type HotkeyCallback, type Options, useHotkeys } from 'react-hotkeys-hook'; @@ -8,16 +16,7 @@ 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 { IS_MAC_OS } from 'features/system/components/HotkeysModal/hotkeyStrings'; export type Hotkey = { id: string; @@ -36,17 +35,15 @@ type HotkeysData = Record; type HotkeyTranslator = (key: string) => string; type CustomHotkeys = Record; -const formatKeysForPlatform = (keys: string[]): string[][] => { - return keys.map((k) => { - 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')); - } - }); +const formatKeysForPlatform = (keys: string[], keyboardLayoutMap?: HotkeyKeyboardLayoutMap | null): string[][] => { + return keys.map((key) => formatHotkeyStringForPlatform(key, IS_MAC_OS, keyboardLayoutMap)); }; -export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotkeys): HotkeysData => { +export const buildHotkeysData = ( + t: HotkeyTranslator, + customHotkeys: CustomHotkeys, + keyboardLayoutMap?: HotkeyKeyboardLayoutMap | null +): HotkeysData => { const data: HotkeysData = { app: { title: t('hotkeys.app.title'), @@ -72,15 +69,16 @@ export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotke const addHotkey = (category: HotkeyCategory, id: string, keys: string[], isEnabled: boolean = true) => { const hotkeyId = `${category}.${id}`; - const effectiveKeys = customHotkeys[hotkeyId] ?? keys; + const defaultHotkeys = keys.map((key) => canonicalizeHotkeyString(key, IS_MAC_OS)); + const effectiveKeys = customHotkeys[hotkeyId] ?? defaultHotkeys; data[category].hotkeys[id] = { id, category, title: t(`hotkeys.${category}.${id}.title`), desc: t(`hotkeys.${category}.${id}.desc`), hotkeys: effectiveKeys, - defaultHotkeys: keys, - platformKeys: formatKeysForPlatform(effectiveKeys), + defaultHotkeys, + platformKeys: formatKeysForPlatform(effectiveKeys, keyboardLayoutMap), isEnabled, }; }; @@ -111,8 +109,8 @@ export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotke // Canvas addHotkey('canvas', 'selectBrushTool', ['b']); addHotkey('canvas', 'selectBboxTool', ['c']); - addHotkey('canvas', 'decrementToolWidth', ['[']); - addHotkey('canvas', 'incrementToolWidth', [']']); + addHotkey('canvas', 'decrementToolWidth', ['bracketleft']); + addHotkey('canvas', 'incrementToolWidth', ['bracketright']); addHotkey('canvas', 'selectEraserTool', ['e']); addHotkey('canvas', 'selectMoveTool', ['v']); addHotkey('canvas', 'selectRectTool', ['u']); @@ -138,8 +136,8 @@ export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotke addHotkey('canvas', 'invertMask', ['shift+v']); addHotkey('canvas', 'undo', ['mod+z']); addHotkey('canvas', 'redo', ['mod+shift+z', 'mod+y']); - addHotkey('canvas', 'nextEntity', ['alt+]']); - addHotkey('canvas', 'prevEntity', ['alt+[']); + addHotkey('canvas', 'nextEntity', ['alt+bracketright']); + addHotkey('canvas', 'prevEntity', ['alt+bracketleft']); addHotkey('canvas', 'applyFilter', ['enter']); addHotkey('canvas', 'cancelFilter', ['esc']); addHotkey('canvas', 'applyTransform', ['enter']); @@ -184,7 +182,7 @@ export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotke addHotkey('gallery', 'galleryNavDownAlt', ['alt+down']); addHotkey('gallery', 'galleryNavLeftAlt', ['alt+left']); addHotkey('gallery', 'deleteSelection', ['delete', 'backspace']); - addHotkey('gallery', 'starImage', ['.']); + addHotkey('gallery', 'starImage', ['period']); return data; }; @@ -192,8 +190,12 @@ export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotke export const useHotkeyData = (): HotkeysData => { const { t } = useTranslation(); const customHotkeys = useAppSelector(selectCustomHotkeys); + const keyboardLayoutMap = useKeyboardLayoutMap(); - const hotkeysData = useMemo(() => buildHotkeysData((key) => t(key), customHotkeys), [customHotkeys, t]); + const hotkeysData = useMemo( + () => buildHotkeysData((key) => t(key), customHotkeys, keyboardLayoutMap), + [customHotkeys, keyboardLayoutMap, t] + ); return hotkeysData; }; @@ -211,7 +213,11 @@ export const useHotkeyConflictMap = (): 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}` }); + for (const hotkeyStringAlias of getHotkeyStringAliases(hotkeyString, IS_MAC_OS)) { + if (!map.has(hotkeyStringAlias)) { + map.set(hotkeyStringAlias, { category, id, title: hotkeyData.title, fullId: `${category}.${id}` }); + } + } } } } diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useKeyboardLayoutMap.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useKeyboardLayoutMap.ts new file mode 100644 index 00000000000..c2e42c3b489 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useKeyboardLayoutMap.ts @@ -0,0 +1,62 @@ +import type { HotkeyKeyboardLayoutMap } from 'features/system/components/HotkeysModal/hotkeyStrings'; +import { useEffect, useState } from 'react'; + +type NavigatorWithKeyboardLayoutMap = Navigator & { + keyboard?: { + getLayoutMap?: () => Promise; + }; +}; + +let cachedKeyboardLayoutMap: HotkeyKeyboardLayoutMap | null | undefined; +let keyboardLayoutMapPromise: Promise | null = null; + +const loadKeyboardLayoutMap = async (): Promise => { + if (cachedKeyboardLayoutMap !== undefined) { + return cachedKeyboardLayoutMap; + } + + if (typeof navigator === 'undefined') { + cachedKeyboardLayoutMap = null; + return cachedKeyboardLayoutMap; + } + + const keyboard = (navigator as NavigatorWithKeyboardLayoutMap).keyboard; + if (!keyboard?.getLayoutMap) { + cachedKeyboardLayoutMap = null; + return cachedKeyboardLayoutMap; + } + + try { + cachedKeyboardLayoutMap = await keyboard.getLayoutMap(); + } catch { + cachedKeyboardLayoutMap = null; + } + + return cachedKeyboardLayoutMap; +}; + +export const useKeyboardLayoutMap = (): HotkeyKeyboardLayoutMap | null => { + const [keyboardLayoutMap, setKeyboardLayoutMap] = useState( + cachedKeyboardLayoutMap ?? null + ); + + useEffect(() => { + let isSubscribed = true; + + if (!keyboardLayoutMapPromise) { + keyboardLayoutMapPromise = loadKeyboardLayoutMap(); + } + + keyboardLayoutMapPromise.then((layoutMap) => { + if (isSubscribed) { + setKeyboardLayoutMap(layoutMap); + } + }); + + return () => { + isSubscribed = false; + }; + }, []); + + return keyboardLayoutMap; +}; diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts index e0347ff4a18..c4275dc340b 100644 --- a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts +++ b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts @@ -5,7 +5,7 @@ import type { HotkeysState } from './hotkeysTypes'; describe('Hotkeys Slice', () => { const getInitialState = (): HotkeysState => ({ - _version: 1, + _version: 2, customHotkeys: {}, }); @@ -17,7 +17,7 @@ describe('Hotkeys Slice', () => { const action = hotkeyChanged({ id: 'app.invoke', hotkeys: ['ctrl+shift+enter'] }); const result = reducer(state, action); expect(result).toEqual({ - _version: 1, + _version: 2, customHotkeys: { 'app.invoke': ['ctrl+shift+enter'], }, @@ -26,7 +26,7 @@ describe('Hotkeys Slice', () => { it('should update an existing custom hotkey', () => { const state: HotkeysState = { - _version: 1, + _version: 2, customHotkeys: { 'app.invoke': ['ctrl+enter'], }, @@ -34,7 +34,7 @@ describe('Hotkeys Slice', () => { const action = hotkeyChanged({ id: 'app.invoke', hotkeys: ['ctrl+shift+enter'] }); const result = reducer(state, action); expect(result).toEqual({ - _version: 1, + _version: 2, customHotkeys: { 'app.invoke': ['ctrl+shift+enter'], }, @@ -45,7 +45,7 @@ describe('Hotkeys Slice', () => { describe('hotkeyReset', () => { it('should remove a custom hotkey', () => { const state: HotkeysState = { - _version: 1, + _version: 2, customHotkeys: { 'app.invoke': ['ctrl+shift+enter'], 'app.cancelQueueItem': ['shift+x'], @@ -62,7 +62,7 @@ describe('Hotkeys Slice', () => { describe('allHotkeysReset', () => { it('should clear all custom hotkeys', () => { const state: HotkeysState = { - _version: 1, + _version: 2, customHotkeys: { 'app.invoke': ['ctrl+shift+enter'], 'app.cancelQueueItem': ['shift+x'], @@ -71,9 +71,34 @@ describe('Hotkeys Slice', () => { const action = allHotkeysReset(); const result = reducer(state, action); expect(result).toEqual({ - _version: 1, + _version: 2, customHotkeys: {}, }); }); }); + + describe('migration', () => { + it('should migrate v1 persisted hotkeys without remapping punctuation glyphs to physical keys', () => { + const migrate = hotkeysSliceConfig.persistConfig?.migrate; + if (!migrate) { + throw new Error('Expected hotkeys slice to be persisted'); + } + + const result = migrate({ + _version: 1, + customHotkeys: { + 'canvas.decrementToolWidth': ['Shift+[', ' '], + 'canvas.incrementToolWidth': [']'], + }, + }); + + expect(result).toEqual({ + _version: 2, + customHotkeys: { + 'canvas.decrementToolWidth': ['shift+[', 'space'], + 'canvas.incrementToolWidth': [']'], + }, + }); + }); + }); }); diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts index 7ef7c2c4a9a..e8ecdade5fa 100644 --- a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts @@ -3,12 +3,13 @@ import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; +import { canonicalizeHotkeyString } from 'features/system/components/HotkeysModal/hotkeyStrings'; import { assert } from 'tsafe'; import { type HotkeysState, zHotkeysState } from './hotkeysTypes'; const getInitialState = (): HotkeysState => ({ - _version: 1, + _version: 2, customHotkeys: {}, }); @@ -41,6 +42,18 @@ export const hotkeysSliceConfig: SliceConfig = { if (!('_version' in state)) { state._version = 1; } + if (state._version === 1) { + state._version = 2; + if (isPlainObject(state.customHotkeys)) { + for (const [id, hotkeys] of Object.entries(state.customHotkeys)) { + if (Array.isArray(hotkeys)) { + state.customHotkeys[id] = hotkeys + .filter((hotkey): hotkey is string => typeof hotkey === 'string') + .map((hotkey) => canonicalizeHotkeyString(hotkey)); + } + } + } + } return zHotkeysState.parse(state); }, }, diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts b/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts index f6bbb21dab9..172c0a8a7fa 100644 --- a/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts +++ b/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const zHotkeysState = z.object({ - _version: z.literal(1), + _version: z.literal(2), customHotkeys: z.record(z.string(), z.array(z.string())), }); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts index b641c885d65..ca7ae7cecaf 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts @@ -419,16 +419,26 @@ describe('AppNavigationApi', () => { }); it('should handle custom timeout', async () => { - const start = Date.now(); - const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 200); + vi.useFakeTimers(); + + try { + const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 200); + let isSettled = false; + void waitPromise.catch(() => { + isSettled = true; + }); + + const assertion = expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 200ms/); - await expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 200ms/); + await vi.advanceTimersByTimeAsync(199); + expect(isSettled).toBe(false); - const elapsed = Date.now() - start; - // TODO(psyche): Use vitest's fake timeres - // Allow some margin for timer resolution - expect(elapsed).toBeGreaterThanOrEqual(190); - expect(elapsed).toBeLessThan(210); + await vi.advanceTimersByTimeAsync(1); + await assertion; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } }); });