diff --git a/CUSTOM_KEYBOARD_SHORTCUTS.md b/CUSTOM_KEYBOARD_SHORTCUTS.md new file mode 100644 index 0000000000..1c66fca542 --- /dev/null +++ b/CUSTOM_KEYBOARD_SHORTCUTS.md @@ -0,0 +1,183 @@ +# Custom Keyboard Shortcuts Feature + +## Overview + +This feature allows users to customize keyboard shortcuts for various actions in Vikunja. Users can modify shortcuts for task operations, general app functions, and more through a dedicated settings page. + +## Features + +### ✅ Implemented + +- **Customizable Action Shortcuts**: Users can customize shortcuts for task operations (mark done, assign, labels, etc.) and general app functions (toggle menu, quick search, etc.) +- **Fixed Navigation Shortcuts**: Navigation shortcuts (j/k for list navigation, g+key sequences) remain fixed and cannot be customized +- **Conflict Detection**: Prevents users from assigning the same shortcut to multiple actions +- **Individual and Bulk Reset**: Users can reset individual shortcuts or entire categories to defaults +- **Persistent Storage**: Custom shortcuts are saved to user settings and sync across devices +- **Real-time Updates**: Changes apply immediately without requiring a page refresh +- **Comprehensive UI**: Dedicated settings page with organized categories and intuitive editing + +### 🔧 Architecture + +#### Frontend Components + +1. **useShortcutManager Composable** (`frontend/src/composables/useShortcutManager.ts`) + - Core logic for managing shortcuts + - Validation and conflict detection + - Persistence through auth store + - Reactive updates + +2. **ShortcutEditor Component** (`frontend/src/components/misc/keyboard-shortcuts/ShortcutEditor.vue`) + - Individual shortcut editing interface + - Key capture functionality + - Real-time validation feedback + +3. **KeyboardShortcuts Settings Page** (`frontend/src/views/user/settings/KeyboardShortcuts.vue`) + - Main settings interface + - Category organization + - Bulk operations + +4. **Enhanced v-shortcut Directive** (`frontend/src/directives/shortcut.ts`) + - Supports both old format (direct keys) and new format (actionIds) + - Backwards compatible + +#### Data Models + +- **ICustomShortcut.ts**: TypeScript interfaces for custom shortcuts +- **IUserSettings.ts**: Extended to include `customShortcuts` field +- **shortcuts.ts**: Enhanced with metadata (actionId, customizable, category, contexts) + +#### Storage + +Custom shortcuts are stored in the user's `frontendSettings.customShortcuts` object: + +```typescript +{ + "general.toggleMenu": ["alt", "m"], + "task.markDone": ["ctrl", "d"] +} +``` + +## Usage + +### For Users + +1. **Access Settings**: Navigate to User Settings → Keyboard Shortcuts +2. **Customize Shortcuts**: Click "Edit" next to any customizable shortcut +3. **Capture Keys**: Press the desired key combination in the input field +4. **Save Changes**: Click "Save" to apply the new shortcut +5. **Reset Options**: Use "Reset to default" for individual shortcuts or "Reset Category" for bulk operations + +### For Developers + +#### Adding New Customizable Shortcuts + +1. **Define the shortcut** in `shortcuts.ts`: +```typescript +{ + actionId: 'myFeature.doSomething', + title: 'myFeature.doSomething.title', + keys: ['ctrl', 'x'], + customizable: true, + contexts: ['/my-feature/*'], + category: ShortcutCategory.GENERAL, +} +``` + +2. **Add translation keys** in `en.json`: +```json +{ + "myFeature": { + "doSomething": { + "title": "Do Something" + } + } +} +``` + +3. **Use in components**: +```vue + +``` + +#### Using the Shortcut Manager + +```typescript +import { useShortcutManager } from '@/composables/useShortcutManager' + +const shortcutManager = useShortcutManager() + +// Get effective shortcut +const keys = shortcutManager.getShortcut('task.markDone') + +// Get hotkey string for @github/hotkey +const hotkeyString = shortcutManager.getHotkeyString('task.markDone') + +// Validate shortcut +const result = shortcutManager.validateShortcut('task.markDone', ['ctrl', 'd']) + +// Set custom shortcut +await shortcutManager.setCustomShortcut('task.markDone', ['ctrl', 'd']) +``` + +## Implementation Details + +### Phase 1: Infrastructure Setup ✅ +- Created TypeScript interfaces and models +- Built core shortcut manager composable +- Developed UI components +- Added routing and translations + +### Phase 2: Integration ✅ +- Updated v-shortcut directive for backwards compatibility +- Refactored existing components to use new system +- Updated help modal to show effective shortcuts + +### Phase 3: Polish and Testing ✅ +- Added comprehensive unit tests +- Verified all translation keys +- Created documentation + +## Testing + +### Unit Tests +- `useShortcutManager.test.ts`: Tests for the core composable +- `ShortcutEditor.test.ts`: Tests for the editor component + +### Manual Testing Checklist +- [ ] Can access keyboard shortcuts settings page +- [ ] Can customize individual shortcuts +- [ ] Conflict detection works correctly +- [ ] Reset functionality works (individual and bulk) +- [ ] Changes persist across browser sessions +- [ ] Help modal shows effective shortcuts +- [ ] All existing shortcuts continue to work + +## Future Enhancements + +### Potential Improvements +- **Import/Export**: Allow users to backup and restore their custom shortcuts +- **Profiles**: Multiple shortcut profiles for different workflows +- **Advanced Sequences**: Support for more complex key sequences +- **Context Awareness**: Different shortcuts for different views/contexts +- **Accessibility**: Better support for screen readers and alternative input methods + +### Technical Debt +- Improve test coverage for complex scenarios +- Add E2E tests for the complete workflow +- Consider performance optimizations for large shortcut sets + +## Migration Notes + +This feature is fully backwards compatible. Existing shortcuts continue to work without any changes required. The new system runs alongside the old system until all shortcuts are migrated to use actionIds. + +## Support + +For issues or questions about custom keyboard shortcuts: +1. Check the help modal (Shift+?) for current shortcuts +2. Visit the keyboard shortcuts settings page for customization options +3. Reset to defaults if experiencing issues +4. Report bugs with specific key combinations and browser information diff --git a/frontend/src/components/home/ContentAuth.vue b/frontend/src/components/home/ContentAuth.vue index a77749b651..77f8315bfa 100644 --- a/frontend/src/components/home/ContentAuth.vue +++ b/frontend/src/components/home/ContentAuth.vue @@ -55,7 +55,7 @@ diff --git a/frontend/src/components/home/MenuButton.vue b/frontend/src/components/home/MenuButton.vue index 1ab8dab834..956e959f4c 100644 --- a/frontend/src/components/home/MenuButton.vue +++ b/frontend/src/components/home/MenuButton.vue @@ -1,6 +1,6 @@ + +

