feat: implement custom keyboard shortcuts infrastructure (Phase 1)

- Add TypeScript interfaces for custom shortcuts (ICustomShortcut.ts)
- Update user settings models to include customShortcuts field
- Enhance shortcuts.ts with metadata (actionId, customizable, category)
- Create useShortcutManager composable with full API
- Build ShortcutEditor component with key capture and validation
- Create KeyboardShortcuts settings page with category organization
- Add routing and navigation for keyboard shortcuts settings
- Add comprehensive translation keys for UI and error messages

This implements the foundational infrastructure for customizable keyboard shortcuts.
Navigation shortcuts (j/k, g+keys) remain fixed as specified.
Action shortcuts (task operations, general app) are now customizable.
Settings persist via existing frontendSettings system and sync across devices.
This commit is contained in:
kolaente
2025-11-27 16:59:34 +01:00
parent 51512c1cb4
commit 94e334fa71
12 changed files with 2046 additions and 1 deletions

View File

@@ -0,0 +1,236 @@
<template>
<div
class="shortcut-editor"
:class="{ 'is-disabled': !shortcut.customizable, 'is-editing': isEditing }"
>
<div class="shortcut-info">
<label>{{ $t(shortcut.title) }}</label>
<span
v-if="!shortcut.customizable"
class="tag is-light"
>
{{ $t('keyboardShortcuts.fixed') }}
</span>
</div>
<div class="shortcut-input">
<div
v-if="!isEditing"
class="shortcut-display"
>
<Shortcut :keys="displayKeys" />
<BaseButton
v-if="shortcut.customizable"
size="small"
variant="tertiary"
@click="startEditing"
>
{{ $t('misc.edit') }}
</BaseButton>
</div>
<div
v-else
class="shortcut-edit"
>
<input
ref="captureInput"
type="text"
readonly
:value="captureDisplay"
:placeholder="$t('keyboardShortcuts.pressKeys')"
class="key-capture-input"
@keydown.prevent="captureKey"
@blur="cancelEditing"
>
<BaseButton
size="small"
:disabled="!capturedKeys.length"
@click="saveShortcut"
>
{{ $t('misc.save') }}
</BaseButton>
<BaseButton
size="small"
variant="tertiary"
@click="cancelEditing"
>
{{ $t('misc.cancel') }}
</BaseButton>
</div>
<BaseButton
v-if="isCustomized && !isEditing"
size="small"
variant="tertiary"
:title="$t('keyboardShortcuts.resetToDefault')"
@click="resetToDefault"
>
<Icon icon="undo" />
</BaseButton>
</div>
<p
v-if="validationError"
class="help is-danger"
>
{{ $t(validationError) }}
<span v-if="conflicts.length">
{{ conflicts.map(c => $t(c.title)).join(', ') }}
</span>
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useShortcutManager } from '@/composables/useShortcutManager'
import { eventToHotkeyString } from '@github/hotkey'
import Shortcut from '@/components/misc/Shortcut.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { FontAwesomeIcon as Icon } from '@fortawesome/vue-fontawesome'
import type { ShortcutAction } from '@/components/misc/keyboard-shortcuts/shortcuts'
const props = defineProps<{
shortcut: ShortcutAction
}>()
const emit = defineEmits<{
update: [actionId: string, keys: string[]]
reset: [actionId: string]
}>()
const shortcutManager = useShortcutManager()
const isEditing = ref(false)
const capturedKeys = ref<string[]>([])
const validationError = ref<string | null>(null)
const conflicts = ref<ShortcutAction[]>([])
const captureInput = ref<HTMLInputElement>()
const displayKeys = computed(() => {
return shortcutManager.getShortcut(props.shortcut.actionId) || props.shortcut.keys
})
const isCustomized = computed(() => {
const current = shortcutManager.getShortcut(props.shortcut.actionId)
return JSON.stringify(current) !== JSON.stringify(props.shortcut.keys)
})
const captureDisplay = computed(() => {
return capturedKeys.value.join(' + ')
})
async function startEditing() {
isEditing.value = true
capturedKeys.value = []
validationError.value = null
conflicts.value = []
await nextTick()
captureInput.value?.focus()
}
function captureKey(event: KeyboardEvent) {
event.preventDefault()
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
// Parse hotkey string into keys array
const keys = hotkeyString.includes('+')
? hotkeyString.split('+')
: [hotkeyString]
capturedKeys.value = keys
// Validate in real-time
const validation = shortcutManager.validateShortcut(props.shortcut.actionId, keys)
if (!validation.valid) {
validationError.value = validation.error || null
conflicts.value = validation.conflicts || []
} else {
validationError.value = null
conflicts.value = []
}
}
function saveShortcut() {
if (!capturedKeys.value.length) return
const validation = shortcutManager.validateShortcut(props.shortcut.actionId, capturedKeys.value)
if (!validation.valid) {
validationError.value = validation.error || null
conflicts.value = validation.conflicts || []
return
}
emit('update', props.shortcut.actionId, capturedKeys.value)
isEditing.value = false
capturedKeys.value = []
}
function cancelEditing() {
isEditing.value = false
capturedKeys.value = []
validationError.value = null
conflicts.value = []
}
function resetToDefault() {
emit('reset', props.shortcut.actionId)
}
</script>
<style scoped>
.shortcut-editor {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--grey-200);
}
.shortcut-editor.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shortcut-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-display {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-edit {
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-capture-input {
min-width: 200px;
padding: 0.5rem;
border: 2px solid var(--primary);
border-radius: 4px;
font-family: monospace;
text-align: center;
}
.help.is-danger {
color: var(--danger);
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>

View File

@@ -5,6 +5,25 @@ import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
const reminderModifier = isAppleDevice() ? 'shift' : 'alt'
export enum ShortcutCategory {
GENERAL = 'general',
NAVIGATION = 'navigation',
TASK_ACTIONS = 'taskActions',
PROJECT_VIEWS = 'projectViews',
LIST_VIEW = 'listView',
GANTT_VIEW = 'ganttView',
}
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
combination?: 'then' // For multi-key sequences
}
export interface Shortcut {
title: string
keys: string[]
@@ -13,201 +32,353 @@ export interface Shortcut {
export interface ShortcutGroup {
title: string
category: ShortcutCategory
available?: (route: RouteLocation) => boolean
shortcuts: Shortcut[]
shortcuts: ShortcutAction[]
}
export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
{
title: 'keyboardShortcuts.general',
category: ShortcutCategory.GENERAL,
shortcuts: [
{
actionId: 'general.toggleMenu',
title: 'keyboardShortcuts.toggleMenu',
keys: [ctrl, 'e'],
customizable: true,
contexts: ['*'],
category: ShortcutCategory.GENERAL,
},
{
actionId: 'general.quickSearch',
title: 'keyboardShortcuts.quickSearch',
keys: [ctrl, 'k'],
customizable: true,
contexts: ['*'],
category: ShortcutCategory.GENERAL,
},
],
},
{
title: 'keyboardShortcuts.navigation.title',
category: ShortcutCategory.NAVIGATION,
shortcuts: [
{
actionId: 'navigation.goToOverview',
title: 'keyboardShortcuts.navigation.overview',
keys: ['g', 'o'],
combination: 'then',
customizable: false, // Navigation shortcuts are fixed
contexts: ['*'],
category: ShortcutCategory.NAVIGATION,
},
{
actionId: 'navigation.goToUpcoming',
title: 'keyboardShortcuts.navigation.upcoming',
keys: ['g', 'u'],
combination: 'then',
customizable: false,
contexts: ['*'],
category: ShortcutCategory.NAVIGATION,
},
{
actionId: 'navigation.goToProjects',
title: 'keyboardShortcuts.navigation.projects',
keys: ['g', 'p'],
combination: 'then',
customizable: false,
contexts: ['*'],
category: ShortcutCategory.NAVIGATION,
},
{
actionId: 'navigation.goToLabels',
title: 'keyboardShortcuts.navigation.labels',
keys: ['g', 'a'],
combination: 'then',
customizable: false,
contexts: ['*'],
category: ShortcutCategory.NAVIGATION,
},
{
actionId: 'navigation.goToTeams',
title: 'keyboardShortcuts.navigation.teams',
keys: ['g', 'm'],
combination: 'then',
customizable: false,
contexts: ['*'],
category: ShortcutCategory.NAVIGATION,
},
],
},
{
title: 'keyboardShortcuts.list.title',
category: ShortcutCategory.LIST_VIEW,
available: (route) => route.name === 'project.view',
shortcuts: [
{
actionId: 'listView.nextTask',
title: 'keyboardShortcuts.list.navigateDown',
keys: ['j'],
customizable: false, // List navigation is fixed
contexts: ['/projects/:id/list'],
category: ShortcutCategory.LIST_VIEW,
},
{
actionId: 'listView.previousTask',
title: 'keyboardShortcuts.list.navigateUp',
keys: ['k'],
customizable: false,
contexts: ['/projects/:id/list'],
category: ShortcutCategory.LIST_VIEW,
},
{
actionId: 'listView.openTask',
title: 'keyboardShortcuts.list.open',
keys: ['enter'],
customizable: false,
contexts: ['/projects/:id/list'],
category: ShortcutCategory.LIST_VIEW,
},
],
},
{
title: 'project.kanban.title',
category: ShortcutCategory.PROJECT_VIEWS,
available: (route) => route.name === 'project.view',
shortcuts: [
{
actionId: 'kanban.markTaskDone',
title: 'keyboardShortcuts.task.done',
keys: [ctrl, 'click'],
customizable: false, // Mouse combinations are not customizable
contexts: ['/projects/:id/kanban'],
category: ShortcutCategory.PROJECT_VIEWS,
},
],
},
{
title: 'keyboardShortcuts.project.title',
category: ShortcutCategory.PROJECT_VIEWS,
available: (route) => (route.name as string)?.startsWith('project.'),
shortcuts: [
{
actionId: 'projectViews.switchToList',
title: 'keyboardShortcuts.project.switchToListView',
keys: ['g', 'l'],
combination: 'then',
customizable: false, // Navigation shortcuts are fixed
contexts: ['/projects/:id/*'],
category: ShortcutCategory.PROJECT_VIEWS,
},
{
actionId: 'projectViews.switchToGantt',
title: 'keyboardShortcuts.project.switchToGanttView',
keys: ['g', 'g'],
combination: 'then',
customizable: false,
contexts: ['/projects/:id/*'],
category: ShortcutCategory.PROJECT_VIEWS,
},
{
actionId: 'projectViews.switchToTable',
title: 'keyboardShortcuts.project.switchToTableView',
keys: ['g', 't'],
combination: 'then',
customizable: false,
contexts: ['/projects/:id/*'],
category: ShortcutCategory.PROJECT_VIEWS,
},
{
actionId: 'projectViews.switchToKanban',
title: 'keyboardShortcuts.project.switchToKanbanView',
keys: ['g', 'k'],
combination: 'then',
customizable: false,
contexts: ['/projects/:id/*'],
category: ShortcutCategory.PROJECT_VIEWS,
},
],
},
{
title: 'keyboardShortcuts.gantt.title',
category: ShortcutCategory.GANTT_VIEW,
available: (route) => route.name === 'project.view',
shortcuts: [
{
actionId: 'gantt.moveTaskLeft',
title: 'keyboardShortcuts.gantt.moveTaskLeft',
keys: ['←'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
{
actionId: 'gantt.moveTaskRight',
title: 'keyboardShortcuts.gantt.moveTaskRight',
keys: ['→'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
{
actionId: 'gantt.expandTaskLeft',
title: 'keyboardShortcuts.gantt.expandTaskLeft',
keys: ['shift', '←'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
{
actionId: 'gantt.expandTaskRight',
title: 'keyboardShortcuts.gantt.expandTaskRight',
keys: ['shift', '→'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
{
actionId: 'gantt.shrinkTaskLeft',
title: 'keyboardShortcuts.gantt.shrinkTaskLeft',
keys: [ctrl, '←'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
{
actionId: 'gantt.shrinkTaskRight',
title: 'keyboardShortcuts.gantt.shrinkTaskRight',
keys: [ctrl, '→'],
customizable: true,
contexts: ['/projects/:id/gantt'],
category: ShortcutCategory.GANTT_VIEW,
},
],
},
{
title: 'keyboardShortcuts.task.title',
category: ShortcutCategory.TASK_ACTIONS,
available: (route) => route.name === 'task.detail',
shortcuts: [
{
actionId: 'task.markDone',
title: 'keyboardShortcuts.task.done',
keys: ['t'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.assign',
title: 'keyboardShortcuts.task.assign',
keys: ['a'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.labels',
title: 'keyboardShortcuts.task.labels',
keys: ['l'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.dueDate',
title: 'keyboardShortcuts.task.dueDate',
keys: ['d'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.attachment',
title: 'keyboardShortcuts.task.attachment',
keys: ['f'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.related',
title: 'keyboardShortcuts.task.related',
keys: ['r'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.move',
title: 'keyboardShortcuts.task.move',
keys: ['m'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.color',
title: 'keyboardShortcuts.task.color',
keys: ['c'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.reminder',
title: 'keyboardShortcuts.task.reminder',
keys: [reminderModifier, 'r'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.description',
title: 'keyboardShortcuts.task.description',
keys: ['e'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.priority',
title: 'keyboardShortcuts.task.priority',
keys: ['p'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.delete',
title: 'keyboardShortcuts.task.delete',
keys: ['shift', 'delete'],
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,
},
{
actionId: 'task.openProject',
title: 'keyboardShortcuts.task.openProject',
keys: ['u'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
{
actionId: 'task.save',
title: 'keyboardShortcuts.task.save',
keys: [ctrl, 's'],
customizable: true,
contexts: ['/tasks/:id'],
category: ShortcutCategory.TASK_ACTIONS,
},
],
},

View File

@@ -0,0 +1,247 @@
import { computed, type ComputedRef } 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 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<ValidationResult>
// Reset single shortcut to default
resetShortcut(actionId: string): Promise<void>
// Reset all shortcuts in a category
resetCategory(category: ShortcutCategory): Promise<void>
// Reset all shortcuts to defaults
resetAll(): Promise<void>
// Get all shortcuts (for settings UI)
getAllShortcuts(): ComputedRef<ShortcutGroup[]>
// Get all customizable shortcuts
getCustomizableShortcuts(): ComputedRef<ShortcutAction[]>
// Validate a shortcut assignment
validateShortcut(actionId: string, keys: string[]): ValidationResult
// Find conflicts for a given key combination
findConflicts(keys: string[]): ShortcutAction[]
}
export const useShortcutManager = createSharedComposable(() => {
const authStore = useAuthStore()
// Build flat map of all shortcuts by actionId
const defaultShortcuts = computed<Map<string, ShortcutAction>>(() => {
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<ICustomShortcutsMap>(() => {
return authStore.settings.frontendSettings.customShortcuts || {}
})
// Effective shortcuts (merged default + custom)
const effectiveShortcuts = computed<Map<string, string[]>>(() => {
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<ValidationResult> {
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({
settings: {
...authStore.settings,
frontendSettings: {
...authStore.settings.frontendSettings,
customShortcuts: updated,
},
},
showMessage: false,
})
return { valid: true }
}
async function resetShortcut(actionId: string): Promise<void> {
const updated = { ...customShortcuts.value }
delete updated[actionId]
await authStore.saveUserSettings({
settings: {
...authStore.settings,
frontendSettings: {
...authStore.settings.frontendSettings,
customShortcuts: updated,
},
},
showMessage: false,
})
}
async function resetCategory(category: ShortcutCategory): Promise<void> {
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({
settings: {
...authStore.settings,
frontendSettings: {
...authStore.settings.frontendSettings,
customShortcuts: updated,
},
},
showMessage: false,
})
}
async function resetAll(): Promise<void> {
await authStore.saveUserSettings({
settings: {
...authStore.settings,
frontendSettings: {
...authStore.settings.frontendSettings,
customShortcuts: {},
},
},
showMessage: false,
})
}
function getAllShortcuts(): ComputedRef<ShortcutGroup[]> {
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<ShortcutAction[]> {
return computed(() => {
return Array.from(defaultShortcuts.value.values())
.filter(action => action.customizable)
})
}
function isModifier(key: string): boolean {
return ['Control', 'Meta', 'Shift', 'Alt'].includes(key)
}
return {
getShortcut,
getHotkeyString,
isCustomizable,
setCustomShortcut,
resetShortcut,
resetCategory,
resetAll,
getAllShortcuts,
getCustomizableShortcuts,
validateShortcut,
findConflicts,
}
})

View File

@@ -208,6 +208,18 @@
"expiresAt": "Expires at",
"permissions": "Permissions"
}
},
"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",
"shortcutUpdated": "Shortcut updated successfully",
"shortcutReset": "Shortcut reset to default",
"categoryReset": "Category shortcuts reset to defaults",
"allReset": "All shortcuts reset to defaults"
}
},
"deletion": {
@@ -1095,6 +1107,16 @@
"toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar",
"then": "then",
"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: "
},
"task": {
"title": "Task Page",
"done": "Mark task done / undone",

View File

@@ -0,0 +1,34 @@
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"]
}
export interface ValidationResult {
valid: boolean
error?: string // i18n key
conflicts?: ShortcutAction[]
}
// Re-export from shortcuts.ts to avoid circular dependencies
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',
}

View File

@@ -9,6 +9,7 @@ import type {Priority} from '@/constants/priorities'
import type {DateDisplay} from '@/constants/dateDisplay'
import type {TimeFormat} from '@/constants/timeFormat'
import type {IRelationKind} from '@/types/IRelationKind'
import type {ICustomShortcutsMap} from './ICustomShortcut'
export interface IFrontendSettings {
playSoundWhenDone: boolean
@@ -21,6 +22,7 @@ export interface IFrontendSettings {
dateDisplay: DateDisplay
timeFormat: TimeFormat
defaultTaskRelationType: IRelationKind
customShortcuts?: ICustomShortcutsMap
}
export interface IExtraSettingsLink {

View File

@@ -30,6 +30,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
customShortcuts: {},
}
extraSettingsLinks = {}

View File

@@ -138,6 +138,11 @@ const router = createRouter({
name: 'user.settings.apiTokens',
component: () => import('@/views/user/settings/ApiTokens.vue'),
},
{
path: '/user/settings/keyboard-shortcuts',
name: 'user.settings.keyboardShortcuts',
component: () => import('@/views/user/settings/KeyboardShortcuts.vue'),
},
{
path: '/user/settings/migrate',
name: 'migrate.start',

View File

@@ -137,6 +137,7 @@ export const useAuthStore = defineStore('auth', () => {
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
customShortcuts: {},
...newSettings.frontendSettings,
},
})

View File

@@ -108,6 +108,10 @@ const navigationItems = computed(() => {
title: t('user.settings.apiTokens.title'),
routeName: 'user.settings.apiTokens',
},
{
title: t('user.settings.keyboardShortcuts.title'),
routeName: 'user.settings.keyboardShortcuts',
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@@ -0,0 +1,163 @@
<template>
<div class="keyboard-shortcuts-settings">
<header>
<h2>{{ $t('user.settings.keyboardShortcuts.title') }}</h2>
<p class="help">
{{ $t('user.settings.keyboardShortcuts.description') }}
</p>
<BaseButton
variant="secondary"
@click="resetAll"
>
{{ $t('user.settings.keyboardShortcuts.resetAll') }}
</BaseButton>
</header>
<!-- Group by category -->
<section
v-for="group in shortcutGroups"
:key="group.category"
class="shortcut-group"
>
<div class="group-header">
<h3>{{ $t(group.title) }}</h3>
<BaseButton
v-if="hasCustomizableInGroup(group)"
variant="tertiary"
size="small"
@click="resetCategory(group.category)"
>
{{ $t('user.settings.keyboardShortcuts.resetCategory') }}
</BaseButton>
</div>
<div class="shortcuts-list">
<ShortcutEditor
v-for="shortcut in group.shortcuts"
:key="shortcut.actionId"
:shortcut="shortcut"
@update="updateShortcut"
@reset="resetShortcut"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useShortcutManager } from '@/composables/useShortcutManager'
import { ShortcutCategory, type ShortcutGroup } from '@/components/misc/keyboard-shortcuts/shortcuts'
import ShortcutEditor from '@/components/misc/keyboard-shortcuts/ShortcutEditor.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { success, error } from '@/message'
const { t } = useI18n()
const shortcutManager = useShortcutManager()
const shortcutGroups = shortcutManager.getAllShortcuts()
function hasCustomizableInGroup(group: ShortcutGroup) {
return group.shortcuts.some(s => s.customizable)
}
async function updateShortcut(actionId: string, keys: string[]) {
try {
const result = await shortcutManager.setCustomShortcut(actionId, keys)
if (!result.valid) {
error({
message: t(result.error || 'keyboardShortcuts.errors.unknown'),
})
} else {
success({
message: t('user.settings.keyboardShortcuts.shortcutUpdated'),
})
}
} catch (e) {
error(e)
}
}
async function resetShortcut(actionId: string) {
try {
await shortcutManager.resetShortcut(actionId)
success({
message: t('user.settings.keyboardShortcuts.shortcutReset'),
})
} catch (e) {
error(e)
}
}
async function resetCategory(category: ShortcutCategory) {
try {
await shortcutManager.resetCategory(category)
success({
message: t('user.settings.keyboardShortcuts.categoryReset'),
})
} catch (e) {
error(e)
}
}
async function resetAll() {
if (confirm(t('user.settings.keyboardShortcuts.resetAllConfirm'))) {
try {
await shortcutManager.resetAll()
success({
message: t('user.settings.keyboardShortcuts.allReset'),
})
} catch (e) {
error(e)
}
}
}
</script>
<style scoped>
.keyboard-shortcuts-settings {
max-width: 800px;
}
header {
margin-bottom: 2rem;
}
header h2 {
margin-bottom: 0.5rem;
}
header .help {
margin-bottom: 1rem;
color: var(--text-light);
}
.shortcut-group {
margin-bottom: 2rem;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--grey-200);
}
.group-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.shortcuts-list {
background: var(--white);
border: 1px solid var(--grey-200);
border-radius: 4px;
}
.shortcuts-list > :last-child {
border-bottom: none;
}
</style>

1159
keyboard-shortcuts-custom.md Normal file

File diff suppressed because it is too large Load Diff