Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, string> = 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)
Expand All @@ -51,7 +35,7 @@ const isModifierKey = (key: string): boolean => {

// Build hotkey string from pressed keys
const buildHotkeyString = (keys: Set<string>): 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));

Expand All @@ -67,14 +51,6 @@ const buildHotkeyString = (keys: Set<string>): 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;
Expand Down Expand Up @@ -129,25 +105,30 @@ const HotkeyItem = memo(
}: HotkeyItemProps) => {
const [recordedKey, setRecordedKey] = useState<string | null>(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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<ReadonlyMap<string, string>, '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<string, string>;

const HOTKEY_KEY_CODES_BY_ALIAS = Object.fromEntries(
HOTKEY_PHYSICAL_KEY_ALIASES.map(({ code, token }) => [token, code])
) as Record<string, string>;

const HOTKEY_KEY_DISPLAY_ALIASES = Object.fromEntries(
HOTKEY_PHYSICAL_KEY_ALIASES.map(({ token, display }) => [token, display])
) as Record<string, string>;

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<string, (typeof HOTKEY_PHYSICAL_KEY_ALIASES)[number]>;

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<string, string> = 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<string>([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));
};
Loading
Loading