feat(ui): improve keyboard shortcuts discoverability and centralized management

This commit is contained in:
Tiberiu Ichim 2026-02-22 08:18:50 +02:00
parent b581b4f86c
commit d7a2f4a515
9 changed files with 233 additions and 39 deletions

View file

@ -48,6 +48,12 @@
</ui-tooltip>
</nuxt-link>
<div class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1" @click="$store.commit('globals/setShowShortcutsModal', true)">
<ui-tooltip text="Keyboard Shortcuts (?)" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="Keyboard Shortcuts" role="button">keyboard</span>
</ui-tooltip>
</div>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
@ -177,7 +183,8 @@ export default {
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
action: 'quick-match',
shortcut: this.$hotkeys.Batch.MATCH
}
]
@ -195,7 +202,8 @@ export default {
options.push({
text: 'Reset Metadata',
action: 'reset-metadata'
action: 'reset-metadata',
shortcut: this.$hotkeys.Batch.RESET
})
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
@ -211,21 +219,24 @@ export default {
if (this.userCanDelete) {
options.push({
text: this.$strings.LabelMoveToLibrary,
action: 'move-to-library'
action: 'move-to-library',
shortcut: this.$hotkeys.Batch.MOVE
})
// Merge option - only for books and if multiple selected
if (this.isBookLibrary && this.selectedMediaItems.length > 1) {
options.push({
text: this.$strings.LabelMerge,
action: 'merge'
action: 'merge',
shortcut: this.$hotkeys.Batch.MERGE
})
}
if (this.isBookLibrary) {
options.push({
text: 'Consolidate',
action: 'consolidate'
action: 'consolidate',
shortcut: this.$hotkeys.Batch.CONSOLIDATE
})
}
}
@ -534,68 +545,74 @@ export default {
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
},
getHotkeyName(e) {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return null
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) return null
var name = this.$keynames[keyCode]
if (e.ctrlKey || e.metaKey) name = 'Ctrl-' + name
if (e.altKey) name = 'Alt-' + name
if (e.shiftKey) name = 'Shift-' + name
return name
},
handleKeyDown(e) {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
return
}
const name = this.getHotkeyName(e)
if (!name) return
const ctrlOrMeta = e.ctrlKey || e.metaKey
const shift = e.shiftKey
const alt = e.altKey
if (ctrlOrMeta && e.key.toLowerCase() === 'a') {
if (name === this.$hotkeys.Batch.SELECT_ALL) {
if (this.isBookshelfPage) {
e.preventDefault()
this.$eventBus.$emit('bookshelf_select_all')
}
} else if (ctrlOrMeta && e.key.toLowerCase() === 'k') {
} else if (name === this.$hotkeys.Batch.CONSOLIDATE) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchConsolidate()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_consolidate')
}
} else if (ctrlOrMeta && !shift && e.key.toLowerCase() === 'm') {
if (this.numMediaItemsSelected > 1) {
e.preventDefault()
this.batchMerge()
}
} else if ((ctrlOrMeta && shift && e.key.toLowerCase() === 'm') || (alt && e.key.toLowerCase() === 'm')) {
} else if (name === this.$hotkeys.Batch.MERGE || name === this.$hotkeys.Batch.MOVE) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchMoveToLibrary()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_move')
if (name === this.$hotkeys.Batch.MERGE && this.numMediaItemsSelected > 1) {
this.batchMerge()
} else if (name === this.$hotkeys.Batch.MOVE) {
if (this.numMediaItemsSelected > 0) {
this.batchMoveToLibrary()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_move')
}
}
} else if (alt && e.key.toLowerCase() === 'r') {
} else if (name === this.$hotkeys.Batch.RESET) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchResetMetadata()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_reset')
}
} else if (alt && e.key.toLowerCase() === 'q') {
} else if (name === this.$hotkeys.Batch.MATCH) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchAutoMatchClick()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_match')
}
} else if (alt && this.currentLibrary?.id) {
} else if (this.currentLibrary?.id) {
const libId = this.currentLibrary.id
if (e.key.toLowerCase() === 'h') {
if (name === this.$hotkeys.Navigation.HOME) {
e.preventDefault()
this.$router.push(`/library/${libId}`)
} else if (e.key.toLowerCase() === 'l') {
} else if (name === this.$hotkeys.Navigation.LIBRARY) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf`)
} else if (e.key.toLowerCase() === 's') {
} else if (name === this.$hotkeys.Navigation.SERIES) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/series`)
} else if (e.key.toLowerCase() === 'c') {
} else if (name === this.$hotkeys.Navigation.COLLECTIONS) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/collections`)
} else if (e.key.toLowerCase() === 'a') {
} else if (name === this.$hotkeys.Navigation.AUTHORS) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/authors`)
}

