feat: Add keyboard shortcuts
Implement keyboard shortcuts for the board view and individual cards Signed-off-by: Adrian Missy <adrian.missy@onewavestudios.com> Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
committed by
Julius Härtl
parent
d3750196bb
commit
05d4f529f5
279
src/components/KeyboardShortcuts.vue
Normal file
279
src/components/KeyboardShortcuts.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<!-- :style="{top:cardTop, left:cardLeft}" -->
|
||||
<div v-if="card && selector"
|
||||
ref="shortcutModal"
|
||||
v-click-outside="close"
|
||||
class="keyboard-shortcuts__modal"
|
||||
tabindex="0"
|
||||
@keydown.esc="close">
|
||||
<CardItem :card="card" />
|
||||
<DueDateSelector v-if="selector === 'due-date'" :card="card" :can-edit="true" />
|
||||
<TagSelector v-if="selector === 'tag'" :card="card" :can-edit="true" />
|
||||
<AssignmentSelector v-if="selector === 'assignment'" :card="card" :can-edit="true" />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import DueDateSelector from './card/DueDateSelector.vue'
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import { mapState } from 'vuex'
|
||||
import TagSelector from './card/TagSelector.vue'
|
||||
import AssignmentSelector from './card/AssignmentSelector.vue'
|
||||
import CardItem from './cards/CardItem.vue'
|
||||
|
||||
export default {
|
||||
name: 'KeyboardShortcuts',
|
||||
components: {
|
||||
DueDateSelector,
|
||||
TagSelector,
|
||||
AssignmentSelector,
|
||||
CardItem,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
card: null,
|
||||
cardTop: null,
|
||||
cardLeft: null,
|
||||
selector: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
board: state => state.currentBoard,
|
||||
}),
|
||||
},
|
||||
created() {
|
||||
document.addEventListener('keydown', this.onKeydown)
|
||||
subscribe('deck:card:show-assignment-selector', this.handleShowAssignemnt)
|
||||
subscribe('deck:card:show-due-date-selector', this.handleShowDueDate)
|
||||
subscribe('deck:card:show-label-selector', this.handleShowLabel)
|
||||
},
|
||||
destroyed() {
|
||||
document.removeEventListener('keydown', this.onKeydown)
|
||||
unsubscribe('deck:card:show-assignment-selector', this.handleShowAssignemnt)
|
||||
unsubscribe('deck:card:show-due-date-selector', this.handleShowDueDate)
|
||||
unsubscribe('deck:card:show-label-selector', this.handleShowLabel)
|
||||
},
|
||||
methods: {
|
||||
onKeydown(key) {
|
||||
if (OCP.Accessibility.disableKeyboardShortcuts()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(key.target.tagName) || key.target.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
// Global shortcuts (not board specific)
|
||||
if ((key.metaKey || key.ctrlKey) && key.code === 'KeyF') {
|
||||
const searchInput = document.getElementById('deck-search-input')
|
||||
if (searchInput === document.activeElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
document.getElementById('deck-search-input').focus()
|
||||
key.preventDefault()
|
||||
return true
|
||||
}
|
||||
if (key.code === 'Minus') {
|
||||
emit('deck:global:toggle-help-dialog')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.$store.state.shortcutLock || key.shiftKey || key.ctrlKey || key.altKey || key.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.$route.name === 'card' && key.code === 'Escape') {
|
||||
this.$router.push({ name: 'board' })
|
||||
return
|
||||
}
|
||||
|
||||
// Board specific shortcuts
|
||||
if (!this.board) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (key.code) {
|
||||
case 'KeyN':
|
||||
emit('deck:board:show-new-card', this.board.id)
|
||||
break
|
||||
case 'KeyF':
|
||||
emit('deck:board:toggle-filter-popover', this.board.id)
|
||||
break
|
||||
case 'KeyX':
|
||||
emit('deck:board:clear-filter', this.board.id)
|
||||
break
|
||||
case 'KeyQ':
|
||||
emit('deck:board:toggle-filter-by-me', this.board.id)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
this.keyboardFocusDown()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
this.keyboardFocusUp()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
this.keyboardFocusLeft()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
this.keyboardFocusRight()
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
key.preventDefault()
|
||||
},
|
||||
keyboardFocusDown() {
|
||||
const activeCard = document.activeElement.closest('.card')
|
||||
const cards = document.querySelectorAll('.card')
|
||||
const stacks = document.querySelectorAll('.stack')
|
||||
const index = Array.from(cards).findIndex(card => card === activeCard)
|
||||
if (index === -1) {
|
||||
cards[0]?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const currentStack = Array.from(stacks).find(stack => stack.contains(document.activeElement))
|
||||
const currentStackCards = currentStack.querySelectorAll('.card')
|
||||
const currentStackIndex = Array.from(currentStackCards).findIndex(card => card === document.activeElement)
|
||||
|
||||
if (currentStackIndex === currentStackCards.length - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
cards[index + 1]?.focus()
|
||||
cards[index + 1]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
|
||||
keyboardFocusUp() {
|
||||
const activeCard = document.activeElement.closest('.card')
|
||||
const cards = document.querySelectorAll('.card')
|
||||
const stacks = document.querySelectorAll('.stack')
|
||||
const index = Array.from(cards).findIndex(card => card === activeCard)
|
||||
if (index === -1) {
|
||||
cards[0]?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const currentStack = Array.from(stacks).find(stack => stack.contains(document.activeElement))
|
||||
const currentStackCards = currentStack.querySelectorAll('.card')
|
||||
const currentStackIndex = Array.from(currentStackCards).findIndex(card => card === document.activeElement)
|
||||
|
||||
if (currentStackIndex === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
cards[index - 1]?.focus()
|
||||
cards[index - 1]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
keyboardFocusLeft() {
|
||||
const activeCard = document.activeElement.closest('.card')
|
||||
const stacks = document.querySelectorAll('.stack')
|
||||
const currentStackIndex = Array.from(stacks).findIndex(stack => stack.contains(activeCard))
|
||||
|
||||
if (!currentStackIndex === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStack = stacks[currentStackIndex - 1] ?? stacks[0]
|
||||
|
||||
const currentCardTopOffset = document.activeElement.getBoundingClientRect().top
|
||||
|
||||
// iterate over all next stack cards and remember the one with the smallest offset
|
||||
const nextStackCards = nextStack.querySelectorAll('.card')
|
||||
let nextCard = null
|
||||
for (const card of nextStackCards) {
|
||||
const cardTopOffset = card.getBoundingClientRect().bottom
|
||||
if (cardTopOffset < currentCardTopOffset) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextCard = card
|
||||
break
|
||||
}
|
||||
|
||||
if (!nextCard) {
|
||||
nextCard = nextStackCards[nextStackCards.length - 1]
|
||||
}
|
||||
|
||||
nextCard?.focus()
|
||||
nextCard?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
keyboardFocusRight() {
|
||||
const activeCard = document.activeElement.closest('.card')
|
||||
const stacks = document.querySelectorAll('.stack')
|
||||
const currentStackIndex = Array.from(stacks).findIndex(stack => stack.contains(activeCard))
|
||||
|
||||
if (currentStackIndex === stacks.length - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStack = stacks[currentStackIndex + 1]
|
||||
|
||||
const currentCardTopOffset = document.activeElement.getBoundingClientRect().top
|
||||
|
||||
// iterate over all next stack cards and remember the one with the smallest offset
|
||||
const nextStackCards = nextStack.querySelectorAll('.card')
|
||||
let nextCard = null
|
||||
for (const card of nextStackCards) {
|
||||
const cardTopOffset = card.getBoundingClientRect().bottom
|
||||
if (cardTopOffset < currentCardTopOffset) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextCard = card
|
||||
break
|
||||
}
|
||||
|
||||
if (!nextCard) {
|
||||
nextCard = nextStackCards[nextStackCards.length - 1]
|
||||
}
|
||||
|
||||
nextCard?.focus()
|
||||
nextCard?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
handleShowDueDate({ card, element }) {
|
||||
// this.cardTop = element.getBoundingClientRect().top + 'px'
|
||||
// this.cardLeft = element.getBoundingClientRect().left + 'px'
|
||||
this.card = card
|
||||
this.selector = 'due-date'
|
||||
this.$refs.shortcutModal?.focus()
|
||||
},
|
||||
handleShowAssignemnt({ card, element }) {
|
||||
// this.cardTop = element.getBoundingClientRect().top + 'px'
|
||||
// this.cardLeft = element.getBoundingClientRect().left + 'px'
|
||||
this.card = card
|
||||
this.selector = 'assignment'
|
||||
this.$refs.shortcutModal?.focus()
|
||||
},
|
||||
handleShowLabel({ card, element }) {
|
||||
// this.cardTop = element.getBoundingClientRect().top + 'px'
|
||||
// this.cardLeft = element.getBoundingClientRect().left + 'px'
|
||||
this.card = card
|
||||
this.selector = 'tag'
|
||||
this.$refs.shortcutModal?.focus()
|
||||
},
|
||||
close() {
|
||||
this.card = null
|
||||
this.selector = null
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.keyboard-shortcuts__modal {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 0 100px 30px rgba(0, 0, 0, 0.5);
|
||||
max-width: 500px;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-rounded);
|
||||
padding: 24px 32px;
|
||||
width: 100%;
|
||||
border: 2px solid var(--color-border-maxcontrast);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user