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 */} + + + + + + ); + } + + 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;