feat: refactor existing shortcuts to use shortcut manager (Phase 2)

- Update v-shortcut directive to support both old format (keys) and new format (actionIds)
- Refactor general shortcuts: MenuButton (Ctrl+E), OpenQuickActions (Ctrl+K), ContentAuth (Shift+?)
- Refactor navigation shortcuts: all g+key sequences in Navigation.vue now use actionIds
- Refactor task detail shortcuts: all 14 task shortcuts + Ctrl+S now use shortcut manager
- Update help modal to show effective shortcuts and link to settings page
- Add showHelp shortcut action for Shift+? keyboard shortcut help

All existing shortcuts now use the shortcut manager system and will respect user customizations.
The help modal displays current effective shortcuts (default or customized) and provides
a direct link to the keyboard shortcuts settings page for easy customization.
This commit is contained in:
kolaente 2025-11-27 17:06:52 +01:00
parent 94e334fa71
commit c16a2cf362
9 changed files with 125 additions and 32 deletions

View File

@ -55,7 +55,7 @@
</Modal>
<BaseButton
v-shortcut="'Shift+?'"
v-shortcut="'.general.showHelp'"
class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()"
>

View File

@ -1,6 +1,6 @@
<template>
<BaseButton
v-shortcut="'Mod+e'"
v-shortcut="'.general.toggleMenu'"
class="menu-show-button"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"

View File

@ -17,7 +17,7 @@
<menu class="menu-list other-menu-items">
<li>
<RouterLink
v-shortcut="'g o'"
v-shortcut="'.navigation.goToOverview'"
:to="{ name: 'home'}"
>
<span class="menu-item-icon icon">
@ -28,7 +28,7 @@
</li>
<li>
<RouterLink
v-shortcut="'g u'"
v-shortcut="'.navigation.goToUpcoming'"
:to="{ name: 'tasks.range'}"
>
<span class="menu-item-icon icon">
@ -39,7 +39,7 @@
</li>
<li>
<RouterLink
v-shortcut="'g p'"
v-shortcut="'.navigation.goToProjects'"
:to="{ name: 'projects.index'}"
>
<span class="menu-item-icon icon">
@ -50,7 +50,7 @@
</li>
<li>
<RouterLink
v-shortcut="'g a'"
v-shortcut="'.navigation.goToLabels'"
:to="{ name: 'labels.index'}"
>
<span class="menu-item-icon icon">
@ -61,7 +61,7 @@
</li>
<li>
<RouterLink
v-shortcut="'g m'"
v-shortcut="'.navigation.goToTeams'"
:to="{ name: 'teams.index'}"
>
<span class="menu-item-icon icon">

View File

@ -3,19 +3,19 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {onBeforeUnmount, onMounted} from 'vue'
import {eventToHotkeyString} from '@github/hotkey'
import {isAppleDevice} from '@/helpers/isAppleDevice'
import {useShortcutManager} from '@/composables/useShortcutManager'
const baseStore = useBaseStore()
const shortcutManager = useShortcutManager()
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function openQuickActionsViaHotkey(event) {
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
// On macOS, use Cmd+K (Meta+K), on other platforms use Ctrl+K (Control+K)
const expectedHotkey = isAppleDevice() ? 'Meta+k' : 'Control+k'
const expectedHotkey = shortcutManager.getHotkeyString('general.quickSearch')
if (hotkeyString !== expectedHotkey) return
event.preventDefault()
openQuickActions()

View File

@ -7,6 +7,18 @@
:show-close="true"
@close="close()"
>
<template #header>
<div class="help-header">
<h2>{{ $t('keyboardShortcuts.title') }}</h2>
<RouterLink
:to="{ name: 'user.settings.keyboardShortcuts' }"
class="button is-small"
@click="close()"
>
{{ $t('keyboardShortcuts.customizeShortcuts') }}
</RouterLink>
</div>
</template>
<template
v-for="(s, i) in shortcuts"
:key="i"
@ -39,27 +51,44 @@
<Shortcut
is="dd"
class="shortcut-keys"
:keys="sc.keys"
:keys="getEffectiveKeys(sc)"
:combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
/>
</template>
</dl>
</template>
<p class="help-text">
{{ $t('keyboardShortcuts.helpText') }}
</p>
</Card>
</Modal>
</template>
<script lang="ts" setup>
import {useBaseStore} from '@/stores/base'
import {useShortcutManager} from '@/composables/useShortcutManager'
import Shortcut from '@/components/misc/Shortcut.vue'
import Message from '@/components/misc/Message.vue'
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
import type {ShortcutAction} from './shortcuts'
const shortcutManager = useShortcutManager()
function close() {
useBaseStore().setKeyboardShortcutsActive(false)
}
function getEffectiveKeys(shortcut: ShortcutAction): string[] {
// For shortcuts with actionId, get effective keys from shortcut manager
if (shortcut.actionId) {
return shortcutManager.getShortcut(shortcut.actionId) || shortcut.keys
}
// Fallback to default keys for backwards compatibility
return shortcut.keys
}
</script>
<style scoped>
@ -67,6 +96,25 @@ function close() {
text-align: start;
}
.help-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.help-header h2 {
margin: 0;
}
.help-text {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--grey-200);
color: var(--text-light);
font-size: 0.875rem;
}
.message:not(:last-child) {
margin-block-end: 1rem;
}

