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:
parent
db605e0d21
commit
feea191ecf
18 changed files with 251 additions and 394 deletions
|
@ -17,6 +17,7 @@
|
||||||
"browserslist:update": "npx browserslist@latest --update-db"
|
"browserslist:update": "npx browserslist@latest --update-db"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@github/hotkey": "^1.6.0",
|
||||||
"@kyvg/vue3-notification": "2.3.4",
|
"@kyvg/vue3-notification": "2.3.4",
|
||||||
"@sentry/tracing": "6.14.3",
|
"@sentry/tracing": "6.14.3",
|
||||||
"@sentry/vue": "6.14.3",
|
"@sentry/vue": "6.14.3",
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="$store.commit('toggleMenu')"
|
@click="$store.commit('toggleMenu')"
|
||||||
class="menu-show-button"
|
class="menu-show-button"
|
||||||
@shortkey="() => $store.commit('toggleMenu')"
|
@shortkey="() => $store.commit('toggleMenu')"
|
||||||
v-shortkey="['ctrl', 'e']"
|
v-shortcut="'Control+e'"
|
||||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||||
/>
|
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed} from 'vue'
|
import {computed} from 'vue'
|
||||||
import {store} from '@/store'
|
import {store} from '@/store'
|
||||||
|
|
||||||
const menuActive = computed(() => store.menuActive)
|
const menuActive = computed(() => store.menuActive)
|
||||||
|
@ -32,6 +33,7 @@ $size: $lineWidth + 1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
$transformX: translateX(-50%);
|
$transformX: translateX(-50%);
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
|
@ -31,8 +31,7 @@
|
||||||
<a
|
<a
|
||||||
class="keyboard-shortcuts-button"
|
class="keyboard-shortcuts-button"
|
||||||
@click="showKeyboardShortcuts()"
|
@click="showKeyboardShortcuts()"
|
||||||
@shortkey="showKeyboardShortcuts()"
|
v-shortcut="'?'"
|
||||||
v-shortkey="['?']"
|
|
||||||
>
|
>
|
||||||
<icon icon="keyboard"/>
|
<icon icon="keyboard"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
class="navbar main-theme is-fixed-top"
|
class="navbar main-theme is-fixed-top"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
>
|
>
|
||||||
<router-link :to="{name: 'home'}" class="logo">
|
<router-link :to="{name: 'home'}" class="navbar-item logo">
|
||||||
<Logo width="164" height="48" />
|
<Logo width="164" height="48"/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<MenuButton class="menu-button" />
|
<MenuButton class="menu-button"/>
|
||||||
<div class="list-title" ref="listTitle" v-show="currentList.id">
|
<div class="list-title" ref="listTitle" v-show="currentList.id">
|
||||||
<template v-if="currentList.id">
|
<template v-if="currentList.id">
|
||||||
<h1
|
<h1
|
||||||
|
@ -26,8 +26,8 @@
|
||||||
<a
|
<a
|
||||||
@click="openQuickActions"
|
@click="openQuickActions"
|
||||||
class="trigger-button pr-0"
|
class="trigger-button pr-0"
|
||||||
@shortkey="openQuickActions"
|
v-shortcut="'Control+k'"
|
||||||
v-shortkey="['ctrl', 'k']"
|
:title="$t('keyboardShortcuts.quickSearch')"
|
||||||
>
|
>
|
||||||
<icon icon="search"/>
|
<icon icon="search"/>
|
||||||
</a>
|
</a>
|
||||||
|
@ -256,33 +256,33 @@ $vikunja-nav-logo-full-width: 164px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-title {
|
.list-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
$edit-icon-width: 1rem;
|
$edit-icon-width: 1rem;
|
||||||
|
|
||||||
@media screen and (min-width: $tablet) {
|
@media screen and (min-width: $tablet) {
|
||||||
// We need a fixed width for overflowing ellipsis to work
|
// We need a fixed width for overflowing ellipsis to work
|
||||||
--nav-username-width: 0;
|
--nav-username-width: 0;
|
||||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
|
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
@media screen and (max-width: $tablet) {
|
||||||
// We need a fixed width for overflowing ellipsis to work
|
// We need a fixed width for overflowing ellipsis to work
|
||||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
|
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dropdown-trigger) {
|
:deep(.dropdown-trigger) {
|
||||||
color: $grey-400;
|
color: $grey-400;
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -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>
|
|
54
src/components/misc/keyboard-shortcuts/index.vue
Normal file
54
src/components/misc/keyboard-shortcuts/index.vue
Normal 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>
|
88
src/components/misc/keyboard-shortcuts/shortcuts.js
Normal file
88
src/components/misc/keyboard-shortcuts/shortcuts.js
Normal 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="shortcuts">
|
<component :is="is" class="shortcuts">
|
||||||
<template v-for="(k, i) in keys" :key="i">
|
<template v-for="(k, i) in keys" :key="i">
|
||||||
<kbd>{{ k }}</kbd>
|
<kbd>{{ k }}</kbd>
|
||||||
<span v-if="i < keys.length - 1">+</span>
|
<span v-if="i < keys.length - 1">{{ combination }}</span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -15,6 +15,14 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
combination: {
|
||||||
|
type: String,
|
||||||
|
default: '+',
|
||||||
|
},
|
||||||
|
is: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,8 +12,7 @@
|
||||||
class="modal-container"
|
class="modal-container"
|
||||||
:class="{'has-overflow': overflow}"
|
:class="{'has-overflow': overflow}"
|
||||||
@click.self.prevent.stop="$emit('close')"
|
@click.self.prevent.stop="$emit('close')"
|
||||||
@shortkey="$emit('close')"
|
v-shortcut="'Escape'"
|
||||||
v-shortkey="['esc']"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="modal-content"
|
class="modal-content"
|
||||||
|
|
17
src/directives/shortcut.ts
Normal file
17
src/directives/shortcut.ts
Normal 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
|
10
src/helpers/isAppleDevice.ts
Normal file
10
src/helpers/isAppleDevice.ts
Normal 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)
|
||||||
|
}
|
|
@ -757,10 +757,12 @@
|
||||||
},
|
},
|
||||||
"keyboardShortcuts": {
|
"keyboardShortcuts": {
|
||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
|
"general": "General",
|
||||||
"allPages": "These shortcuts work on all pages.",
|
"allPages": "These shortcuts work on all pages.",
|
||||||
"currentPageOnly": "These shortcuts work only on the current page.",
|
"currentPageOnly": "These shortcuts work only on the current page.",
|
||||||
"toggleMenu": "Toggle The Menu",
|
"toggleMenu": "Toggle The Menu",
|
||||||
"quickSearch": "Open the search/quick action bar",
|
"quickSearch": "Open the search/quick action bar",
|
||||||
|
"then": "then",
|
||||||
"task": {
|
"task": {
|
||||||
"title": "Task Page",
|
"title": "Task Page",
|
||||||
"done": "Mark a task as done",
|
"done": "Mark a task as done",
|
||||||
|
@ -769,6 +771,13 @@
|
||||||
"dueDate": "Change the due date of this task",
|
"dueDate": "Change the due date of this task",
|
||||||
"attachment": "Add an attachment to this task",
|
"attachment": "Add an attachment to this task",
|
||||||
"related": "Modify related tasks of 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": {
|
"update": {
|
||||||
|
|
|
@ -31,8 +31,6 @@ import Notifications from '@kyvg/vue3-notification'
|
||||||
// PWA
|
// PWA
|
||||||
import './registerServiceWorker'
|
import './registerServiceWorker'
|
||||||
|
|
||||||
// Shortcuts
|
|
||||||
import shortkey from '@/plugins/shortkey'
|
|
||||||
// Vuex
|
// Vuex
|
||||||
import {store} from './store'
|
import {store} from './store'
|
||||||
// i18n
|
// i18n
|
||||||
|
@ -55,14 +53,14 @@ const app = createApp(App)
|
||||||
|
|
||||||
app.use(Notifications)
|
app.use(Notifications)
|
||||||
|
|
||||||
app.use(shortkey, {prevent: ['input', 'textarea', '.input', '[contenteditable]']})
|
|
||||||
|
|
||||||
// directives
|
// directives
|
||||||
import focus from './directives/focus'
|
import focus from './directives/focus'
|
||||||
import tooltip from './directives/tooltip'
|
import tooltip from './directives/tooltip'
|
||||||
|
import shortcut from '@/directives/shortcut'
|
||||||
|
|
||||||
app.directive('focus', focus)
|
app.directive('focus', focus)
|
||||||
app.directive('tooltip', tooltip)
|
app.directive('tooltip', tooltip)
|
||||||
|
app.directive('shortcut', shortcut)
|
||||||
|
|
||||||
// global components
|
// global components
|
||||||
import FontAwesomeIcon from './icons'
|
import FontAwesomeIcon from './icons'
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -6,21 +6,29 @@
|
||||||
<div class="switch-view-container">
|
<div class="switch-view-container">
|
||||||
<div class="switch-view">
|
<div class="switch-view">
|
||||||
<router-link
|
<router-link
|
||||||
|
v-shortcut="'g l'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToListView')"
|
||||||
:class="{'is-active': $route.name.includes('list.list')}"
|
:class="{'is-active': $route.name.includes('list.list')}"
|
||||||
:to="{ name: 'list.list', params: { listId: listId } }">
|
:to="{ name: 'list.list', params: { listId: listId } }">
|
||||||
{{ $t('list.list.title') }}
|
{{ $t('list.list.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-shortcut="'g g'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
||||||
:class="{'is-active': $route.name.includes('list.gantt')}"
|
:class="{'is-active': $route.name.includes('list.gantt')}"
|
||||||
:to="{ name: 'list.gantt', params: { listId: listId } }">
|
:to="{ name: 'list.gantt', params: { listId: listId } }">
|
||||||
{{ $t('list.gantt.title') }}
|
{{ $t('list.gantt.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-shortcut="'g t'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
||||||
:class="{'is-active': $route.name.includes('list.table')}"
|
:class="{'is-active': $route.name.includes('list.table')}"
|
||||||
:to="{ name: 'list.table', params: { listId: listId } }">
|
:to="{ name: 'list.table', params: { listId: listId } }">
|
||||||
{{ $t('list.table.title') }}
|
{{ $t('list.table.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-shortcut="'g k'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
||||||
:class="{'is-active': $route.name.includes('list.kanban')}"
|
:class="{'is-active': $route.name.includes('list.kanban')}"
|
||||||
:to="{ name: 'list.kanban', params: { listId: listId } }">
|
:to="{ name: 'list.kanban', params: { listId: listId } }">
|
||||||
{{ $t('list.kanban.title') }}
|
{{ $t('list.kanban.title') }}
|
||||||
|
|
|
@ -270,18 +270,16 @@
|
||||||
/>
|
/>
|
||||||
<x-button
|
<x-button
|
||||||
@click="setFieldActive('assignees')"
|
@click="setFieldActive('assignees')"
|
||||||
@shortkey="setFieldActive('assignees')"
|
|
||||||
type="secondary"
|
type="secondary"
|
||||||
v-shortkey="['a']">
|
v-shortcut="'a'">
|
||||||
<span class="icon is-small"><icon icon="users"/></span>
|
<span class="icon is-small"><icon icon="users"/></span>
|
||||||
{{ $t('task.detail.actions.assign') }}
|
{{ $t('task.detail.actions.assign') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button
|
<x-button
|
||||||
@click="setFieldActive('labels')"
|
@click="setFieldActive('labels')"
|
||||||
@shortkey="setFieldActive('labels')"
|
|
||||||
type="secondary"
|
type="secondary"
|
||||||
v-shortkey="['l']"
|
|
||||||
icon="tags"
|
icon="tags"
|
||||||
|
v-shortcut="'l'"
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.label') }}
|
{{ $t('task.detail.actions.label') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
@ -294,10 +292,9 @@
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button
|
<x-button
|
||||||
@click="setFieldActive('dueDate')"
|
@click="setFieldActive('dueDate')"
|
||||||
@shortkey="setFieldActive('dueDate')"
|
|
||||||
type="secondary"
|
type="secondary"
|
||||||
v-shortkey="['d']"
|
|
||||||
icon="calendar"
|
icon="calendar"
|
||||||
|
v-shortcut="'d'"
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.dueDate') }}
|
{{ $t('task.detail.actions.dueDate') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
@ -338,19 +335,17 @@
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button
|
<x-button
|
||||||
@click="setFieldActive('attachments')"
|
@click="setFieldActive('attachments')"
|
||||||
@shortkey="setFieldActive('attachments')"
|
|
||||||
type="secondary"
|
type="secondary"
|
||||||
v-shortkey="['f']"
|
|
||||||
icon="paperclip"
|
icon="paperclip"
|
||||||
|
v-shortcut="'f'"
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.attachments') }}
|
{{ $t('task.detail.actions.attachments') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button
|
<x-button
|
||||||
@click="setFieldActive('relatedTasks')"
|
@click="setFieldActive('relatedTasks')"
|
||||||
@shortkey="setFieldActive('relatedTasks')"
|
|
||||||
type="secondary"
|
type="secondary"
|
||||||
v-shortkey="['r']"
|
|
||||||
icon="sitemap"
|
icon="sitemap"
|
||||||
|
v-shortcut="'r'"
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.relatedTasks') }}
|
{{ $t('task.detail.actions.relatedTasks') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
|
|
@ -1849,6 +1849,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-5.tgz#6251e6917198362fa56510eb256cfb6aa6d30a32"
|
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-5.tgz#6251e6917198362fa56510eb256cfb6aa6d30a32"
|
||||||
integrity sha512-aNmBT4bOecrFsZTog1l6AJDQHPP3ocXV+WQ3Ogy8WZCqstB/ahfhH4CPu5i4N9Hw0MBKXqE+LX+NbUxcj8cVTw==
|
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":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.2.0"
|
version "9.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131"
|
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131"
|
||||||
|
|
Loading…
Add table
Reference in a new issue