mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
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:
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
247
frontend/src/composables/useShortcutManager.ts
Normal file
247
frontend/src/composables/useShortcutManager.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
34
frontend/src/modelTypes/ICustomShortcut.ts
Normal file
34
frontend/src/modelTypes/ICustomShortcut.ts
Normal 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',
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
163
frontend/src/views/user/settings/KeyboardShortcuts.vue
Normal file
163
frontend/src/views/user/settings/KeyboardShortcuts.vue
Normal 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
1159
keyboard-shortcuts-custom.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user