View File

@ -58,6 +58,14 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
contexts: ['*'],
category: ShortcutCategory.GENERAL,
},
{
actionId: 'general.showHelp',
title: 'keyboardShortcuts.showHelp',
keys: ['shift', '?'],
customizable: true,
contexts: ['*'],
category: ShortcutCategory.GENERAL,
},
],
},
{

View File

@ -1,12 +1,44 @@
import type {Directive} from 'vue'
import {install, uninstall} from '@github/hotkey'
import {useShortcutManager} from '@/composables/useShortcutManager'
const directive = <Directive<HTMLElement,string>>{
mounted(el, {value}) {
if(value === '') {
return
}
install(el, value)
// Support both old format (direct keys) and new format (actionId)
const shortcutManager = useShortcutManager()
const hotkeyString = value.startsWith('.')
? shortcutManager.getHotkeyString(value.slice(1)) // New format: actionId (remove leading dot)
: value // Old format: direct keys (backwards compat)
if (!hotkeyString) return
install(el, hotkeyString)
// Store for cleanup and updates
el.dataset.shortcutActionId = value
},
updated(el, {value, oldValue}) {
if (value === oldValue) return
// Reinstall with new shortcut
uninstall(el)
if(value === '') {
return
}
const shortcutManager = useShortcutManager()
const hotkeyString = value.startsWith('.')
? shortcutManager.getHotkeyString(value.slice(1))
: value
if (!hotkeyString) return
install(el, hotkeyString)
el.dataset.shortcutActionId = value
},
beforeUnmount(el) {
uninstall(el)

View File

@ -1106,6 +1106,7 @@
"somePagesOnly": "These shortcuts work only on some pages.",
"toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar",
"showHelp": "Show keyboard shortcuts help",
"then": "then",
"fixed": "Fixed",
"pressKeys": "Press keys...",

View File

@ -37,14 +37,14 @@
>
<a
v-if="router.options.history.state?.back?.includes('/projects/'+p.id+'/') || false"
v-shortcut="p.id === project?.id ? 'u' : ''"
v-shortcut="p.id === project?.id ? '.task.openProject' : ''"
@click="router.back()"
>
{{ getProjectTitle(p) }}
</a>
<RouterLink
v-else
v-shortcut="p.id === project?.id ? 'u' : ''"
v-shortcut="p.id === project?.id ? '.task.openProject' : ''"
:to="{ name: 'project.index', params: { projectId: p.id } }"
>
{{ getProjectTitle(p) }}
@ -416,7 +416,7 @@
>
<template v-if="canWrite">
<XButton
v-shortcut="'t'"
v-shortcut="'.task.markDone'"
:class="{'is-success': !task.done}"
:shadow="task.done"
class="is-outlined has-no-border"
@ -433,7 +433,7 @@
@update:modelValue="sub => task.subscription = sub"
/>
<XButton
v-shortcut="'s'"
v-shortcut="'.task.toggleFavorite'"
variant="secondary"
:icon="task.isFavorite ? 'star' : ['far', 'star']"
@click="toggleFavorite"
@ -446,7 +446,7 @@
<span class="action-heading">{{ $t('task.detail.organization') }}</span>
<XButton
v-shortcut="'l'"
v-shortcut="'.task.labels'"
variant="secondary"
icon="tags"
@click="setFieldActive('labels')"
@ -454,7 +454,7 @@
{{ $t('task.detail.actions.label') }}
</XButton>
<XButton
v-shortcut="'p'"
v-shortcut="'.task.priority'"
variant="secondary"
icon="exclamation-circle"
@click="setFieldActive('priority')"
@ -469,7 +469,7 @@
{{ $t('task.detail.actions.percentDone') }}
</XButton>
<XButton
v-shortcut="'c'"
v-shortcut="'.task.color'"
variant="secondary"
icon="fill-drip"
:icon-color="color"
@ -481,7 +481,7 @@
<span class="action-heading">{{ $t('task.detail.management') }}</span>
<XButton
v-shortcut="'a'"
v-shortcut="'.task.assign'"
v-cy="'taskDetail.assign'"
variant="secondary"
icon="users"
@ -490,7 +490,7 @@
{{ $t('task.detail.actions.assign') }}
</XButton>
<XButton
v-shortcut="'f'"
v-shortcut="'.task.attachment'"
variant="secondary"
icon="paperclip"
@click="setFieldActive('attachments')"
@ -498,7 +498,7 @@
{{ $t('task.detail.actions.attachments') }}
</XButton>
<XButton
v-shortcut="'r'"
v-shortcut="'.task.related'"
variant="secondary"
icon="sitemap"
@click="setRelatedTasksActive()"
@ -506,7 +506,7 @@
{{ $t('task.detail.actions.relatedTasks') }}
</XButton>
<XButton
v-shortcut="'m'"
v-shortcut="'.task.move'"
variant="secondary"
icon="list"
@click="setFieldActive('moveProject')"
@ -517,7 +517,7 @@
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
<XButton
v-shortcut="'d'"
v-shortcut="'.task.dueDate'"
variant="secondary"
icon="calendar"
@click="setFieldActive('dueDate')"
@ -539,7 +539,7 @@
{{ $t('task.detail.actions.endDate') }}
</XButton>
<XButton
v-shortcut="reminderShortcut"
v-shortcut="'.task.reminder'"
variant="secondary"
:icon="['far', 'clock']"
@click="setFieldActive('reminders')"
@ -554,7 +554,7 @@
{{ $t('task.detail.actions.repeatAfter') }}
</XButton>
<XButton
v-shortcut="'Shift+Delete'"
v-shortcut="'.task.delete'"
icon="trash-alt"
:shadow="false"
class="is-danger is-outlined has-no-border"
@ -604,6 +604,7 @@ import {useI18n} from 'vue-i18n'
import {unrefElement, useMediaQuery} from '@vueuse/core'
import {klona} from 'klona/lite'
import {eventToHotkeyString} from '@github/hotkey'
import {useShortcutManager} from '@/composables/useShortcutManager'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
@ -640,7 +641,7 @@ import Reactions from '@/components/input/Reactions.vue'
import {uploadFile} from '@/helpers/attachments'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {isAppleDevice} from '@/helpers/isAppleDevice'
// isAppleDevice no longer needed - reminder shortcut handled by shortcut manager
import {scrollIntoView} from '@/helpers/scrollIntoView'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import {playPopSound} from '@/helpers/playPop'
@ -687,9 +688,12 @@ useTitle(taskTitle)
function saveTaskViaHotkey(event) {
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return
event.preventDefault()
const shortcutManager = useShortcutManager()
const expectedHotkey = shortcutManager.getHotkeyString('task.save')
if (hotkeyString !== expectedHotkey) return
event.preventDefault()
saveTask()
}
@ -713,7 +717,7 @@ const lastProjectOrTaskProject = computed(() => lastProject.value ?? project.val
// Use Shift+R on macOS (Alt+R produces special characters depending on keyboard layout)
// Use Alt+r on other platforms
const reminderShortcut = computed(() => isAppleDevice() ? 'Shift+R' : 'Alt+r')
// Reminder shortcut is now handled by shortcut manager
onMounted(() => {
document.addEventListener('keydown', saveTaskViaHotkey)