Skip to content
Draft
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
132 changes: 132 additions & 0 deletions invokeai/frontend/web/CUSTOMIZABLE_HOTKEYS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Customizable Hotkeys

This feature allows users to customize keyboard shortcuts (hotkeys) in the InvokeAI frontend application.

## Overview

Users can now:
- View all available hotkeys in the application
- Edit individual hotkeys to their preference
- Reset individual hotkeys to their defaults
- Reset all hotkeys to defaults
- Have their custom hotkeys persist across sessions

## Implementation

### Architecture

The customizable hotkeys feature is built on top of the existing hotkey system with the following components:

1. **Hotkeys State Slice** (`hotkeysSlice.ts`)
- Stores custom hotkey mappings in Redux state
- Persisted to IndexedDB using `redux-remember`
- Provides actions to change, reset individual, or reset all hotkeys

2. **useHotkeyData Hook** (`useHotkeyData.ts`)
- Updated to merge default hotkeys with custom hotkeys from the store
- Returns the effective hotkeys that should be used throughout the app

3. **HotkeyEditor Component** (`HotkeyEditor.tsx`)
- Provides UI for editing individual hotkeys
- Shows inline editor with save/cancel buttons
- Displays reset button for customized hotkeys

4. **HotkeysModal Updates** (`HotkeysModal.tsx`)
- Added "Edit Mode" / "View Mode" toggle
- Shows HotkeyEditor components when in edit mode
- Provides "Reset All to Default" button in edit mode

### Data Flow

1. User opens Hotkeys Modal (Shift+?)
2. User clicks "Edit Mode" button
3. User clicks the edit icon next to any hotkey
4. User enters new hotkey(s) (comma-separated for multiple)
5. User clicks save or presses Enter
6. Custom hotkey is stored in Redux state via `hotkeyChanged` action
7. Redux state is persisted to IndexedDB via `redux-remember`
8. `useHotkeyData` hook picks up the change and returns updated hotkeys
9. All components using `useRegisteredHotkeys` automatically use the new hotkey

### Hotkey Format

- Hotkeys use the format from `react-hotkeys-hook`
- Multiple hotkeys for the same action are separated by commas
- Modifiers: `mod` (ctrl on Windows/Linux, cmd on Mac), `shift`, `alt`
- Examples: `mod+enter`, `shift+x`, `ctrl+shift+a`

## Usage

### For Users

1. Press `Shift+?` to open the Hotkeys Modal
2. Click "Edit Mode" button at the bottom
3. Click the pencil icon next to the hotkey you want to change
4. Enter the new hotkey(s) (e.g., `ctrl+k` or `ctrl+k, cmd+k` for multiple)
5. Press Enter or click the checkmark to save
6. To reset a single hotkey, click the counter-clockwise arrow icon
7. To reset all hotkeys, click "Reset All to Default" button

### For Developers

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 },
});