View file

@ -0,0 +1,79 @@
<template>
<ui-modal v-model="show" :width="600" title="Keyboard Shortcuts" @close="close">
<div class="p-6 text-sm text-gray-200">
<div v-for="(section, index) in shortcutSections" :key="index" class="mb-6 last:mb-0">
<h3 class="text-lg font-semibold text-white mb-3">{{ section.title }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4">
<div v-for="shortcut in section.shortcuts" :key="shortcut.keys" class="flex justify-between items-center py-1 border-b border-white/10 last:border-b-0">
<span>{{ shortcut.action }}</span>
<div class="flex gap-1">
<span v-for="(key, i) in shortcut.keys.replace(/Key/g, '').split('-')" :key="i" class="px-2 py-0.5 bg-bg font-mono text-xs rounded-sm border border-gray-600 text-gray-300">
{{ key }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end p-4 border-t border-white/10">
<ui-btn @click="close">Close</ui-btn>
</div>
</ui-modal>
</template>
<script>
export default {
computed: {
shortcutSections() {
if (!this.$hotkeys) return []
return [
{
title: 'Global Navigation',
shortcuts: [
{ action: 'Home', keys: this.$hotkeys.Navigation.HOME },
{ action: 'Library', keys: this.$hotkeys.Navigation.LIBRARY },
{ action: 'Series', keys: this.$hotkeys.Navigation.SERIES },
{ action: 'Collections', keys: this.$hotkeys.Navigation.COLLECTIONS },
{ action: 'Authors', keys: this.$hotkeys.Navigation.AUTHORS },
{ action: 'Show Shortcuts Helper', keys: this.$hotkeys.Global.SHORTCUTS_HELPER }
]
},
{
title: 'Library Actions (Batch Selection)',
shortcuts: [
{ action: 'Select All Items', keys: this.$hotkeys.Batch.SELECT_ALL },
{ action: 'Consolidate', keys: this.$hotkeys.Batch.CONSOLIDATE },
{ action: 'Merge Items', keys: this.$hotkeys.Batch.MERGE },
{ action: 'Move to Library', keys: this.$hotkeys.Batch.MOVE },
{ action: 'Reset Metadata', keys: this.$hotkeys.Batch.RESET },
{ action: 'Quick Match', keys: this.$hotkeys.Batch.MATCH },
{ action: 'Clear Selection', keys: this.$hotkeys.Batch.CANCEL }
]
},
{
title: 'Item View Actions',
shortcuts: [
{ action: 'Consolidate', keys: this.$hotkeys.Item.CONSOLIDATE },
{ action: 'Move to Library', keys: this.$hotkeys.Item.MOVE },
{ action: 'Reset Metadata', keys: this.$hotkeys.Item.RESET },
{ action: 'Match', keys: this.$hotkeys.Item.MATCH }
]
}
]
},
show: {
get() {
return this.$store.state.globals.showShortcutsModal
},
set(val) {
this.$store.commit('globals/setShowShortcutsModal', val)
}
}
},
methods: {
close() {
this.show = false
}
}
}
</script>

View file

@ -14,7 +14,8 @@
<template v-for="(item, index) in items">
<template v-if="item.subitems">
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
<p class="text-left grow">{{ item.text }}</p>
<p v-if="item.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ item.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
<div
v-if="mouseoverItemIndex === index"
@ -26,12 +27,14 @@
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
<p class="text-left grow">{{ subitem.text }}</p>
<p v-if="subitem.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ subitem.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
</div>
</template>
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
<p class="text-left grow">{{ item.text }}</p>
<p v-if="item.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ item.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
</template>
</div>

View file

@ -22,6 +22,7 @@
<modals-raw-cover-preview-modal />
<modals-share-modal />
<modals-item-move-to-library-modal />
<modals-shortcuts-modal />
<modals-consolidation-conflict-modal
v-model="showConsolidationConflictModal"
:item="consolidationConflictItem"
@ -547,6 +548,13 @@ export default {
return
}
// Show Shortcuts modal prompt
if (name === this.$hotkeys.Global.SHORTCUTS_HELPER) {
this.$store.commit('globals/setShowShortcutsModal', true)
e.preventDefault()
return
}
// Modal is open
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
this.$eventBus.$emit('modal-hotkey', name)
@ -562,7 +570,7 @@ export default {
}
// Batch selecting
if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === 'Escape') {
if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === this.$hotkeys.Batch.CANCEL) {
// ESCAPE key cancels batch selection
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')

View file

@ -113,7 +113,7 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate && !isPodcast" :text="isNotConsolidated ? 'Consolidate' : 'Already Consolidated'" direction="top">
<ui-tooltip v-if="userCanUpdate && !isPodcast" :text="isNotConsolidated ? `Consolidate (${$hotkeys.Item.CONSOLIDATE.replace(/Key/g, '').replace(/-/g, '+')})` : 'Already Consolidated'" direction="top">
<ui-icon-btn icon="folder_open" class="mx-0.5" :class="isNotConsolidated ? 'text-warning' : 'opacity-50'" :disabled="!isNotConsolidated" @click="consolidate" />
</ui-tooltip>
@ -442,7 +442,8 @@ export default {
})
items.push({
text: this.$strings.ButtonMoveToLibrary,
action: 'move'
action: 'move',
shortcut: this.$hotkeys.Item.MOVE
})
items.push({
text: this.$strings.ButtonDelete,

View file

@ -54,8 +54,17 @@ const KeyNames = {
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
57: 'Key9',
65: 'KeyA',
67: 'KeyC',
72: 'KeyH',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM'
77: 'KeyM',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
191: 'Slash'
}
const Hotkeys = {
AudioPlayer: {
@ -80,6 +89,31 @@ const Hotkeys = {
PREV_PAGE: 'ArrowLeft',
CLOSE: 'Escape',
SUBMIT: 'Enter'
},
Global: {
SHORTCUTS_HELPER: 'Shift-Slash'
},
Navigation: {
HOME: 'Alt-KeyH',
LIBRARY: 'Alt-KeyL',
SERIES: 'Alt-KeyS',
COLLECTIONS: 'Alt-KeyC',
AUTHORS: 'Alt-KeyA'
},
Batch: {
SELECT_ALL: 'Alt-KeyA',
CONSOLIDATE: 'Alt-KeyK',
MERGE: 'Alt-KeyM',
MOVE: 'Shift-Alt-KeyM',
RESET: 'Alt-KeyR',
MATCH: 'Alt-KeyQ',
CANCEL: 'Escape'
},
Item: {
CONSOLIDATE: 'Alt-KeyK',
MOVE: 'Shift-Alt-KeyM',
RESET: 'Alt-KeyR',
MATCH: 'Alt-KeyQ'
}
}

View file

@ -13,6 +13,7 @@ export const state = () => ({
showShareModal: false,
showConfirmPrompt: false,
showRawCoverPreviewModal: false,
showShortcutsModal: false,
confirmPromptOptions: null,
showEditAuthorModal: false,
rssFeedEntity: null,
@ -161,6 +162,9 @@ export const mutations = {
setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val
},
setShowShortcutsModal(state, val) {
state.showShortcutsModal = val
},
setConfirmPrompt(state, options) {
state.confirmPromptOptions = options
state.showConfirmPrompt = true