From 97ce2fc81971b9d7d16facea526430a79adc8bee Mon Sep 17 00:00:00 2001 From: TAKAHASHI Shuuji Date: Fri, 31 May 2024 19:35:04 +0900 Subject: [PATCH] feat: keyboard status navigation with `j`/`k` (#2739) --- .../magickeys/MagickeysKeyboardShortcuts.vue | 16 +++--- plugins/magic-keys.client.ts | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/components/magickeys/MagickeysKeyboardShortcuts.vue b/components/magickeys/MagickeysKeyboardShortcuts.vue index c6f5d308..e5b1f4c9 100644 --- a/components/magickeys/MagickeysKeyboardShortcuts.vue +++ b/components/magickeys/MagickeysKeyboardShortcuts.vue @@ -32,14 +32,14 @@ const shortcutItemGroups = computed(() => [ description: t('magic_keys.groups.navigation.shortcut_help'), shortcut: { keys: ['?'], isSequence: false }, }, - // { - // description: t('magic_keys.groups.navigation.next_status'), - // shortcut: { keys: ['j'], isSequence: false }, - // }, - // { - // description: t('magic_keys.groups.navigation.previous_status'), - // shortcut: { keys: ['k'], isSequence: false }, - // }, + { + description: t('magic_keys.groups.navigation.next_status'), + shortcut: { keys: ['j'], isSequence: false }, + }, + { + description: t('magic_keys.groups.navigation.previous_status'), + shortcut: { keys: ['k'], isSequence: false }, + }, { description: t('magic_keys.groups.navigation.go_to_search'), shortcut: { keys: ['/'], isSequence: false }, diff --git a/plugins/magic-keys.client.ts b/plugins/magic-keys.client.ts index 2c885347..84128344 100644 --- a/plugins/magic-keys.client.ts +++ b/plugins/magic-keys.client.ts @@ -6,6 +6,8 @@ export default defineNuxtPlugin(({ $scrollToTop }) => { const keys = useMagicKeys() const router = useRouter() const i18n = useNuxtApp().$i18n + const { y } = useWindowScroll({ behavior: 'instant' }) + const virtualScroller = usePreferences('experimentalVirtualScroller') // disable shortcuts when focused on inputs (https://vueuse.org/core/usemagickeys/#conditionally-disable) const activeElement = useActiveElement() @@ -76,4 +78,54 @@ export default defineNuxtPlugin(({ $scrollToTop }) => { ?.click() } whenever(logicAnd(isAuthenticated, notUsingInput, keys['.']), showNewItems) + + // TODO: virtual scroller cannot load off-screen post + // that prevents focusing next post properly + // we disabled this shortcut when enabled virtual scroller + if (!virtualScroller.value) { + const statusSelector = '[aria-roledescription="status-card"]' + + // find the nearest status element id traversing up from the current active element + // `activeElement` can be some of an element within a status element + // otherwise, reach to the root `` + function getActiveStatueId(element: HTMLElement): string | undefined { + if (element.nodeName === 'HTML') + return undefined + + if (element.matches(statusSelector)) + return element.id + + return getActiveStatueId(element.parentNode as HTMLElement) + } + + function focusNextOrPreviousStatus(direction: 'next' | 'previous') { + const activeStatusId = activeElement.value ? getActiveStatueId(activeElement.value) : undefined + const nextOrPreviousStatusId = getNextOrPreviousStatusId(activeStatusId, direction) + if (nextOrPreviousStatusId) { + const status = document.getElementById(nextOrPreviousStatusId) + if (status) { + status.focus({ preventScroll: true }) + const topBarHeight = 58 + y.value += status.getBoundingClientRect().top - topBarHeight + } + } + } + + function getNextOrPreviousStatusId(currentStatusId: string | undefined, direction: 'next' | 'previous'): string | undefined { + const statusIds = [...document.querySelectorAll(statusSelector)].map(s => s.id) + if (currentStatusId === undefined) { + // if there is no selection, always focus on the first status + return statusIds[0] + } + + const currentIndex = statusIds.findIndex(id => id === currentStatusId) + const statusId = direction === 'next' + ? statusIds[Math.min(currentIndex + 1, statusIds.length)] + : statusIds[Math.max(0, currentIndex - 1)] + return statusId + } + + whenever(logicAnd(notUsingInput, keys.j), () => focusNextOrPreviousStatus('next')) + whenever(logicAnd(notUsingInput, keys.k), () => focusNextOrPreviousStatus('previous')) + } })