mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
feat(ui): improve keyboard shortcuts discoverability and centralized management
This commit is contained in:
parent
b581b4f86c
commit
d7a2f4a515
9 changed files with 233 additions and 39 deletions
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
79
client/components/modals/ShortcutsModal.vue
Normal file
79
client/components/modals/ShortcutsModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue