feat: add v-shortcut directive for keyboard shortcuts (#942)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/942
Reviewed-by: dpschen <dpschen@noreply.kolaente.de>
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
This commit is contained in:
konrad 2021-11-13 20:28:29 +00:00
parent db605e0d21
commit feea191ecf
18 changed files with 251 additions and 394 deletions

View file

@ -17,6 +17,7 @@
"browserslist:update": "npx browserslist@latest --update-db"
},
"dependencies": {
"@github/hotkey": "^1.6.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.14.3",
"@sentry/vue": "6.14.3",

View file

@ -4,13 +4,14 @@
@click="$store.commit('toggleMenu')"
class="menu-show-button"
@shortkey="() => $store.commit('toggleMenu')"
v-shortkey="['ctrl', 'e']"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
/>
</template>
<script setup>
import { computed} from 'vue'
import {computed} from 'vue'
import {store} from '@/store'
const menuActive = computed(() => store.menuActive)
@ -32,6 +33,7 @@ $size: $lineWidth + 1rem;
position: relative;
$transformX: translateX(-50%);
&::before,
&::after {
content: '';

View file

@ -31,8 +31,7 @@
<a
class="keyboard-shortcuts-button"
@click="showKeyboardShortcuts()"
@shortkey="showKeyboardShortcuts()"
v-shortkey="['?']"
v-shortcut="'?'"
>
<icon icon="keyboard"/>
</a>

View file

@ -5,10 +5,10 @@
class="navbar main-theme is-fixed-top"
role="navigation"
>
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48" />
<router-link :to="{name: 'home'}" class="navbar-item logo">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button" />
<MenuButton class="menu-button"/>
<div class="list-title" ref="listTitle" v-show="currentList.id">
<template v-if="currentList.id">
<h1
@ -26,8 +26,8 @@
<a
@click="openQuickActions"
class="trigger-button pr-0"
@shortkey="openQuickActions"
v-shortkey="['ctrl', 'k']"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</a>

View file

@ -1,72 +0,0 @@
<template>
<modal @close="close()">
<card class="has-no-shadow" :title="$t('keyboardShortcuts.title')">
<div class="message is-primary">
<div class="message-body">
{{ $t('keyboardShortcuts.allPages') }}
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.toggleMenu') }}</strong>
<shortcut :keys="['ctrl', 'e']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.quickSearch') }}</strong>
<shortcut :keys="['ctrl', 'k']"/>
</p>
<h3>{{ $t('list.kanban.title') }}</h3>
<div class="message is-primary" v-if="$route.name === 'list.kanban'">
<div class="message-body">
{{ $t('keyboardShortcuts.currentPageOnly') }}
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.task.done') }}</strong>
<shortcut :keys="['ctrl', 'click']"/>
</p>
<h3>{{ $t('keyboardShortcuts.task.title') }}</h3>
<div
class="message is-primary"
v-if="$route.name === 'task.detail' || $route.name === 'task.list.detail' || $route.name === 'task.gantt.detail' || $route.name === 'task.kanban.detail' || $route.name === 'task.detail'">
<div class="message-body">
{{ $t('keyboardShortcuts.currentPageOnly') }}
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.task.assign') }}</strong>
<shortcut :keys="['a']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.labels') }}</strong>
<shortcut :keys="['l']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.dueDate') }}</strong>
<shortcut :keys="['d']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.attachment') }}</strong>
<shortcut :keys="['f']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.related') }}</strong>
<shortcut :keys="['r']"/>
</p>
</card>
</modal>
</template>
<script>
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import Shortcut from '@/components/misc/shortcut.vue'
export default {
name: 'keyboard-shortcuts',
components: {Shortcut},
methods: {
close() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
},
},
}
</script>

View file

@ -0,0 +1,54 @@
<template>
<modal @close="close()">
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">
<template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3>
<div class="message is-primary">
<div class="message-body">
{{
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
}}
</div>
</div>
<dl>
<template v-for="(sc, si) in s.shortcuts" :key="si">
<dt>{{ $t(sc.title) }}</dt>
<shortcut
is="dd"
:keys="sc.keys"
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/>
</template>
</dl>
</template>
</card>
</modal>
</template>
<script>
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import Shortcut from '@/components/misc/shortcut.vue'
import {KEYBOARD_SHORTCUTS} from './shortcuts'
export default {
name: 'keyboard-shortcuts',
components: {Shortcut},
data() {
return {
shortcuts: KEYBOARD_SHORTCUTS,
}
},
methods: {
close() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
},
},
}
</script>
<style>
dt {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,88 @@
import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
export const KEYBOARD_SHORTCUTS = [
{
title: 'keyboardShortcuts.general',
available: () => null,
shortcuts: [
{
title: 'keyboardShortcuts.toggleMenu',
keys: [ctrl, 'e'],
},
{
title: 'keyboardShortcuts.quickSearch',
keys: [ctrl, 'k'],
},
],
},
{
title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
keys: [ctrl, 'click'],
},
],
},
{
title: 'keyboardShortcuts.list.title',
available: (route) => route.name.startsWith('list.'),
shortcuts: [
{
title: 'keyboardShortcuts.list.switchToListView',
keys: ['g', 'l'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToGanttView',
keys: ['g', 'g'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToTableView',
keys: ['g', 't'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToKanbanView',
keys: ['g', 'k'],
combination: 'then',
},
],
},
{
title: 'keyboardShortcuts.task.title',
available: (route) => [
'task.detail',
'task.list.detail',
'task.gantt.detail',
'task.kanban.detail',
'task.detail',
].includes(route.name),
shortcuts: [
{
title: 'keyboardShortcuts.task.assign',
keys: ['a'],
},
{
title: 'keyboardShortcuts.task.labels',
keys: ['l'],
},
{
title: 'keyboardShortcuts.task.dueDate',
keys: ['d'],
},
{
title: 'keyboardShortcuts.task.attachment',
keys: ['f'],
},
{
title: 'keyboardShortcuts.task.related',
keys: ['r'],
},
],
},
]

View file

@ -1,10 +1,10 @@
<template>
<span class="shortcuts">
<component :is="is" class="shortcuts">
<template v-for="(k, i) in keys" :key="i">
<kbd>{{ k }}</kbd>
<span v-if="i < keys.length - 1">+</span>
<span v-if="i < keys.length - 1">{{ combination }}</span>
</template>
</span>
</component>
</template>
<script>
@ -15,6 +15,14 @@ export default {
type: Array,
required: true,
},
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
},
}
</script>

