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,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';
Expand All @@ -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<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 +31,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 +47,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 @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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(['.']);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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));
};
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -37,13 +33,7 @@ type HotkeyTranslator = (key: string) => string;
type CustomHotkeys = Record<string, string[]>;

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 => {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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']);
Expand All @@ -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']);
Expand Down Expand Up @@ -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;
};
Expand Down
Loading
Loading