+ {{ $t('keyboardShortcuts.helpText') }} +

diff --git a/keyboard-shortcuts-custom.md b/keyboard-shortcuts-custom.md new file mode 100644 index 0000000000..2dff2e9b55 --- /dev/null +++ b/keyboard-shortcuts-custom.md @@ -0,0 +1,1159 @@ +# Customizable Keyboard Shortcuts - Implementation Plan + +**Date:** 2025-11-27 +**Feature:** Allow users to customize keyboard shortcuts for actions in the Vikunja frontend + +## Overview + +This plan outlines the implementation of customizable keyboard shortcuts for Vikunja. Users will be able to customize action shortcuts (task operations, general app shortcuts) while keeping navigation shortcuts (j/k, g+key sequences) fixed. Customizations will be stored in the existing `frontendSettings` system and sync across devices. + +## Requirements Summary + +- **Scope:** Only action shortcuts customizable (not navigation keys like j/k or g+letter sequences) +- **Location:** Dedicated section in user settings page +- **Storage:** `frontendSettings` in auth store (syncs via backend) +- **Conflicts:** Prevent conflicts with validation and clear error messages +- **Reset:** Individual shortcut reset, category reset, and reset all +- **Display:** Show all shortcuts with non-customizable ones displayed as disabled + +## Architecture Overview + +### Core Component: ShortcutManager Composable + +The centerpiece will be a new `useShortcutManager()` composable that becomes the single source of truth for all keyboard shortcuts. This manager will: + +**Core Responsibilities:** +- Maintain the registry of all shortcuts (default + custom) +- Validate shortcut assignments and prevent conflicts +- Load/save custom shortcuts to `frontendSettings` +- Provide a reactive API for binding shortcuts to actions +- Handle the merging of defaults with user customizations + +**Key Design Decisions:** + +1. **Two-tier storage model:** Immutable defaults (from `shortcuts.ts`) and mutable overrides (from `frontendSettings.customShortcuts`) +2. **Semantic IDs:** Instead of hardcoded key strings, components register actions using IDs like `"general.toggleMenu"` or `"task.markDone"` +3. **Shared composable:** Uses VueUse's `createSharedComposable` for consistent state across all instances +4. **Reactive updates:** When settings change, all bound shortcuts update automatically without page reload + +### Current State Analysis + +**Current Implementation Uses Three Binding Approaches:** + +1. **v-shortcut directive** - Element-bound shortcuts on buttons/links + - Uses `@github/hotkey` library's `install()`/`uninstall()` + - Example: `` + +2. **Global keydown listeners** - App-wide shortcuts not tied to elements + - Uses `eventToHotkeyString()` to normalize key events + - Example: Ctrl+K for quick search, Ctrl+S for save + +3. **Direct key checking** - View-specific navigation (j/k in lists) + - Direct `e.key` checking in event handlers + - Example: List navigation in `ProjectList.vue` + +**All three approaches will be refactored** to use the ShortcutManager, ensuring consistent behavior and customization support. + +## Data Model & Storage + +### TypeScript Interfaces + +**New Interface: `ICustomShortcut`** +```typescript +// frontend/src/modelTypes/ICustomShortcut.ts +export interface ICustomShortcut { + actionId: string // e.g., "task.markDone" + keys: string[] // e.g., ["t"] or ["Control", "s"] + isCustomized: boolean // true if user changed from default +} + +export interface ICustomShortcutsMap { + [actionId: string]: string[] // Maps "task.markDone" -> ["t"] +} +``` + +**Update: `IFrontendSettings`** +```typescript +// frontend/src/modelTypes/IUserSettings.ts +export interface IFrontendSettings { + // ... existing fields ... + customShortcuts?: ICustomShortcutsMap // New field +} +``` + +### Shortcut Action Registry + +**Update: `shortcuts.ts`** + +Add metadata to existing shortcut definitions: + +```typescript +// frontend/src/components/misc/keyboard-shortcuts/shortcuts.ts + +export interface ShortcutAction { + actionId: string // Unique ID like "general.toggleMenu" + title: string // i18n key for display + keys: string[] // Default keys + customizable: boolean // Can user customize this? + contexts?: string[] // Which routes/contexts apply + category: ShortcutCategory +} + +export enum ShortcutCategory { + GENERAL = 'general', + NAVIGATION = 'navigation', + TASK_ACTIONS = 'taskActions', + PROJECT_VIEWS = 'projectViews', + LIST_VIEW = 'listView', + GANTT_VIEW = 'ganttView', +} + +export interface ShortcutGroup { + title: string + category: ShortcutCategory + shortcuts: ShortcutAction[] +} + +// Example updated structure: +export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [ + { + title: 'keyboardShortcuts.general', + category: ShortcutCategory.GENERAL, + shortcuts: [ + { + actionId: 'general.toggleMenu', + title: 'keyboardShortcuts.toggleMenu', + keys: ['Control', 'e'], + customizable: true, + contexts: ['*'], + category: ShortcutCategory.GENERAL, + }, + { + actionId: 'general.quickSearch', + title: 'keyboardShortcuts.quickSearch', + keys: ['Control', 'k'], + customizable: true, + contexts: ['*'], + category: ShortcutCategory.GENERAL, + }, + ], + }, + { + title: 'keyboardShortcuts.navigation', + category: ShortcutCategory.NAVIGATION, + shortcuts: [ + { + actionId: 'navigation.goToOverview', + title: 'keyboardShortcuts.goToOverview', + keys: ['g', 'o'], + customizable: false, // Navigation shortcuts are fixed + contexts: ['*'], + category: ShortcutCategory.NAVIGATION, + }, + // ... more navigation shortcuts with customizable: false + ], + }, + { + title: 'keyboardShortcuts.task', + category: ShortcutCategory.TASK_ACTIONS, + shortcuts: [ + { + actionId: 'task.markDone', + title: 'keyboardShortcuts.task.done', + keys: ['t'], + customizable: true, + contexts: ['/tasks/:id'], + category: ShortcutCategory.TASK_ACTIONS, + }, + { + actionId: 'task.toggleFavorite', + title: 'keyboardShortcuts.task.favorite', + keys: ['s'], + customizable: true, + contexts: ['/tasks/:id'], + category: ShortcutCategory.TASK_ACTIONS, + }, + // ... all task shortcuts + ], + }, + { + title: 'keyboardShortcuts.listView', + category: ShortcutCategory.LIST_VIEW, + shortcuts: [ + { + actionId: 'listView.nextTask', + title: 'keyboardShortcuts.list.down', + keys: ['j'], + customizable: false, // List navigation is fixed + contexts: ['/projects/:id/list'], + category: ShortcutCategory.LIST_VIEW, + }, + { + actionId: 'listView.previousTask', + title: 'keyboardShortcuts.list.up', + keys: ['k'], + customizable: false, + contexts: ['/projects/:id/list'], + category: ShortcutCategory.LIST_VIEW, + }, + // ... + ], + }, +] +``` + +**Default Values:** + +```typescript +// frontend/src/models/userSettings.ts +export default class UserSettingsModel implements IUserSettings { + // ... existing defaults ... + frontendSettings = { + // ... existing frontend settings ... + customShortcuts: {} as ICustomShortcutsMap, + } +} +``` + +## ShortcutManager Composable + +**File:** `frontend/src/composables/useShortcutManager.ts` + +### API Design + +```typescript +export interface UseShortcutManager { + // Get effective shortcut for an action (default or custom) + getShortcut(actionId: string): string[] | null + + // Get shortcut as hotkey string for @github/hotkey + getHotkeyString(actionId: string): string + + // Check if action is customizable + isCustomizable(actionId: string): boolean + + // Set custom shortcut for an action + setCustomShortcut(actionId: string, keys: string[]): Promise + + // Reset single shortcut to default + resetShortcut(actionId: string): Promise + + // Reset all shortcuts in a category + resetCategory(category: ShortcutCategory): Promise + + // Reset all shortcuts to defaults + resetAll(): Promise + + // Get all shortcuts (for settings UI) + getAllShortcuts(): ComputedRef + + // Get all customizable shortcuts + getCustomizableShortcuts(): ComputedRef + + // Validate a shortcut assignment + validateShortcut(actionId: string, keys: string[]): ValidationResult + + // Find conflicts for a given key combination + findConflicts(keys: string[]): ShortcutAction[] +} + +export interface ValidationResult { + valid: boolean + error?: string // i18n key + conflicts?: ShortcutAction[] +} +``` + +### Implementation Structure + +```typescript +import { computed, readonly } from 'vue' +import { createSharedComposable } from '@vueuse/core' +import { useAuthStore } from '@/stores/auth' +import { KEYBOARD_SHORTCUTS, ShortcutCategory } from '@/components/misc/keyboard-shortcuts/shortcuts' +import type { ShortcutAction, ShortcutGroup } from '@/components/misc/keyboard-shortcuts/shortcuts' +import type { ICustomShortcutsMap, ValidationResult } from '@/modelTypes/ICustomShortcut' + +export const useShortcutManager = createSharedComposable(() => { + const authStore = useAuthStore() + + // Build flat map of all shortcuts by actionId + const defaultShortcuts = computed>(() => { + const map = new Map() + KEYBOARD_SHORTCUTS.forEach(group => { + group.shortcuts.forEach(shortcut => { + map.set(shortcut.actionId, shortcut) + }) + }) + return map + }) + + // Get custom shortcuts from settings + const customShortcuts = computed(() => { + return authStore.settings.frontendSettings.customShortcuts || {} + }) + + // Effective shortcuts (merged default + custom) + const effectiveShortcuts = computed>(() => { + const map = new Map() + defaultShortcuts.value.forEach((action, actionId) => { + const custom = customShortcuts.value[actionId] + map.set(actionId, custom || action.keys) + }) + return map + }) + + function getShortcut(actionId: string): string[] | null { + return effectiveShortcuts.value.get(actionId) || null + } + + function getHotkeyString(actionId: string): string { + const keys = getShortcut(actionId) + if (!keys) return '' + + // Convert array to hotkey string format + // ['Control', 'k'] -> 'Control+k' + // ['g', 'o'] -> 'g o' + return keys.join(keys.length > 1 && !isModifier(keys[0]) ? ' ' : '+') + } + + function isCustomizable(actionId: string): boolean { + const action = defaultShortcuts.value.get(actionId) + return action?.customizable ?? false + } + + function findConflicts(keys: string[], excludeActionId?: string): ShortcutAction[] { + const conflicts: ShortcutAction[] = [] + const keysStr = keys.join('+') + + effectiveShortcuts.value.forEach((shortcutKeys, actionId) => { + if (actionId === excludeActionId) return + if (shortcutKeys.join('+') === keysStr) { + const action = defaultShortcuts.value.get(actionId) + if (action) conflicts.push(action) + } + }) + + return conflicts + } + + function validateShortcut(actionId: string, keys: string[]): ValidationResult { + // Check if action exists and is customizable + const action = defaultShortcuts.value.get(actionId) + if (!action) { + return { valid: false, error: 'keyboardShortcuts.errors.unknownAction' } + } + if (!action.customizable) { + return { valid: false, error: 'keyboardShortcuts.errors.notCustomizable' } + } + + // Check if keys array is valid + if (!keys || keys.length === 0) { + return { valid: false, error: 'keyboardShortcuts.errors.emptyShortcut' } + } + + // Check for conflicts + const conflicts = findConflicts(keys, actionId) + if (conflicts.length > 0) { + return { + valid: false, + error: 'keyboardShortcuts.errors.conflict', + conflicts + } + } + + return { valid: true } + } + + async function setCustomShortcut(actionId: string, keys: string[]): Promise { + const validation = validateShortcut(actionId, keys) + if (!validation.valid) return validation + + // Update custom shortcuts + const updated = { + ...customShortcuts.value, + [actionId]: keys, + } + + // Save to backend via auth store + await authStore.saveUserSettings({ + frontendSettings: { + ...authStore.settings.frontendSettings, + customShortcuts: updated, + }, + }) + + return { valid: true } + } + + async function resetShortcut(actionId: string): Promise { + const updated = { ...customShortcuts.value } + delete updated[actionId] + + await authStore.saveUserSettings({ + frontendSettings: { + ...authStore.settings.frontendSettings, + customShortcuts: updated, + }, + }) + } + + async function resetCategory(category: ShortcutCategory): Promise { + const actionsInCategory = Array.from(defaultShortcuts.value.values()) + .filter(action => action.category === category) + .map(action => action.actionId) + + const updated = { ...customShortcuts.value } + actionsInCategory.forEach(actionId => { + delete updated[actionId] + }) + + await authStore.saveUserSettings({ + frontendSettings: { + ...authStore.settings.frontendSettings, + customShortcuts: updated, + }, + }) + } + + async function resetAll(): Promise { + await authStore.saveUserSettings({ + frontendSettings: { + ...authStore.settings.frontendSettings, + customShortcuts: {}, + }, + }) + } + + function getAllShortcuts(): ComputedRef { + return computed(() => { + // Return groups with effective shortcuts applied + return KEYBOARD_SHORTCUTS.map(group => ({ + ...group, + shortcuts: group.shortcuts.map(shortcut => ({ + ...shortcut, + keys: getShortcut(shortcut.actionId) || shortcut.keys, + })), + })) + }) + } + + function getCustomizableShortcuts(): ComputedRef { + return computed(() => { + return Array.from(defaultShortcuts.value.values()) + .filter(action => action.customizable) + }) + } + + return { + getShortcut, + getHotkeyString, + isCustomizable, + setCustomShortcut, + resetShortcut, + resetCategory, + resetAll, + getAllShortcuts, + getCustomizableShortcuts, + validateShortcut, + findConflicts, + } +}) + +function isModifier(key: string): boolean { + return ['Control', 'Meta', 'Shift', 'Alt'].includes(key) +} +``` + +## Settings UI Components + +### Main Settings Page Section + +**File:** `frontend/src/views/user/settings/KeyboardShortcuts.vue` + +**Structure:** +```vue + + + +``` + +### Shortcut Editor Component + +**File:** `frontend/src/components/misc/keyboard-shortcuts/ShortcutEditor.vue` + +**Features:** +- Display current shortcut with visual keys +- Edit mode with key capture +- Validation with conflict detection +- Reset button for customized shortcuts +- Disabled state for non-customizable shortcuts + +**Structure:** +```vue + + + + + +``` + +### Update Settings Navigation + +**File:** `frontend/src/views/user/settings/index.vue` + +Add new route and navigation item for keyboard shortcuts settings. + +## Migration Strategy + +### Phase 1: Add Infrastructure (No Breaking Changes) + +1. **Add new TypeScript interfaces** + - `ICustomShortcut`, `ICustomShortcutsMap` + - Update `IFrontendSettings` + +2. **Update `shortcuts.ts` with metadata** + - Add `actionId`, `customizable`, `category`, `contexts` to all shortcuts + - Keep existing structure, only add fields + +3. **Create `useShortcutManager` composable** + - Implement all API methods + - Test in isolation + +4. **Build settings UI components** + - `KeyboardShortcuts.vue` settings page + - `ShortcutEditor.vue` component + - Add to settings navigation + +**Verification:** Settings UI works, can customize and persist shortcuts, but existing code still uses hardcoded shortcuts. + +### Phase 2: Refactor Shortcut Bindings + +Refactor components one category at a time to use the manager: + +#### 2.1 Update v-shortcut Directive + +**File:** `frontend/src/directives/shortcut.ts` + +```typescript +import { install, uninstall } from '@github/hotkey' +import { useShortcutManager } from '@/composables/useShortcutManager' +import type { Directive } from 'vue' + +const directive = >{ + mounted(el, { value }) { + if (value === '') return + + // Support both old format (direct keys) and new format (actionId) + const shortcutManager = useShortcutManager() + const hotkeyString = value.startsWith('.') + ? shortcutManager.getHotkeyString(value) // New format: actionId + : value // Old format: direct keys (backwards compat) + + if (!hotkeyString) return + + install(el, hotkeyString) + + // Store for cleanup + el.dataset.shortcutActionId = value + }, + updated(el, { value, oldValue }) { + if (value === oldValue) return + + // Reinstall with new shortcut + uninstall(el) + + const shortcutManager = useShortcutManager() + const hotkeyString = value.startsWith('.') + ? shortcutManager.getHotkeyString(value) + : value + + if (!hotkeyString) return + install(el, hotkeyString) + }, + beforeUnmount(el) { + uninstall(el) + }, +} + +export default directive +``` + +**Usage migration:** +```vue + + + + + +``` + +#### 2.2 Refactor General Shortcuts + +**Files to update:** +- `frontend/src/components/home/MenuButton.vue` - Menu toggle (Ctrl+E) +- `frontend/src/components/misc/OpenQuickActions.vue` - Quick search (Ctrl+K) +- `frontend/src/components/home/ContentAuth.vue` - Help modal (Shift+?) + +**Pattern:** +```typescript +// Old +function handleShortcut(event) { + const hotkeyString = eventToHotkeyString(event) + if (hotkeyString !== 'Control+k') return + event.preventDefault() + action() +} + +// New +import { useShortcutManager } from '@/composables/useShortcutManager' + +const shortcutManager = useShortcutManager() + +function handleShortcut(event) { + const hotkeyString = eventToHotkeyString(event) + const expectedHotkey = shortcutManager.getHotkeyString('general.quickSearch') + if (hotkeyString !== expectedHotkey) return + event.preventDefault() + action() +} +``` + +#### 2.3 Refactor Navigation Shortcuts + +**Files to update:** +- `frontend/src/components/home/Navigation.vue` - All g+key sequences + +**Pattern:** +```vue + + + + + +``` + +#### 2.4 Refactor Task Detail Shortcuts + +**File:** `frontend/src/views/tasks/TaskDetailView.vue` + +Update all 14 task shortcuts to use actionIds through the directive: + +```vue + + + + + +``` + +For the save shortcut (global listener): +```typescript +// Old +function saveTaskViaHotkey(event) { + const hotkeyString = eventToHotkeyString(event) + if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return + // ... +} + +// New +const shortcutManager = useShortcutManager() + +function saveTaskViaHotkey(event) { + const hotkeyString = eventToHotkeyString(event) + const expectedHotkey = shortcutManager.getHotkeyString('task.save') + if (hotkeyString !== expectedHotkey) return + // ... +} +``` + +#### 2.5 List View Navigation (Keep Fixed) + +**File:** `frontend/src/components/project/views/ProjectList.vue` + +Keep j/k/Enter as hardcoded since they're non-customizable, but add them to the registry for documentation purposes. + +### Phase 3: Update Help Modal + +**File:** `frontend/src/components/misc/keyboard-shortcuts/index.vue` + +Update to use `shortcutManager.getAllShortcuts()` instead of the static `KEYBOARD_SHORTCUTS` constant, so the help modal always shows current effective shortcuts (including customizations). + +```typescript +import { useShortcutManager } from '@/composables/useShortcutManager' + +const shortcutManager = useShortcutManager() +const shortcuts = shortcutManager.getAllShortcuts() +``` + +Add link to settings page: +```vue +

