Keyboard Shortcuts System - nself-org/nchat GitHub Wiki
Complete documentation for the nself-chat keyboard shortcuts system.
The keyboard shortcuts system provides:
- Comprehensive Shortcuts: 40+ pre-defined shortcuts across 5 categories
- Customization: Users can customize any shortcut key binding
- Conflict Detection: Automatic detection and warning of shortcut conflicts
- Scope Management: Context-aware shortcuts (e.g., editor-only, message-selected)
- Platform Support: Cross-platform compatibility (Mac, Windows, Linux)
- Persistence: Custom shortcuts saved to localStorage
- Import/Export: Backup and restore custom configurations
-
Help Modal: Searchable, categorized shortcuts reference (press
?)
src/
āāā lib/
ā āāā keyboard/ # Core keyboard infrastructure
ā ā āāā index.ts # Main exports
ā ā āāā shortcuts.ts # Shortcut definitions
ā ā āāā shortcut-store.ts # Zustand store for customization
ā ā āāā shortcut-utils.ts # Utility functions
ā ā āāā use-shortcuts.ts # React hooks
ā āāā shortcuts/
ā āāā shortcut-manager.ts # Central manager class
āāā hooks/
ā āāā use-keyboard-shortcuts.ts # Basic shortcut hook
ā āāā use-hotkey.ts # Simple hotkey hook
ā āāā use-global-shortcuts.ts # App-level shortcuts
ā āāā use-editor-shortcuts.ts # Editor formatting
ā āāā use-message-shortcuts.ts # Message actions
āāā components/
ā āāā modals/
ā ā āāā ShortcutsModal.tsx # Help modal (? key)
ā āāā settings/
ā āāā KeyboardShortcuts.tsx # Settings panel
āāā stores/
āāā settings-store.ts # Global settings
import { useShortcut } from '@/lib/keyboard'
function MyComponent() {
useShortcut('QUICK_SWITCHER', () => {
openQuickSwitcher()
})
return <div>...</div>
}import { useHotkey } from '@/hooks/use-hotkey'
function MyComponent() {
// Simple usage
useHotkey('mod+k', () => openSearch())
// With options
useHotkey('mod+s', handleSave, {
preventDefault: true,
enableOnInputs: true,
})
return <div>...</div>
}import { useEditorShortcuts } from '@/hooks/use-editor-shortcuts';
import { useEditor } from '@tiptap/react';
function MessageEditor() {
const editor = useEditor({...});
useEditorShortcuts({
editor,
isFocused: true,
onInsertLink: () => setLinkDialogOpen(true),
});
return <EditorContent editor={editor} />;
}import { useScopedKeyboard } from '@/lib/keyboard'
function MessageList() {
const [selectedMessageId, setSelectedMessageId] = useState(null)
// Activate 'message-selected' scope when a message is selected
useScopedKeyboard('message-selected', !!selectedMessageId)
// This shortcut only works when scope is active
useShortcut(
'REPLY',
() => {
replyToMessage(selectedMessageId)
},
{ scopes: ['message-selected'] }
)
return <div>...</div>
}import { getShortcutManager } from '@/lib/shortcuts/shortcut-manager'
const manager = getShortcutManager()
// Register a shortcut
const unregister = manager.register({
id: 'my-shortcut',
key: 'mod+shift+x',
handler: (event) => {
console.log('Shortcut triggered!')
},
priority: 10,
scopes: ['chat'],
})
// Add scope
manager.addScope('chat')
// Clean up
unregister()| Shortcut | Keys | Description |
|---|---|---|
| Quick Switcher | Cmd+K |
Open channel/DM quick switcher |
| Search | Cmd+F |
Search messages and files |
| Next Channel | Alt+ā |
Navigate to next channel |
| Previous Channel | Alt+ā |
Navigate to previous channel |
| Next Unread | Alt+Shift+ā |
Jump to next unread channel |
| Previous Unread | Alt+Shift+ā |
Jump to previous unread channel |
| Go to DMs | Cmd+Shift+K |
Open direct messages |
| Focus Message Input | Cmd+/ |
Focus the message input field |
| Shortcut | Keys | Description |
|---|---|---|
| Edit Last | ā |
Edit your last message (empty input) |
| Reply | R |
Reply to selected message |
| React | E |
Add emoji reaction |
| Delete | Backspace |
Delete selected message |
| Copy | Cmd+C |
Copy message text |
| Pin | P |
Pin/unpin message |
| Mark Unread | U |
Mark as unread |
| Thread | T |
Open message thread |
| Shortcut | Keys | Description |
|---|---|---|
| Bold | Cmd+B |
Make text bold |
| Italic | Cmd+I |
Make text italic |
| Underline | Cmd+U |
Underline text |
| Strikethrough | Cmd+Shift+X |
Strikethrough text |
| Code | Cmd+Shift+C |
Inline code |
| Code Block | Cmd+Shift+Enter |
Code block |
| Link | Cmd+Shift+U |
Insert link |
| Quote | Cmd+Shift+. |
Quote text |
| Bullet List | Cmd+Shift+8 |
Bullet list |
| Numbered List | Cmd+Shift+7 |
Numbered list |
| Shortcut | Keys | Description |
|---|---|---|
| Toggle Sidebar | Cmd+Shift+D |
Show/hide sidebar |
| Toggle Thread | Cmd+Shift+T |
Show/hide thread panel |
| Toggle Members | Cmd+Shift+M |
Show/hide members panel |
| Show Shortcuts | ? |
Open shortcuts modal |
| Close Modal | Esc |
Close any modal/overlay |
| Toggle Fullscreen | Cmd+Shift+F |
Enter/exit fullscreen |
| Toggle Compact | Cmd+Shift+J |
Toggle compact mode |
| Emoji Picker | Cmd+Shift+E |
Open emoji picker |
| Shortcut | Keys | Description |
|---|---|---|
| New Channel | Cmd+Shift+N |
Create new channel |
| New DM | Cmd+N |
Start new direct message |
| Upload File | Cmd+Shift+U |
Upload a file |
| Invite Members | Cmd+Shift+I |
Invite members |
| Settings | Cmd+, |
Open settings |
| Profile | Cmd+Shift+P |
Open profile |
- Open Settings ā Keyboard Shortcuts
- Search/Filter shortcuts by name or category
- Click Keyboard Icon to record new key combo
- Press Desired Keys in the recording dialog
- Save or cancel
The system automatically detects conflicts:
// Example: Two shortcuts with same key in overlapping scopes
{
key: 'mod+s',
id: 'save-message',
scopes: ['chat']
}
{
key: 'mod+s',
id: 'save-draft',
scopes: ['chat'] // ā CONFLICT!
}Conflicts are displayed in the settings UI with a warning icon.
// Export customizations
const json = useShortcutStore.getState().exportCustomizations()
// Download as JSON file
// Import customizations
const success = useShortcutStore.getState().importCustomizations(json)Shortcuts can be scoped to specific contexts:
| Scope | Description |
|---|---|
global |
Always active (no scope specified) |
editor |
Active when message editor is focused |
message-selected |
Active when a message is selected |
message-input-empty |
Active when input is empty |
chat |
Active in chat view |
modal-open |
Active when modal is open |
import { useScopedKeyboard } from '@/lib/keyboard'
function MyComponent() {
const [isActive, setIsActive] = useState(false)
// Scope is active when isActive is true
useScopedKeyboard('my-scope', isActive)
useShortcut('MY_SHORTCUT', handleAction, {
scopes: ['my-scope'],
})
}interface ShortcutStoreState {
customShortcuts: Record<string, CustomShortcut>
disabledShortcuts: Set<string>
shortcutsEnabled: boolean
showKeyboardHints: boolean
recordingShortcut: ShortcutKey | null
conflicts: ShortcutConflict[]
}// Customization
setCustomKey(id: ShortcutKey, key: string): void
resetToDefault(id: ShortcutKey): void
resetAllToDefaults(): void
// Enable/Disable
disableShortcut(id: ShortcutKey): void
enableShortcut(id: ShortcutKey): void
toggleShortcut(id: ShortcutKey): void
setShortcutsEnabled(enabled: boolean): void
// Recording
startRecording(id: ShortcutKey): void
stopRecording(): void
recordKey(key: string): boolean
// Preferences
setShowKeyboardHints(show: boolean): void
// Getters
getEffectiveKey(id: ShortcutKey): string
isShortcutEnabled(id: ShortcutKey): boolean
getConflicts(): ShortcutConflict[]
// Export/Import
exportCustomizations(): string
importCustomizations(json: string): booleanimport { formatShortcut } from '@/lib/keyboard/shortcut-utils'
// Platform-aware formatting
const display = formatShortcut('mod+shift+k', { useMacSymbols: true })
// Mac: "āā§K"
// Windows: "Ctrl+Shift+K"import { shortcutsConflict } from '@/lib/keyboard/shortcut-utils'
const hasConflict = shortcutsConflict('mod+k', 'mod+k') // true
const hasConflict = shortcutsConflict('mod+k', 'mod+shift+k') // falseimport { parseShortcut } from '@/lib/keyboard/shortcut-utils'
const parsed = parseShortcut('mod+shift+k')
// {
// modifiers: ['mod', 'shift'],
// key: 'k',
// originalString: 'mod+shift+k'
// }// ā
Good - works on all platforms
useHotkey('mod+k', handleAction)
// ā Bad - Mac only
useHotkey('meta+k', handleAction)// ā
Prevent browser's print dialog
useHotkey('mod+p', handlePrint, { preventDefault: true })// ā
Editor shortcuts only active when editing
useShortcut('BOLD', handleBold, { scopes: ['editor'] })
// ā Would interfere with global actions
useShortcut('BOLD', handleBold) // No scope// Higher priority executes first
manager.register({
id: 'high-priority',
key: 'mod+k',
handler: handleAction,
priority: 100, // Executes before lower priorities
})useEffect(() => {
const unregister = manager.register({...});
return () => {
unregister(); // ā
Clean up
};
}, []);When showKeyboardHints is enabled, shortcuts are displayed in tooltips:
import { useShortcutStore } from '@/lib/keyboard/shortcut-store'
function MyButton() {
const showHints = useShortcutStore(selectShowKeyboardHints)
const shortcut = useShortcutStore(selectEffectiveKey('QUICK_SWITCHER'))
return (
<Tooltip>
<TooltipTrigger>
<Button>Quick Switcher</Button>
</TooltipTrigger>
{showHints && (
<TooltipContent>
<p>{formatShortcut(shortcut)}</p>
</TooltipContent>
)}
</Tooltip>
)
}Shortcuts are announced via aria-keyshortcuts:
<button aria-keyshortcuts="Control+K">Open Quick Switcher</button>import { matchesShortcut } from '@/lib/keyboard/shortcut-utils'
test('matches shortcut correctly', () => {
const event = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
expect(matchesShortcut(event, 'mod+k')).toBe(true)
})import { renderHook } from '@testing-library/react'
import { useShortcut } from '@/lib/keyboard'
test('shortcut triggers callback', () => {
const handleAction = jest.fn()
renderHook(() => useShortcut('QUICK_SWITCHER', handleAction))
// Simulate Cmd+K
const event = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
window.dispatchEvent(event)
expect(handleAction).toHaveBeenCalled()
})-
Debounce Heavy Handlers: Use
useMemooruseCallback - Lazy Registration: Only register shortcuts when needed
- Unregister on Unmount: Prevent memory leaks
- Use Scopes: Reduce active shortcut count
- Priority System: Most important shortcuts first
- Registration Time: < 1ms per shortcut
- Event Processing: < 5ms average
- Memory Usage: ~50KB for 40 shortcuts
- Conflict Detection: O(n²) but cached
- Check if shortcuts are globally enabled
- Verify scope is active
- Check for conflicts in settings
- Ensure shortcut is not disabled
- Check if input field has focus (and shouldn't)
- Check if shortcuts have different scopes
- Verify conflict detection is running
- Check store state in DevTools
- Check localStorage permissions
- Verify store persistence config
- Check browser console for errors
// Old
useKeyboardShortcuts({
'Cmd+K': handleAction,
})
// New
useShortcut('QUICK_SWITCHER', handleAction)
// or
useHotkey('mod+k', handleAction)- Chord shortcuts (e.g.,
gthenh) - Shortcut recording UI improvements
- Cloud sync for custom shortcuts
- Shortcut analytics
- Voice-controlled shortcuts
- Gesture shortcuts (mobile)
- Macro recording
- Shortcut themes/presets
For issues or questions:
- File an issue on GitHub
- Check the troubleshooting section
- Review the examples in
/examples - Join our Discord community