diff --git a/docs/contributing/HOTKEYS.md b/docs/contributing/HOTKEYS.md
new file mode 100644
index 00000000000..fac13976b12
--- /dev/null
+++ b/docs/contributing/HOTKEYS.md
@@ -0,0 +1,295 @@
+# Hotkeys System
+
+This document describes the technical implementation of the customizable hotkeys system in InvokeAI.
+
+> **Note:** For user-facing documentation on how to use customizable hotkeys, see [Hotkeys Feature Documentation](../features/hotkeys.md).
+
+## Overview
+
+The hotkeys system allows users to customize keyboard shortcuts throughout the application. All hotkeys are:
+- Centrally defined and managed
+- Customizable by users
+- Persisted across sessions
+- Type-safe and validated
+
+## Architecture
+
+The customizable hotkeys feature is built on top of the existing hotkey system with the following components:
+
+### 1. Hotkeys State Slice (`hotkeysSlice.ts`)
+
+Location: `invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts`
+
+**Responsibilities:**
+- Stores custom hotkey mappings in Redux state
+- Persisted to IndexedDB using `redux-remember`
+- Provides actions to change, reset individual, or reset all hotkeys
+
+**State Shape:**
+```typescript
+{
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['mod+enter'],
+ 'canvas.undo': ['mod+z'],
+ // ...
+ }
+}
+```
+
+**Actions:**
+- `hotkeyChanged(id, hotkeys)` - Update a single hotkey
+- `hotkeyReset(id)` - Reset a single hotkey to default
+- `allHotkeysReset()` - Reset all hotkeys to defaults
+
+### 2. useHotkeyData Hook (`useHotkeyData.ts`)
+
+Location: `invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts`
+
+**Responsibilities:**
+- Defines all default hotkeys
+- Merges default hotkeys with custom hotkeys from the store
+- Returns the effective hotkeys that should be used throughout the app
+- Provides platform-specific key translations (Ctrl/Cmd, Alt/Option)
+
+**Key Functions:**
+- `useHotkeyData()` - Returns all hotkeys organized by category
+- `useRegisteredHotkeys()` - Hook to register a hotkey in a component
+
+### 3. HotkeyEditor Component (`HotkeyEditor.tsx`)
+
+Location: `invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx`
+
+**Features:**
+- Inline editor with input field
+- Modifier buttons (Mod, Ctrl, Shift, Alt) for quick insertion
+- Live preview of hotkey combinations
+- Validation with visual feedback
+- Help tooltip with syntax examples
+- Save/cancel/reset buttons
+
+**Smart Features:**
+- Automatic `+` insertion between modifiers
+- Cursor position preservation
+- Validation prevents invalid combinations (e.g., modifier-only keys)
+
+### 4. HotkeysModal Component (`HotkeysModal.tsx`)
+
+Location: `invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx`
+
+**Features:**
+- View Mode / Edit Mode toggle
+- Search functionality
+- Category-based organization
+- Shows HotkeyEditor components when in edit mode
+- "Reset All to Default" button in edit mode
+
+## Data Flow
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 1. User opens Hotkeys Modal │
+│ 2. User clicks "Edit Mode" button │
+│ 3. User clicks edit icon next to a hotkey │
+│ 4. User enters new hotkey(s) using editor │
+│ 5. User clicks save or presses Enter │
+│ 6. Custom hotkey stored via hotkeyChanged() action │
+│ 7. Redux state persisted to IndexedDB (redux-remember) │
+│ 8. useHotkeyData() hook picks up the change │
+│ 9. All components using useRegisteredHotkeys() get update │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Hotkey Format
+
+Hotkeys use the format from `react-hotkeys-hook` library:
+
+- **Modifiers:** `mod`, `ctrl`, `shift`, `alt`, `meta`
+- **Keys:** Letters, numbers, function keys, special keys
+- **Separator:** `+` between keys in a combination
+- **Multiple hotkeys:** Comma-separated (e.g., `mod+a, ctrl+b`)
+
+**Examples:**
+- `mod+enter` - Mod key + Enter
+- `shift+x` - Shift + X
+- `ctrl+shift+a` - Control + Shift + A
+- `f1, f2` - F1 or F2 (alternatives)
+
+## Developer Guide
+
+### Using Hotkeys in Components
+
+To use a hotkey in a component:
+
+```tsx
+import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+
+const MyComponent = () => {
+ const handleAction = useCallback(() => {
+ // Your action here
+ }, []);
+
+ // This automatically uses custom hotkeys if configured
+ useRegisteredHotkeys({
+ id: 'myAction',
+ category: 'app', // or 'canvas', 'viewer', 'gallery', 'workflows'
+ callback: handleAction,
+ options: { enabled: true, preventDefault: true },
+ dependencies: [handleAction]
+ });
+
+ // ...
+};
+```
+
+**Options:**
+- `enabled` - Whether the hotkey is active
+- `preventDefault` - Prevent default browser behavior
+- `enableOnFormTags` - Allow hotkey in form elements (default: false)
+
+### Adding New Hotkeys
+
+To add a new hotkey to the system:
+
+#### 1. Add Translation Strings
+
+In `invokeai/frontend/web/public/locales/en.json`:
+
+```json
+{
+ "hotkeys": {
+ "app": {
+ "myAction": {
+ "title": "My Action",
+ "desc": "Description of what this hotkey does"
+ }
+ }
+ }
+}
+```
+
+#### 2. Register the Hotkey
+
+In `invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts`:
+
+```typescript
+// Inside the appropriate category builder function
+addHotkey('app', 'myAction', ['mod+k']); // Default binding
+```
+
+#### 3. Use the Hotkey
+
+In your component:
+
+```typescript
+useRegisteredHotkeys({
+ id: 'myAction',
+ category: 'app',
+ callback: handleMyAction,
+ options: { enabled: true },
+ dependencies: [handleMyAction]
+});
+```
+
+### Hotkey Categories
+
+Current categories:
+- **app** - Global application hotkeys
+- **canvas** - Canvas/drawing operations
+- **viewer** - Image viewer operations
+- **gallery** - Gallery/image grid operations
+- **workflows** - Node workflow editor
+
+To add a new category, update `useHotkeyData.ts` and add translations.
+
+## Testing
+
+Tests are located in `invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts`.
+
+**Test Coverage:**
+- Adding custom hotkeys
+- Updating existing custom hotkeys
+- Resetting individual hotkeys
+- Resetting all hotkeys
+- State persistence and migration
+
+Run tests with:
+
+```bash
+cd invokeai/frontend/web
+pnpm test:no-watch
+```
+
+## Persistence
+
+Custom hotkeys are persisted using the same mechanism as other app settings:
+
+- Stored in Redux state under the `hotkeys` slice
+- Persisted to IndexedDB via `redux-remember`
+- Automatically loaded when the app starts
+- Survives page refreshes and browser restarts
+- Includes migration support for state schema changes
+
+**State Location:**
+- IndexedDB database: `invoke`
+- Store key: `hotkeys`
+
+## Dependencies
+
+- **react-hotkeys-hook** (v4.5.0) - Core hotkey handling
+- **@reduxjs/toolkit** - State management
+- **redux-remember** - Persistence
+- **zod** - State validation
+
+## Best Practices
+
+1. **Use `mod` instead of `ctrl`** - Automatically maps to Cmd on Mac, Ctrl elsewhere
+2. **Provide descriptive translations** - Help users understand what each hotkey does
+3. **Avoid conflicts** - Check existing hotkeys before adding new ones
+4. **Use preventDefault** - Prevent browser default behavior when appropriate
+5. **Check enabled state** - Only activate hotkeys when the action is available
+6. **Use dependencies correctly** - Ensure callbacks are stable with useCallback
+
+## Common Patterns
+
+### Conditional Hotkeys
+
+```typescript
+useRegisteredHotkeys({
+ id: 'save',
+ category: 'app',
+ callback: handleSave,
+ options: {
+ enabled: hasUnsavedChanges && !isLoading, // Only when valid
+ preventDefault: true
+ },
+ dependencies: [hasUnsavedChanges, isLoading, handleSave]
+});
+```
+
+### Multiple Hotkeys for Same Action
+
+```typescript
+// In useHotkeyData.ts
+addHotkey('canvas', 'redo', ['mod+shift+z', 'mod+y']); // Two alternatives
+```
+
+### Focus-Scoped Hotkeys
+
+```typescript
+import { useFocusRegion } from 'common/hooks/focus';
+
+const MyComponent = () => {
+ const focusRegionRef = useFocusRegion('myRegion');
+
+ // Hotkey only works when this region has focus
+ useRegisteredHotkeys({
+ id: 'myAction',
+ category: 'app',
+ callback: handleAction,
+ options: { enabled: true }
+ });
+
+ return
...
;
+};
+```
diff --git a/docs/features/hotkeys.md b/docs/features/hotkeys.md
new file mode 100644
index 00000000000..4998eb95caf
--- /dev/null
+++ b/docs/features/hotkeys.md
@@ -0,0 +1,80 @@
+# Customizable Hotkeys
+
+InvokeAI allows you to customize all keyboard shortcuts (hotkeys) to match your workflow preferences.
+
+## Features
+
+- **View All Hotkeys**: See all available keyboard shortcuts in one place
+- **Customize Any Hotkey**: Change any shortcut to your preference
+- **Multiple Bindings**: Assign multiple key combinations to the same action
+- **Smart Validation**: Built-in validation prevents invalid combinations
+- **Persistent Settings**: Your custom hotkeys are saved and restored across sessions
+- **Easy Reset**: Reset individual hotkeys or all hotkeys back to defaults
+
+## How to Use
+
+### Opening the Hotkeys Modal
+
+Press `Shift+?` or click the keyboard icon in the application to open the Hotkeys Modal.
+
+### Viewing Hotkeys
+
+In **View Mode** (default), you can:
+- Browse all available hotkeys organized by category (App, Canvas, Gallery, Workflows, etc.)
+- Search for specific hotkeys using the search bar
+- See the current key combination for each action
+
+### Customizing Hotkeys
+
+1. Click the **Edit Mode** button at the bottom of the Hotkeys Modal
+2. Find the hotkey you want to change
+3. Click the **pencil icon** next to it
+4. The editor will appear with:
+ - **Input field**: Enter your new hotkey combination
+ - **Modifier buttons**: Quick-insert Mod, Ctrl, Shift, Alt keys
+ - **Help icon** (?): Shows syntax examples and valid keys
+ - **Live preview**: See how your hotkey will look
+
+5. Enter your new hotkey using the format:
+ - `mod+a` - Mod key + A (Mod = Ctrl on Windows/Linux, Cmd on Mac)
+ - `ctrl+shift+k` - Multiple modifiers
+ - `f1` - Function keys
+ - `mod+enter, ctrl+enter` - Multiple alternatives (separated by comma)
+
+6. Click the **checkmark** or press Enter to save
+7. Click the **X** or press Escape to cancel
+
+### Resetting Hotkeys
+
+**Reset a single hotkey:**
+- Click the counter-clockwise arrow icon that appears next to customized hotkeys
+
+**Reset all hotkeys:**
+- In Edit Mode, click the **Reset All to Default** button at the bottom
+
+### Hotkey Format Reference
+
+**Valid Modifiers:**
+- `mod` - Context-aware: Ctrl (Windows/Linux) or Cmd (Mac)
+- `ctrl` - Control key
+- `shift` - Shift key
+- `alt` - Alt key (Option on Mac)
+
+**Valid Keys:**
+- Letters: `a-z`
+- Numbers: `0-9`
+- Function keys: `f1-f12`
+- Special keys: `enter`, `space`, `tab`, `backspace`, `delete`, `escape`
+- Arrow keys: `up`, `down`, `left`, `right`
+- And more...
+
+**Examples:**
+- ✅ `mod+s` - Save action
+- ✅ `ctrl+shift+p` - Command palette
+- ✅ `f5, mod+r` - Two alternatives for refresh
+- ❌ `mod+` - Invalid (no key after modifier)
+- ❌ `shift+ctrl+` - Invalid (ends with modifier)
+
+## For Developers
+
+For technical implementation details, architecture, and how to add new hotkeys to the system, see the [Hotkeys Developer Documentation](../contributing/HOTKEYS.md).
diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json
index 9108ebc42c3..e28c79be595 100644
--- a/invokeai/frontend/web/public/locales/de.json
+++ b/invokeai/frontend/web/public/locales/de.json
@@ -185,9 +185,33 @@
"imagesSettings": "Galeriebildereinstellungen"
},
"hotkeys": {
+ "hotkeys": "Tastenkombinationen",
"noHotkeysFound": "Kein Hotkey gefunden",
"searchHotkeys": "Hotkeys durchsuchen",
"clearSearch": "Suche leeren",
+ "editMode": "Bearbeitungsmodus",
+ "viewMode": "Ansichtsmodus",
+ "editHotkey": "Hotkey bearbeiten",
+ "resetToDefault": "Auf Standard zurücksetzen",
+ "resetAll": "Alle auf Standard zurücksetzen",
+ "enterHotkeys": "Tastenkombination(en) eingeben, mit Komma getrennt",
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "modifiers": "Modifikatoren",
+ "syntaxHelp": "Syntax-Hilfe",
+ "combineWith": "Kombinieren mit +",
+ "multipleHotkeys": "Mehrere Hotkeys mit Komma",
+ "validKeys": "Gültige Tasten",
+ "help": "Hilfe",
+ "noHotkeysRecorded": "Noch keine Hotkeys aufgenommen",
+ "pressKeys": "Tasten drücken...",
+ "setHotkey": "SETZEN",
+ "setAnother": "WEITEREN SETZEN",
+ "removeLastHotkey": "Letzten Hotkey entfernen",
+ "clearAll": "Alle löschen",
+ "duplicateWarning": "Dieser Hotkey wurde bereits aufgenommen",
+ "conflictWarning": "wird bereits von \"{{hotkeyTitle}}\" verwendet",
+ "thisHotkey": "diesem Hotkey",
"canvas": {
"fitBboxToCanvas": {
"desc": "Skalierung und Positionierung der Ansicht auf Bbox-Größe.",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 8a6bd7b337e..95ca671bc4d 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -428,6 +428,29 @@
"searchHotkeys": "Search Hotkeys",
"clearSearch": "Clear Search",
"noHotkeysFound": "No Hotkeys Found",
+ "editMode": "Edit Mode",
+ "viewMode": "View Mode",
+ "editHotkey": "Edit Hotkey",
+ "resetToDefault": "Reset to Default",
+ "resetAll": "Reset All to Default",
+ "enterHotkeys": "Enter hotkey(s), separated by commas",
+ "save": "Save",
+ "cancel": "Cancel",
+ "modifiers": "Modifiers",
+ "syntaxHelp": "Syntax Help",
+ "combineWith": "Combine with +",
+ "multipleHotkeys": "Multiple hotkeys with comma",
+ "validKeys": "Valid keys",
+ "help": "Help",
+ "noHotkeysRecorded": "No hotkeys recorded yet",
+ "pressKeys": "Press keys...",
+ "setHotkey": "SET",
+ "setAnother": "SET ANOTHER",
+ "removeLastHotkey": "Remove last hotkey",
+ "clearAll": "Clear All",
+ "duplicateWarning": "This hotkey is already recorded",
+ "conflictWarning": "is already used by \"{{hotkeyTitle}}\"",
+ "thisHotkey": "this hotkey",
"app": {
"title": "App",
"invoke": {
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index cad6f489df7..3fc6b893a29 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -35,6 +35,7 @@ import { workflowSettingsSliceConfig } from 'features/nodes/store/workflowSettin
import { upscaleSliceConfig } from 'features/parameters/store/upscaleSlice';
import { queueSliceConfig } from 'features/queue/store/queueSlice';
import { stylePresetSliceConfig } from 'features/stylePresets/store/stylePresetSlice';
+import { hotkeysSliceConfig } from 'features/system/store/hotkeysSlice';
import { systemSliceConfig } from 'features/system/store/systemSlice';
import { uiSliceConfig } from 'features/ui/store/uiSlice';
import { diff } from 'jsondiffpatch';
@@ -64,6 +65,7 @@ const SLICE_CONFIGS = {
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
+ [hotkeysSliceConfig.slice.reducerPath]: hotkeysSliceConfig,
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig,
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig,
[nodesSliceConfig.slice.reducerPath]: nodesSliceConfig,
@@ -92,6 +94,7 @@ const ALL_REDUCERS = {
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer,
+ [hotkeysSliceConfig.slice.reducerPath]: hotkeysSliceConfig.slice.reducer,
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig.slice.reducer,
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig.slice.reducer,
// Undoable!
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx
new file mode 100644
index 00000000000..5873a299ae6
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyEditor.tsx
@@ -0,0 +1,376 @@
+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 */}
+
+ }
+ isDisabled={recordedHotkeys.length === 0 || isRecording}
+ flex={1}
+ >
+ {t('hotkeys.save')}
+
+ }>
+ {t('hotkeys.cancel')}
+
+
+
+ );
+ }
+
+ 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 39267ddc9d9..48b6f90ceec 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx
@@ -1,13 +1,15 @@
import { Flex, Kbd, Spacer, Text } from '@invoke-ai/ui-library';
+import { HotkeyEditor } from 'features/system/components/HotkeysModal/HotkeyEditor';
import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData';
import { Fragment, memo } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
hotkey: Hotkey;
+ showEditor?: boolean;
}
-const HotkeyListItem = ({ hotkey }: Props) => {
+const HotkeyListItem = ({ hotkey, showEditor = false }: Props) => {
const { t } = useTranslation();
const { id, platformKeys, title, desc } = hotkey;
return (
@@ -15,27 +17,33 @@ const HotkeyListItem = ({ hotkey }: Props) => {
{title}
- {platformKeys.map((hotkey, i1) => {
- return (
-
- {hotkey.map((key, i2) => (
-
- {key}
- {i2 !== hotkey.length - 1 && (
-
- +
+ {showEditor ? (
+
+ ) : (
+ <>
+ {platformKeys.map((hotkey, i1) => {
+ return (
+
+ {hotkey.map((key, i2) => (
+
+ {key}
+ {i2 !== hotkey.length - 1 && (
+
+ +
+
+ )}
+
+ ))}
+ {i1 !== platformKeys.length - 1 && (
+
+ {t('common.or')}
)}
- ))}
- {i1 !== platformKeys.length - 1 && (
-
- {t('common.or')}
-
- )}
-
- );
- })}
+ );
+ })}
+ >
+ )}
{desc}
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 d8ace821bf5..65498465485 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx
@@ -1,4 +1,5 @@
import {
+ Button,
Divider,
Flex,
IconButton,
@@ -14,11 +15,13 @@ import {
ModalOverlay,
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 { StickyScrollable } from 'features/system/components/StickyScrollable';
+import { allHotkeysReset } from 'features/system/store/hotkeysSlice';
import type { ChangeEventHandler, ReactElement } from 'react';
import { cloneElement, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -39,10 +42,16 @@ type TransformedHotkeysCategoryData = {
const HotkeysModal = ({ children }: HotkeysModalProps) => {
const { isOpen, onOpen, onClose } = 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]);
const hotkeysData = useHotkeyData();
const filteredHotkeys = useMemo(() => {
const trimmedHotkeyFilter = hotkeyFilter.trim().toLowerCase();
@@ -110,7 +119,7 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => {
{category.hotkeys.map((hotkey, i) => (
-
+
{i < category.hotkeys.length - 1 && }
))}
@@ -120,7 +129,18 @@ const HotkeysModal = ({ children }: HotkeysModalProps) => {
-
+
+
+
+ {isEditMode && (
+
+ )}
+
+
>
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 981c4d0f511..cdd4fa099e2 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -1,3 +1,5 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectCustomHotkeys } from 'features/system/store/hotkeysSlice';
import { useMemo } from 'react';
import { type HotkeyCallback, type Options, useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
@@ -11,6 +13,7 @@ export type Hotkey = {
title: string;
desc: string;
hotkeys: string[];
+ defaultHotkeys: string[];
platformKeys: string[][];
isEnabled: boolean;
};
@@ -31,6 +34,7 @@ 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');
}, []);
@@ -60,13 +64,16 @@ export const useHotkeyData = (): HotkeysData => {
};
const addHotkey = (category: HotkeyCategory, id: string, keys: string[], isEnabled: boolean = true) => {
+ const hotkeyId = `${category}.${id}`;
+ const effectiveKeys = customHotkeys[hotkeyId] ?? keys;
data[category].hotkeys[id] = {
id,
category,
title: t(`hotkeys.${category}.${id}.title`),
desc: t(`hotkeys.${category}.${id}.desc`),
- hotkeys: keys,
- platformKeys: formatKeysForPlatform(keys, isMacOS),
+ hotkeys: effectiveKeys,
+ defaultHotkeys: keys,
+ platformKeys: formatKeysForPlatform(effectiveKeys, isMacOS),
isEnabled,
};
};
@@ -168,7 +175,7 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('gallery', 'starImage', ['.']);
return data;
- }, [isMacOS, t]);
+ }, [customHotkeys, isMacOS, t]);
return hotkeysData;
};
diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts
new file mode 100644
index 00000000000..e0347ff4a18
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.test.ts
@@ -0,0 +1,79 @@
+import { allHotkeysReset, hotkeyChanged, hotkeyReset, hotkeysSliceConfig } from 'features/system/store/hotkeysSlice';
+import { describe, expect, it } from 'vitest';
+
+import type { HotkeysState } from './hotkeysTypes';
+
+describe('Hotkeys Slice', () => {
+ const getInitialState = (): HotkeysState => ({
+ _version: 1,
+ customHotkeys: {},
+ });
+
+ const { reducer } = hotkeysSliceConfig.slice;
+
+ describe('hotkeyChanged', () => {
+ it('should add a custom hotkey', () => {
+ const state = getInitialState();
+ const action = hotkeyChanged({ id: 'app.invoke', hotkeys: ['ctrl+shift+enter'] });
+ const result = reducer(state, action);
+ expect(result).toEqual({
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['ctrl+shift+enter'],
+ },
+ });
+ });
+
+ it('should update an existing custom hotkey', () => {
+ const state: HotkeysState = {
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['ctrl+enter'],
+ },
+ };
+ const action = hotkeyChanged({ id: 'app.invoke', hotkeys: ['ctrl+shift+enter'] });
+ const result = reducer(state, action);
+ expect(result).toEqual({
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['ctrl+shift+enter'],
+ },
+ });
+ });
+ });
+
+ describe('hotkeyReset', () => {
+ it('should remove a custom hotkey', () => {
+ const state: HotkeysState = {
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['ctrl+shift+enter'],
+ 'app.cancelQueueItem': ['shift+x'],
+ },
+ };
+ const action = hotkeyReset('app.invoke');
+ const result = reducer(state, action);
+ expect(result.customHotkeys).toEqual({
+ 'app.cancelQueueItem': ['shift+x'],
+ });
+ });
+ });
+
+ describe('allHotkeysReset', () => {
+ it('should clear all custom hotkeys', () => {
+ const state: HotkeysState = {
+ _version: 1,
+ customHotkeys: {
+ 'app.invoke': ['ctrl+shift+enter'],
+ 'app.cancelQueueItem': ['shift+x'],
+ },
+ };
+ const action = allHotkeysReset();
+ const result = reducer(state, action);
+ expect(result).toEqual({
+ _version: 1,
+ customHotkeys: {},
+ });
+ });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts
new file mode 100644
index 00000000000..7ef7c2c4a9a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/store/hotkeysSlice.ts
@@ -0,0 +1,50 @@
+import type { PayloadAction } from '@reduxjs/toolkit';
+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 { assert } from 'tsafe';
+
+import { type HotkeysState, zHotkeysState } from './hotkeysTypes';
+
+const getInitialState = (): HotkeysState => ({
+ _version: 1,
+ customHotkeys: {},
+});
+
+const slice = createSlice({
+ name: 'hotkeys',
+ initialState: getInitialState(),
+ reducers: {
+ hotkeyChanged: (state, action: PayloadAction<{ id: string; hotkeys: string[] }>) => {
+ const { id, hotkeys } = action.payload;
+ state.customHotkeys[id] = hotkeys;
+ },
+ hotkeyReset: (state, action: PayloadAction) => {
+ delete state.customHotkeys[action.payload];
+ },
+ allHotkeysReset: (state) => {
+ state.customHotkeys = {};
+ },
+ },
+});
+
+export const { hotkeyChanged, hotkeyReset, allHotkeysReset } = slice.actions;
+
+export const hotkeysSliceConfig: SliceConfig = {
+ slice,
+ schema: zHotkeysState,
+ getInitialState,
+ persistConfig: {
+ migrate: (state) => {
+ assert(isPlainObject(state));
+ if (!('_version' in state)) {
+ state._version = 1;
+ }
+ return zHotkeysState.parse(state);
+ },
+ },
+};
+
+const selectHotkeysSlice = (state: RootState) => state.hotkeys;
+export const selectCustomHotkeys = createSelector(selectHotkeysSlice, (hotkeys) => hotkeys.customHotkeys);
diff --git a/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts b/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts
new file mode 100644
index 00000000000..f6bbb21dab9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/store/hotkeysTypes.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+export const zHotkeysState = z.object({
+ _version: z.literal(1),
+ customHotkeys: z.record(z.string(), z.array(z.string())),
+});
+
+export type HotkeysState = z.infer;