+ {{ $t('keyboardShortcuts.helpText') }} + + {{ $t('keyboardShortcuts.customizeShortcuts') }} + +

+``` + +### Phase 4: Testing & Cleanup + +1. Remove backward compatibility from directive if all components migrated +2. Add unit tests for `useShortcutManager` +3. Add E2E tests for customization flow +4. Update documentation + +## Testing Approach + +### Unit Tests + +**File:** `frontend/src/composables/useShortcutManager.test.ts` + +Test cases: +- ✅ Returns default shortcuts when no customizations +- ✅ Returns custom shortcuts when set +- ✅ Validates conflicts correctly +- ✅ Prevents assigning shortcuts to non-customizable actions +- ✅ Reset individual/category/all works correctly +- ✅ Persists to auth store correctly + +### Component Tests + +**Files:** +- `ShortcutEditor.test.ts` - Test key capture, validation, save/cancel +- `KeyboardShortcuts.test.ts` - Test settings page interactions + +### E2E Tests + +**File:** `frontend/cypress/e2e/keyboard-shortcuts.cy.ts` + +Test scenarios: +1. Navigate to settings, customize a shortcut, verify it works +2. Create conflict, verify error message prevents save +3. Reset individual shortcut, verify default restored +4. Reset all shortcuts, verify all defaults restored +5. Customize shortcut, reload page, verify persistence +6. Verify non-customizable shortcuts show as disabled + +### Manual Testing Checklist + +- [ ] Customize Ctrl+E (menu toggle) and verify it works +- [ ] Try to create conflict, verify error prevents save +- [ ] Customize task shortcut (t for mark done), verify in task detail +- [ ] Reset customized shortcut, verify default works again +- [ ] Reset entire category, verify all in category reset +- [ ] Reset all shortcuts, verify everything back to defaults +- [ ] Verify j/k navigation shortcuts cannot be edited +- [ ] Verify g+key navigation shortcuts cannot be edited +- [ ] Open help modal (Shift+?), verify shows customized shortcuts +- [ ] Logout/login, verify shortcuts persist +- [ ] Test on different device, verify shortcuts sync + +## Translation Keys + +Add to `frontend/src/i18n/lang/en.json`: + +```json +{ + "user": { + "settings": { + "keyboardShortcuts": { + "title": "Keyboard Shortcuts", + "description": "Customize keyboard shortcuts for actions. Navigation shortcuts (j/k, g+keys) are fixed and cannot be changed.", + "resetAll": "Reset All to Defaults", + "resetAllConfirm": "Are you sure you want to reset all keyboard shortcuts to defaults?", + "resetCategory": "Reset Category", + "resetToDefault": "Reset to default" + } + } + }, + "keyboardShortcuts": { + "fixed": "Fixed", + "pressKeys": "Press keys...", + "customizeShortcuts": "Customize shortcuts", + "helpText": "You can customize most keyboard shortcuts in settings.", + "errors": { + "unknownAction": "Unknown shortcut action", + "notCustomizable": "This shortcut cannot be customized", + "emptyShortcut": "Please press at least one key", + "conflict": "This shortcut is already assigned to: " + } + } +} +``` + +## Implementation Checklist + +### Phase 1: Infrastructure (Estimated: Core functionality) +- [ ] Create `ICustomShortcut` and `ICustomShortcutsMap` interfaces +- [ ] Update `IFrontendSettings` with `customShortcuts` field +- [ ] Update `UserSettingsModel` with default value +- [ ] Add metadata to all shortcuts in `shortcuts.ts` (`actionId`, `customizable`, `category`, `contexts`) +- [ ] Create `useShortcutManager.ts` composable with full API +- [ ] Write unit tests for `useShortcutManager` +- [ ] Create `ShortcutEditor.vue` component +- [ ] Create `KeyboardShortcuts.vue` settings page +- [ ] Add route for keyboard shortcuts settings +- [ ] Add navigation item in settings menu +- [ ] Add translation keys +- [ ] Manual test: Verify settings UI works and persists + +### Phase 2: Refactor Bindings (Estimated: Progressive refactoring) +- [ ] Update `shortcut.ts` directive to support actionIds +- [ ] Refactor `MenuButton.vue` (Ctrl+E) +- [ ] Refactor `OpenQuickActions.vue` (Ctrl+K) +- [ ] Refactor `ContentAuth.vue` (Shift+?) +- [ ] Refactor `Navigation.vue` (all g+key sequences) +- [ ] Refactor `TaskDetailView.vue` (all 14 task shortcuts + Ctrl+S) +- [ ] Refactor project view switching shortcuts +- [ ] Document list navigation shortcuts (j/k) in registry (keep hardcoded) +- [ ] Manual test: Verify all refactored shortcuts work with customization + +### Phase 3: Polish (Estimated: Final touches) +- [ ] Update help modal to show effective shortcuts +- [ ] Add link from help modal to settings +- [ ] Remove backward compatibility from directive (if desired) +- [ ] Write component tests for `ShortcutEditor` and `KeyboardShortcuts` +- [ ] Write E2E tests for customization flow +- [ ] Update documentation +- [ ] Full manual testing checklist + +### Phase 4: Code Review & Merge +- [ ] Run frontend lints: `pnpm lint:fix && pnpm lint:styles:fix` +- [ ] Run frontend tests: `pnpm test:unit` +- [ ] Code review +- [ ] Merge to main + +## Open Questions & Decisions + +1. **Multi-key sequences:** Should users be able to create their own multi-key sequences (like "g p" for custom actions), or only single keys and modifier combinations? + - **Decision:** Start with single keys + modifiers only. Can add sequences later if needed. + +2. **Import/Export:** Should we add import/export functionality for sharing shortcut configurations? + - **Decision:** Not in initial version. Can add later if users request it. + +3. **Shortcut recommendations:** Should we suggest alternative shortcuts when conflicts occur? + - **Decision:** Not in initial version. Show conflict error, user chooses different keys. + +4. **Platform differences:** Mac uses Cmd while others use Ctrl. Should we allow different shortcuts per platform? + - **Decision:** No. Use "Mod" (maps to Cmd on Mac, Ctrl elsewhere) and keep shortcuts platform-agnostic. Library already handles this. + +5. **Accessibility:** Should we provide a way to disable all keyboard shortcuts for users who need screen readers? + - **Decision:** Future enhancement. For now, shortcuts don't interfere with standard screen reader keys. + +## Success Criteria + +- ✅ Users can customize action shortcuts from settings page +- ✅ Navigation shortcuts (j/k, g+keys) remain fixed and clearly marked +- ✅ Conflict detection prevents duplicate shortcuts +- ✅ Individual, category, and global reset options work +- ✅ Customizations persist across sessions and devices +- ✅ Help modal reflects current effective shortcuts +- ✅ All existing shortcuts continue to work during migration +- ✅ No regressions in existing functionality +- ✅ Comprehensive test coverage (unit + E2E) +- ✅ Code follows Vikunja conventions and passes linting + +## Future Enhancements + +- Add import/export for shortcut configurations +- Add shortcut recommendation system for conflicts +- Allow custom multi-key sequences for advanced users +- Add keyboard shortcut recorder/tutorial for new users +- Add shortcut profiles (Vim mode, VS Code mode, etc.) +- Add analytics to track most-customized shortcuts