// ...
};
```

To add a new hotkey to the system:

1. Add translation strings in `public/locales/en.json`:
```json
"hotkeys": {
"app": {
"myAction": {
"title": "My Action",
"desc": "Description of what this hotkey does"
}
}
}
```

2. Register the hotkey in `useHotkeyData.ts`:
```typescript
addHotkey('app', 'myAction', ['mod+k']);
```

## Testing

Tests are located in `hotkeysSlice.test.ts` and cover:
- Adding custom hotkeys
- Updating existing custom hotkeys
- Resetting individual hotkeys
- Resetting all hotkeys

Run tests with:
```bash
pnpm run test
```

## 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
8 changes: 8 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@
"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",
"app": {
"title": "App",
"invoke": {
Expand Down
3 changes: 3 additions & 0 deletions invokeai/frontend/web/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Flex, IconButton, Input, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData';
import { hotkeyChanged, hotkeyReset } from 'features/system/store/hotkeysSlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiCheckBold, PiPencilBold, PiXBold } from 'react-icons/pi';

type HotkeyEditorProps = {
hotkey: Hotkey;
};

const formatHotkeyForDisplay = (keys: string[]): string => {
return keys.join(', ');
};

const parseHotkeyInput = (input: string): string[] => {
return input
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0);
};

export const HotkeyEditor = memo(({ hotkey }: HotkeyEditorProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);

const isCustomized = hotkey.hotkeys.join(',') !== hotkey.defaultHotkeys.join(',');

const handleEdit = useCallback(() => {
setEditValue(formatHotkeyForDisplay(hotkey.hotkeys));
setIsEditing(true);
}, [hotkey.hotkeys]);

const handleCancel = useCallback(() => {
setIsEditing(false);
setEditValue('');
}, []);

const handleSave = useCallback(() => {
const newKeys = parseHotkeyInput(editValue);
if (newKeys.length > 0) {
const hotkeyId = `${hotkey.category}.${hotkey.id}`;
dispatch(hotkeyChanged({ id: hotkeyId, hotkeys: newKeys }));
}
setIsEditing(false);
setEditValue('');
}, [dispatch, editValue, hotkey.category, hotkey.id]);

const handleReset = useCallback(() => {
const hotkeyId = `${hotkey.category}.${hotkey.id}`;
dispatch(hotkeyReset(hotkeyId));
}, [dispatch, hotkey.category, hotkey.id]);

const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setEditValue(e.target.value);
}, []);

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
},
[handleSave, handleCancel]
);

useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);

if (isEditing) {
return (
<Flex gap={2} alignItems="center">
<Input
ref={inputRef}
value={editValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('hotkeys.enterHotkeys')}
size="sm"
/>
<IconButton
aria-label={t('hotkeys.save')}
icon={<PiCheckBold />}
onClick={handleSave}
size="sm"
colorScheme="invokeBlue"
/>
<IconButton aria-label={t('hotkeys.cancel')} icon={<PiXBold />} onClick={handleCancel} size="sm" />
</Flex>
);
}

return (
<Flex gap={2} alignItems="center">
<Text fontSize="sm" fontWeight="semibold" minW={32}>
{formatHotkeyForDisplay(hotkey.hotkeys)}
</Text>
<Tooltip label={t('hotkeys.editHotkey')}>
<IconButton
aria-label={t('hotkeys.editHotkey')}
icon={<PiPencilBold />}
onClick={handleEdit}
size="sm"
variant="ghost"
/>
</Tooltip>
{isCustomized && (
<Tooltip label={t('hotkeys.resetToDefault')}>
<IconButton
aria-label={t('hotkeys.resetToDefault')}
icon={<PiArrowCounterClockwiseBold />}
onClick={handleReset}
size="sm"
variant="ghost"
/>
</Tooltip>
)}
</Flex>
);
});

HotkeyEditor.displayName = 'HotkeyEditor';
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
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 (
<Flex flexDir="column" gap={2} px={2}>
<Flex lineHeight={1} gap={1} alignItems="center">
<Text fontWeight="semibold">{title}</Text>
<Spacer />
{platformKeys.map((hotkey, i1) => {
return (
<Fragment key={`${id}-${i1}`}>
{hotkey.map((key, i2) => (
<Fragment key={`${id}-${key}-${i1}-${i2}`}>
<Kbd textTransform="lowercase">{key}</Kbd>
{i2 !== hotkey.length - 1 && (
<Text as="span" fontWeight="semibold">
+
{showEditor ? (
<HotkeyEditor hotkey={hotkey} />
) : (
<>
{platformKeys.map((hotkey, i1) => {
return (
<Fragment key={`${id}-${i1}`}>
{hotkey.map((key, i2) => (
<Fragment key={`${id}-${key}-${i1}-${i2}`}>
<Kbd textTransform="lowercase">{key}</Kbd>
{i2 !== hotkey.length - 1 && (
<Text as="span" fontWeight="semibold">
+
</Text>
)}
</Fragment>
))}
{i1 !== platformKeys.length - 1 && (
<Text as="span" px={2} variant="subtext" fontWeight="semibold">
{t('common.or')}
</Text>
)}
</Fragment>
))}
{i1 !== platformKeys.length - 1 && (
<Text as="span" px={2} variant="subtext" fontWeight="semibold">
{t('common.or')}
</Text>
)}
</Fragment>
);
})}
);
})}
</>
)}
</Flex>
<Text variant="subtext">{desc}</Text>
</Flex>
Expand Down
Loading