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..6c26ecc73ed 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,12 @@ 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 { + formatHotkeyKeyForDisplay, + getHotkeyKeyFromEvent, + 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 { hotkeyChanged, hotkeyReset } from 'features/system/store/hotkeysSlice'; import type { TFunction } from 'i18next'; import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'; @@ -15,30 +19,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 +31,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 +47,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; @@ -132,7 +104,7 @@ const HotkeyItem = memo( // Memoize key parts to avoid repeated split calls const keyParts = useMemo(() => keyString.split('+'), [keyString]); - const displayKeyParts = useMemo(() => keyParts.map(formatKeyForDisplay), [keyParts]); + const displayKeyParts = useMemo(() => keyParts.map((key) => formatHotkeyKeyForDisplay(key)), [keyParts]); // Check if the recorded key conflicts with another hotkey const conflict = useMemo(() => { @@ -196,7 +168,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 +231,7 @@ const HotkeyItem = memo( const renderHotkeyKeys = () => { if (isEditing) { const displayKey = recordedKey ?? keyString; - const parts = displayKey.split('+').map(formatKeyForDisplay); + const parts = displayKey.split('+').map((key) => formatHotkeyKeyForDisplay(key)); const hasConflict = conflict !== 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..5809a9fb6d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalizeHotkeyString, + formatHotkeyKeyForDisplay, + formatHotkeyStringForPlatform, + getHotkeyKeyFromEvent, +} 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('canonicalizes legacy literal bracket hotkeys', () => { + expect(canonicalizeHotkeyString('[')).toBe('bracketleft'); + expect(canonicalizeHotkeyString(']')).toBe('bracketright'); + expect(canonicalizeHotkeyString('.')).toBe('period'); + expect(canonicalizeHotkeyString('mod+[')).toBe('mod+bracketleft'); + expect(canonicalizeHotkeyString('mod+]')).toBe('mod+bracketright'); + }); + + 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(['.']); + }); +}); 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..ee287470093 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/hotkeyStrings.ts @@ -0,0 +1,111 @@ +export const IS_MAC_OS = + typeof navigator !== 'undefined' && + ( + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + '' + ) + .toLowerCase() + .includes('mac'); + +const HOTKEY_KEY_ALIASES_BY_CODE = { + Backquote: 'backquote', + Minus: 'minus', + Equal: 'equal', + BracketLeft: 'bracketleft', + BracketRight: 'bracketright', + Backslash: 'backslash', + Semicolon: 'semicolon', + Quote: 'quote', + Comma: 'comma', + Period: 'period', + Slash: 'slash', +} as const; + +const HOTKEY_KEY_DISPLAY_ALIASES = { + backquote: '`', + minus: '-', + equal: '=', + bracketleft: '[', + bracketright: ']', + backslash: '\\', + semicolon: ';', + quote: "'", + comma: ',', + period: '.', + slash: '/', +} as const; + +export const getHotkeyKeyFromEvent = (key: string, code?: string): string => { + if (code && code in HOTKEY_KEY_ALIASES_BY_CODE) { + return HOTKEY_KEY_ALIASES_BY_CODE[code as keyof typeof HOTKEY_KEY_ALIASES_BY_CODE]; + } + + return key; +}; + +export const normalizeHotkeyKey = (key: string, isMac: boolean = IS_MAC_OS): string => { + const keyMap: Record = isMac + ? { + Meta: 'mod', + Command: 'mod', + Control: 'ctrl', + Alt: 'alt', + Shift: 'shift', + ' ': 'space', + '`': 'backquote', + '-': 'minus', + '=': 'equal', + '[': 'bracketleft', + ']': 'bracketright', + '\\': 'backslash', + ';': 'semicolon', + "'": 'quote', + ',': 'comma', + '.': 'period', + '/': 'slash', + } + : { + Control: 'mod', + Meta: 'meta', + Alt: 'alt', + Shift: 'shift', + ' ': 'space', + '`': 'backquote', + '-': 'minus', + '=': 'equal', + '[': 'bracketleft', + ']': 'bracketright', + '\\': 'backslash', + ';': 'semicolon', + "'": 'quote', + ',': 'comma', + '.': 'period', + '/': 'slash', + }; + + return keyMap[key] || key.toLowerCase(); +}; + +export const canonicalizeHotkeyString = (hotkey: string, isMac: boolean = IS_MAC_OS): string => { + return hotkey + .split('+') + .map((key) => normalizeHotkeyKey(key, isMac)) + .join('+'); +}; + +export const formatHotkeyKeyForDisplay = (key: string, isMac: boolean = IS_MAC_OS): string => { + const normalizedKey = key.toLowerCase(); + const displayKey = + HOTKEY_KEY_DISPLAY_ALIASES[normalizedKey as keyof typeof HOTKEY_KEY_DISPLAY_ALIASES] ?? 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): string[] => { + return hotkey.split('+').map((key) => formatHotkeyKeyForDisplay(key, isMac)); +}; 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..f952cdb46bc 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,35 @@ 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('canonicalizes legacy bracket overrides from persisted custom hotkeys', () => { + const hotkeysData = buildHotkeysData(t, { + 'canvas.decrementToolWidth': ['['], + 'canvas.incrementToolWidth': [']'], + }); + + expect(hotkeysData.canvas.hotkeys.decrementToolWidth?.hotkeys).toEqual(['bracketleft']); + expect(hotkeysData.canvas.hotkeys.incrementToolWidth?.hotkeys).toEqual(['bracketright']); + }); }); 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..193f70f90c4 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,10 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useIsUncommittedCanvasTextSessionActive } from 'features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive'; +import { + canonicalizeHotkeyString, + formatHotkeyStringForPlatform, + IS_MAC_OS, +} from 'features/system/components/HotkeysModal/hotkeyStrings'; import { selectCustomHotkeys } from 'features/system/store/hotkeysSlice'; import { useMemo } from 'react'; import { type HotkeyCallback, type Options, useHotkeys } from 'react-hotkeys-hook'; @@ -8,16 +13,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; @@ -37,13 +33,7 @@ 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')); - } - }); + return keys.map((key) => formatHotkeyStringForPlatform(key, IS_MAC_OS)); }; export const buildHotkeysData = (t: HotkeyTranslator, customHotkeys: CustomHotkeys): HotkeysData => { @@ -72,14 +62,15 @@ 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] ?? keys).map((key) => canonicalizeHotkeyString(key, IS_MAC_OS)); data[category].hotkeys[id] = { id, category, title: t(`hotkeys.${category}.${id}.title`), desc: t(`hotkeys.${category}.${id}.desc`), hotkeys: effectiveKeys, - defaultHotkeys: keys, + defaultHotkeys, platformKeys: formatKeysForPlatform(effectiveKeys), isEnabled, }; @@ -111,8 +102,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 +129,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 +175,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; }; 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 0eff0080bad..a92987f675d 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 @@ -417,16 +417,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(); + } }); });