View file

@ -12,8 +12,7 @@
class="modal-container"
:class="{'has-overflow': overflow}"
@click.self.prevent.stop="$emit('close')"
@shortkey="$emit('close')"
v-shortkey="['esc']"
v-shortcut="'Escape'"
>
<div
class="modal-content"

View file

@ -0,0 +1,17 @@
import {Directive} from 'vue'
import {install, uninstall} from '@github/hotkey'
import {isAppleDevice} from '@/helpers/isAppleDevice'
const directive: Directive = {
mounted(el, {value}) {
if (isAppleDevice() && value.includes('Control')) {
value = value.replace('Control', 'Meta')
}
install(el, value)
},
beforeUnmount(el) {
uninstall(el)
},
}
export default directive

View file

@ -0,0 +1,10 @@
export const isAppleDevice = (): Boolean => {
return navigator.userAgent.includes('Mac') || [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
}

View file

@ -757,10 +757,12 @@
},
"keyboardShortcuts": {
"title": "Keyboard Shortcuts",
"general": "General",
"allPages": "These shortcuts work on all pages.",
"currentPageOnly": "These shortcuts work only on the current page.",
"toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar",
"then": "then",
"task": {
"title": "Task Page",
"done": "Mark a task as done",
@ -769,6 +771,13 @@
"dueDate": "Change the due date of this task",
"attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task"
},
"list": {
"title": "List Views",
"switchToListView": "Switch to list view",
"switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view"
}
},
"update": {

View file

@ -31,8 +31,6 @@ import Notifications from '@kyvg/vue3-notification'
// PWA
import './registerServiceWorker'
// Shortcuts
import shortkey from '@/plugins/shortkey'
// Vuex
import {store} from './store'
// i18n
@ -55,14 +53,14 @@ const app = createApp(App)
app.use(Notifications)
app.use(shortkey, {prevent: ['input', 'textarea', '.input', '[contenteditable]']})
// directives
import focus from './directives/focus'
import tooltip from './directives/tooltip'
import shortcut from '@/directives/shortcut'
app.directive('focus', focus)
app.directive('tooltip', tooltip)
app.directive('shortcut', shortcut)
// global components
import FontAwesomeIcon from './icons'

View file

@ -1,78 +0,0 @@
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
const MODIFIER_KEYS = ['shift', 'ctrl', 'meta', 'alt']
const SHORT_CUT_INDEX = [
{ key: 'ArrowUp', value: 'arrowup' },
{ key: 'ArrowLeft', value: 'arrowlef' },
{ key: 'ArrowRight', value: 'arrowright' },
{ key: 'ArrowDown', value: 'arrowdown' },
{ key: 'AltGraph', value: 'altgraph' },
{ key: 'Escape', value: 'esc' },
{ key: 'Enter', value: 'enter' },
{ key: 'Tab', value: 'tab' },
{ key: ' ', value: 'space' },
{ key: 'PageUp', value: 'pagup' },
{ key: 'PageDown', value: 'pagedow' },
{ key: 'Home', value: 'home' },
{ key: 'End', value: 'end' },
{ key: 'Delete', value: 'del' },
{ key: 'Backspace', value: 'bacspace' },
{ key: 'Insert', value: 'insert' },
{ key: 'NumLock', value: 'numlock' },
{ key: 'CapsLock', value: 'capslock' },
{ key: 'Pause', value: 'pause' },
{ key: 'ContextMenu', value: 'cotextmenu' },
{ key: 'ScrollLock', value: 'scrolllock' },
{ key: 'BrowserHome', value: 'browserhome' },
{ key: 'MediaSelect', value: 'mediaselect' },
]
export function encodeKey(pKey) {
const shortKey = {}
MODIFIER_KEYS.forEach((key) => {
shortKey[`${key}Key`] = pKey.includes(key)
})
let indexedKeys = createShortcutIndex(shortKey)
const vKey = pKey.filter(
(item) => !MODIFIER_KEYS.includes(item),
)
indexedKeys += vKey.join('')
return indexedKeys
}
function createShortcutIndex(pKey) {
let k = ''
MODIFIER_KEYS.forEach((key) => {
if (pKey.key === capitalizeFirstLetter(key) || pKey[`${key}Key`]) {
k += key
}
})
SHORT_CUT_INDEX.forEach(({ key, value }) => {
if (pKey.key === key) {
k += value
}
})
if (
(pKey.key && pKey.key !== ' ' && pKey.key.length === 1) ||
/F\d{1,2}|\//g.test(pKey.key)
) {
k += pKey.key.toLowerCase()
}
return k
}
export { createShortcutIndex as decodeKey }
export function parseValue(value) {
value = typeof value === 'string' ? JSON.parse(value.replace(/'/gi, '"')) : value
return value instanceof Array ? { '': value } : value
}

View file

@ -1,186 +0,0 @@
import { parseValue, decodeKey, encodeKey } from './helpers'
let mapFunctions = {}
let objAvoided = []
let elementAvoided = []
let keyPressed = false
function dispatchShortkeyEvent(pKey) {
const e = new CustomEvent('shortkey', { bubbles: false })
if (mapFunctions[pKey].key) {
e.srcKey = mapFunctions[pKey].key
}
const elm = mapFunctions[pKey].el
if (!mapFunctions[pKey].propagte) {
elm[elm.length - 1].dispatchEvent(e)
} else {
elm.forEach((elmItem) => elmItem.dispatchEvent(e))
}
}
function keyDown(pKey) {
if (
(!mapFunctions[pKey].once && !mapFunctions[pKey].push) ||
(mapFunctions[pKey].push && !keyPressed)
) {
dispatchShortkeyEvent(pKey)
}
}
function fillMappingFunctions(
mappingFunctions,
{ b, push, once, focus, propagte, el },
) {
for (let key in b) {
const k = encodeKey(b[key])
const propagated = mappingFunctions[k] && mappingFunctions[k].propagte
const elm =
mappingFunctions[k] && mappingFunctions[k].el
? mappingFunctions[k].el
: []
elm.push(el)
mappingFunctions[k] = {
push,
once,
focus,
key,
propagte: propagated || propagte,
el: elm,
}
}
}
function bindValue(value, el, binding, vnode) {
const { modifiers } = binding
const push = !!modifiers.push
const avoid = !!modifiers.avoid
const focus = !modifiers.focus
const once = !!modifiers.once
const propagte = !!modifiers.propagte
if (avoid) {
objAvoided = objAvoided.filter((itm) => !itm === el)
objAvoided.push(el)
} else {
fillMappingFunctions(mapFunctions, {
b: value,
push,
once,
focus,
propagte,
el: vnode.el,
})
}
}
function unbindValue(value, el) {
for (let key in value) {
const k = encodeKey(value[key])
const idxElm = mapFunctions[k].el.indexOf(el)
if (mapFunctions[k].el.length > 1 && idxElm > -1) {
mapFunctions[k].el.splice(idxElm, 1)
} else {
delete mapFunctions[k]
}
}
}
function availableElement(decodedKey) {
const objectIsAvoided = !!objAvoided.find(
(r) => r === document.activeElement,
)
const filterAvoided = !!elementAvoided.find(
(selector) =>
document.activeElement && document.activeElement.matches(selector),
)
return !!mapFunctions[decodedKey] && !(objectIsAvoided || filterAvoided)
}
function keyDownListener(pKey) {
const decodedKey = decodeKey(pKey)
// Check avoidable elements
if (!availableElement(decodedKey)) {
return
}
if (!mapFunctions[decodedKey].propagte) {
pKey.preventDefault()
pKey.stopPropagation()
}
if (mapFunctions[decodedKey].focus) {
keyDown(decodedKey)
keyPressed = true
} else if (!keyPressed) {
const elm = mapFunctions[decodedKey].el
elm[elm.length - 1].focus()
keyPressed = true
}
}
function keyUpListener(pKey) {
const decodedKey = decodeKey(pKey)
if (!availableElement(decodedKey)) {
keyPressed = false
return
}
if (!mapFunctions[decodedKey].propagte) {
pKey.preventDefault()
pKey.stopPropagation()
}
if (mapFunctions[decodedKey].once || mapFunctions[decodedKey].push) {
dispatchShortkeyEvent(decodedKey)
}
keyPressed = false
}
// register key presses that happen before mounting of directive
// if (process?.env?.NODE_ENV !== 'test') {
// (() => {
document.addEventListener('keydown', keyDownListener, true)
document.addEventListener('keyup', keyUpListener, true)
// })()
// }
function install(app, options) {
elementAvoided = [...(options && options.prevent ? options.prevent : [])]
app.directive('shortkey', {
beforeMount(el, binding, vnode) {
// Mapping the commands
const value = parseValue(binding.value)
bindValue(value, el, binding, vnode)
},
updated(el, binding, vnode) {
const oldValue = parseValue(binding.oldValue)
unbindValue(oldValue, el)
const newValue = parseValue(binding.value)
bindValue(newValue, el, binding, vnode)
},
unmounted(el, binding) {
const value = parseValue(binding.value)
unbindValue(value, el)
},
})
}
export default {
install,
encodeKey,
decodeKey,
keyDown,
}

View file

@ -6,21 +6,29 @@
<div class="switch-view-container">
<div class="switch-view">
<router-link
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
:class="{'is-active': $route.name.includes('list.list')}"
:to="{ name: 'list.list', params: { listId: listId } }">
{{ $t('list.list.title') }}
</router-link>
<router-link
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
:class="{'is-active': $route.name.includes('list.gantt')}"
:to="{ name: 'list.gantt', params: { listId: listId } }">
{{ $t('list.gantt.title') }}
</router-link>
<router-link
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
:class="{'is-active': $route.name.includes('list.table')}"
:to="{ name: 'list.table', params: { listId: listId } }">
{{ $t('list.table.title') }}
</router-link>
<router-link
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
:class="{'is-active': $route.name.includes('list.kanban')}"
:to="{ name: 'list.kanban', params: { listId: listId } }">
{{ $t('list.kanban.title') }}

View file

@ -270,18 +270,16 @@
/>
<x-button
@click="setFieldActive('assignees')"
@shortkey="setFieldActive('assignees')"
type="secondary"
v-shortkey="['a']">
v-shortcut="'a'">
<span class="icon is-small"><icon icon="users"/></span>
{{ $t('task.detail.actions.assign') }}
</x-button>
<x-button
@click="setFieldActive('labels')"
@shortkey="setFieldActive('labels')"
type="secondary"
v-shortkey="['l']"
icon="tags"
v-shortcut="'l'"
>
{{ $t('task.detail.actions.label') }}
</x-button>
@ -294,10 +292,9 @@
</x-button>
<x-button
@click="setFieldActive('dueDate')"
@shortkey="setFieldActive('dueDate')"
type="secondary"
v-shortkey="['d']"
icon="calendar"
v-shortcut="'d'"
>
{{ $t('task.detail.actions.dueDate') }}
</x-button>
@ -338,19 +335,17 @@
</x-button>
<x-button
@click="setFieldActive('attachments')"
@shortkey="setFieldActive('attachments')"
type="secondary"
v-shortkey="['f']"
icon="paperclip"
v-shortcut="'f'"
>
{{ $t('task.detail.actions.attachments') }}
</x-button>
<x-button
@click="setFieldActive('relatedTasks')"
@shortkey="setFieldActive('relatedTasks')"
type="secondary"
v-shortkey="['r']"
icon="sitemap"
v-shortcut="'r'"
>
{{ $t('task.detail.actions.relatedTasks') }}
</x-button>

View file

@ -1849,6 +1849,11 @@
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-5.tgz#6251e6917198362fa56510eb256cfb6aa6d30a32"
integrity sha512-aNmBT4bOecrFsZTog1l6AJDQHPP3ocXV+WQ3Ogy8WZCqstB/ahfhH4CPu5i4N9Hw0MBKXqE+LX+NbUxcj8cVTw==
"@github/hotkey@^1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@github/hotkey/-/hotkey-1.6.0.tgz#64da82a18ac11d24f9d5d61575a0a58ba101b2ab"
integrity sha512-pm/xBWrn0yyD2GFqPUBH4ne7mdpdrnmdHxwKV0hN/jnSKj01RTPxau65SAvBvWD1Pf2VRv/OEE4H9ECORBHGdg==
"@hapi/hoek@^9.0.0":
version "9.2.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131"