feat: basic keyboard shortcuts (#319)
parent
69c1bd8b6a
commit
c4d8137186
|
@ -0,0 +1,119 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/* TODOs:
|
||||||
|
* - I18n
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ShortcutDef {
|
||||||
|
keys: string[]
|
||||||
|
isSequence: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutItem {
|
||||||
|
description: string
|
||||||
|
shortcut: ShortcutDef
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutItemGroup {
|
||||||
|
name: string
|
||||||
|
items: ShortcutItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutItemGroups: ShortcutItemGroup[] = [
|
||||||
|
{
|
||||||
|
name: t('magic_keys.groups.navigation.title'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
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.go_to_home'),
|
||||||
|
shortcut: { keys: ['g', 'h'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_notifications'),
|
||||||
|
shortcut: { keys: ['g', 'n'], isSequence: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('magic_keys.groups.actions.title'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.command_mode'),
|
||||||
|
shortcut: { keys: ['cmd', '/'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.compose'),
|
||||||
|
shortcut: { keys: ['c'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.favourite'),
|
||||||
|
shortcut: { keys: ['f'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.boost'),
|
||||||
|
shortcut: { keys: ['b'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.zen_mode'),
|
||||||
|
shortcut: { keys: ['z'], isSequence: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('magic_keys.groups.media.title'),
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div px-3 sm:px-5 py-2 sm:py-4 max-w-220 relative max-h-screen>
|
||||||
|
<button btn-action-icon absolute top-1 sm:top-2 right-1 sm:right-2 m1 :aria-label="$t('modals.aria_label_close')" @click="emit('close')">
|
||||||
|
<div i-ri:close-fill />
|
||||||
|
</button>
|
||||||
|
<h2 text-xl font-700 mb3>
|
||||||
|
{{ $t('magic_keys.dialog_header') }}
|
||||||
|
</h2>
|
||||||
|
<div mb2 grid grid-cols-1 md:grid-cols-3 gap-y- md:gap-x-6 lg:gap-x-8>
|
||||||
|
<div
|
||||||
|
v-for="group in shortcutItemGroups"
|
||||||
|
:key="group.name"
|
||||||
|
>
|
||||||
|
<h3 font-700 my-2 text-lg>
|
||||||
|
{{ group.name }}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.description"
|
||||||
|
flex my-1 lg:my-2 justify-between place-items-center max-w-full text-base
|
||||||
|
>
|
||||||
|
<div mr-2 break-words overflow-hidden leading-4 h-full inline-block align-middle>
|
||||||
|
{{ item.description }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template
|
||||||
|
v-for="(key, idx) in item.shortcut.keys"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<span v-if="idx !== 0" mx1 text-sm op80>{{ item.shortcut.isSequence ? $t('magic_keys.sequence_then') : '+' }}</span>
|
||||||
|
<code class="px2 md:px1.5 lg:px2 lg:px2 py0 lg:py-0.5" rounded bg-code border="px $c-border-code" shadow-sm my1 font-mono font-600>{{ key }}</code>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -7,6 +7,7 @@ import {
|
||||||
isEditHistoryDialogOpen,
|
isEditHistoryDialogOpen,
|
||||||
isErrorDialogOpen,
|
isErrorDialogOpen,
|
||||||
isFavouritedBoostedByDialogOpen,
|
isFavouritedBoostedByDialogOpen,
|
||||||
|
isKeyboardShortcutsDialogOpen,
|
||||||
isMediaPreviewOpen,
|
isMediaPreviewOpen,
|
||||||
isPreviewHelpOpen,
|
isPreviewHelpOpen,
|
||||||
isPublishDialogOpen,
|
isPublishDialogOpen,
|
||||||
|
@ -98,5 +99,8 @@ const handleFavouritedBoostedByClose = () => {
|
||||||
>
|
>
|
||||||
<StatusFavouritedBoostedBy />
|
<StatusFavouritedBoostedBy />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
|
||||||
|
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />
|
||||||
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -156,7 +156,7 @@ defineExpose({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4>
|
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4 aria-roledescription="publish-widget">
|
||||||
<template v-if="draft.editingStatus">
|
<template v-if="draft.editingStatus">
|
||||||
<div flex="~ col gap-1">
|
<div flex="~ col gap-1">
|
||||||
<div id="state-editing" text-secondary self-center>
|
<div id="state-editing" text-secondary self-center>
|
||||||
|
|
|
@ -85,6 +85,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
:class="{ 'hover:bg-active': hover }"
|
:class="{ 'hover:bg-active': hover }"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
focus:outline-none focus-visible:ring="2 primary"
|
focus:outline-none focus-visible:ring="2 primary"
|
||||||
|
aria-roledescription="status-card"
|
||||||
:lang="status.language ?? undefined"
|
:lang="status.language ?? undefined"
|
||||||
@click="onclick"
|
@click="onclick"
|
||||||
@keydown.enter="onclick"
|
@keydown.enter="onclick"
|
||||||
|
|
|
@ -30,7 +30,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 ps-3 pe-4 relative :lang="status.language ?? undefined">
|
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 ps-3 pe-4 relative :lang="status.language ?? undefined" aria-roledescription="status-details">
|
||||||
<StatusActionsMore :status="status" absolute inset-ie-2 top-2 />
|
<StatusActionsMore :status="status" absolute inset-ie-2 top-2 />
|
||||||
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
|
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
|
||||||
<AccountHoverWrapper :account="status.account">
|
<AccountHoverWrapper :account="status.account">
|
||||||
|
|
|
@ -18,6 +18,7 @@ export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mo
|
||||||
|
|
||||||
export const isSigninDialogOpen = ref(false)
|
export const isSigninDialogOpen = ref(false)
|
||||||
export const isPublishDialogOpen = ref(false)
|
export const isPublishDialogOpen = ref(false)
|
||||||
|
export const isKeyboardShortcutsDialogOpen = ref(false)
|
||||||
export const isMediaPreviewOpen = ref(false)
|
export const isMediaPreviewOpen = ref(false)
|
||||||
export const isEditHistoryDialogOpen = ref(false)
|
export const isEditHistoryDialogOpen = ref(false)
|
||||||
export const isPreviewHelpOpen = ref(isFirstVisit.value)
|
export const isPreviewHelpOpen = ref(isFirstVisit.value)
|
||||||
|
@ -139,3 +140,11 @@ export function openCommandPanel(isCommandMode = false) {
|
||||||
export function closeCommandPanel() {
|
export function closeCommandPanel() {
|
||||||
isCommandPanelOpen.value = false
|
isCommandPanelOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleKeyboardShortcuts() {
|
||||||
|
isKeyboardShortcutsDialogOpen.value = !isKeyboardShortcutsDialogOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeKeyboardShortcuts() {
|
||||||
|
isKeyboardShortcutsDialogOpen.value = false
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
// TODO: consider to allow combinations similar to useMagicKeys using proxy?
|
||||||
|
// e.g. `const magicSequence = useMagicSequence()`
|
||||||
|
// `magicSequence['Shift+Ctrl+A']`
|
||||||
|
// `const { Ctrl_A_B } = useMagicSequence()`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* source: inspired by https://github.com/vueuse/vueuse/issues/427#issuecomment-815619446
|
||||||
|
* @param keys ordered list of keys making up the sequence
|
||||||
|
*/
|
||||||
|
export function useMagicSequence(keys: string[]): ComputedRef<boolean> {
|
||||||
|
const magicKeys = useMagicKeys()
|
||||||
|
|
||||||
|
const success = ref(false)
|
||||||
|
const i = ref(0)
|
||||||
|
let down = false
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => magicKeys.current,
|
||||||
|
() => {
|
||||||
|
if (magicKeys[keys[i.value]].value && !down) {
|
||||||
|
down = true
|
||||||
|
i.value += 1
|
||||||
|
}
|
||||||
|
else if (i.value > 0 && !magicKeys[keys[i.value - 1]].value && down) {
|
||||||
|
down = false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
i.value = 0
|
||||||
|
down = false
|
||||||
|
success.value = false
|
||||||
|
}
|
||||||
|
if (i.value >= keys.length && !down) {
|
||||||
|
i.value = 0
|
||||||
|
down = false
|
||||||
|
success.value = true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
deep: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return computed(() => success.value)
|
||||||
|
}
|
|
@ -199,6 +199,31 @@
|
||||||
"remove_account": "Remove account from list",
|
"remove_account": "Remove account from list",
|
||||||
"save": "Save changes"
|
"save": "Save changes"
|
||||||
},
|
},
|
||||||
|
"magic_keys": {
|
||||||
|
"dialog_header": "Keyboard shortcuts",
|
||||||
|
"groups": {
|
||||||
|
"actions": {
|
||||||
|
"boost": "Boost",
|
||||||
|
"command_mode": "Command mode",
|
||||||
|
"compose": "Compose",
|
||||||
|
"favourite": "Favourite",
|
||||||
|
"title": "Actions",
|
||||||
|
"zen_mode": "Zen mode"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"title": "Media"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"go_to_home": "Home",
|
||||||
|
"go_to_notifications": "Notifications",
|
||||||
|
"next_status": "Next status",
|
||||||
|
"previous_status": "Previous status",
|
||||||
|
"shortcut_help": "Shortcut help",
|
||||||
|
"title": "Navigation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sequence_then": "then"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"block_account": "Block {0}",
|
"block_account": "Block {0}",
|
||||||
"block_domain": "Block domain {0}",
|
"block_domain": "Block domain {0}",
|
||||||
|
@ -229,6 +254,9 @@
|
||||||
"unmute_conversation": "Unmute this post",
|
"unmute_conversation": "Unmute this post",
|
||||||
"unpin_on_profile": "Unpin on profile"
|
"unpin_on_profile": "Unpin on profile"
|
||||||
},
|
},
|
||||||
|
"modals": {
|
||||||
|
"aria_label_close": "Close"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"back": "Go back",
|
"back": "Go back",
|
||||||
"blocked_domains": "Blocked domains",
|
"blocked_domains": "Blocked domains",
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import { useMagicSequence } from '~/composables/magickeys'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(({ $scrollToTop }) => {
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
const keys = useMagicKeys()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// disable shortcuts when focused on inputs (https://vueuse.org/core/usemagickeys/#conditionally-disable)
|
||||||
|
const activeElement = useActiveElement()
|
||||||
|
|
||||||
|
const notUsingInput = computed(() =>
|
||||||
|
activeElement.value?.tagName !== 'INPUT'
|
||||||
|
&& activeElement.value?.tagName !== 'TEXTAREA'
|
||||||
|
&& !activeElement.value?.isContentEditable,
|
||||||
|
)
|
||||||
|
const isAuthenticated = currentUser.value !== undefined
|
||||||
|
|
||||||
|
const navigateTo = (to: string | RouteLocationRaw) => {
|
||||||
|
closeKeyboardShortcuts()
|
||||||
|
$scrollToTop() // is this really required?
|
||||||
|
router.push(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
whenever(logicAnd(notUsingInput, keys['?']), toggleKeyboardShortcuts)
|
||||||
|
whenever(logicAnd(notUsingInput, keys.z), () => userSettings.value.zenMode = !userSettings.value.zenMode)
|
||||||
|
|
||||||
|
const defaultPublishDialog = () => {
|
||||||
|
const current = keys.current
|
||||||
|
// exclusive 'c' - not apply in combination
|
||||||
|
// TODO: bugfix -> create PR for vueuse, reset `current` ref on window focus|blur
|
||||||
|
if (!current.has('shift') && !current.has('meta') && !current.has('control') && !current.has('alt')) {
|
||||||
|
// TODO: is this the correct way of using openPublishDialog()?
|
||||||
|
openPublishDialog('dialog', getDefaultDraft())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whenever(logicAnd(isAuthenticated, notUsingInput, keys.c), defaultPublishDialog)
|
||||||
|
|
||||||
|
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 'h'])), () => navigateTo('/home'))
|
||||||
|
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'n'])), () => navigateTo('/notifications'))
|
||||||
|
|
||||||
|
const toggleFavouriteActiveStatus = () => {
|
||||||
|
// TODO: find a better solution than clicking buttons...
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>('[aria-roledescription=status-details]')
|
||||||
|
?.querySelector<HTMLElement>('button[aria-label=Favourite]')
|
||||||
|
?.click()
|
||||||
|
}
|
||||||
|
whenever(logicAnd(isAuthenticated, notUsingInput, keys.f), toggleFavouriteActiveStatus)
|
||||||
|
|
||||||
|
const toggleBoostActiveStatus = () => {
|
||||||
|
// TODO: find a better solution than clicking buttons...
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>('[aria-roledescription=status-details]')
|
||||||
|
?.querySelector<HTMLElement>('button[aria-label=Boost]')
|
||||||
|
?.click()
|
||||||
|
}
|
||||||
|
whenever(logicAnd(isAuthenticated, notUsingInput, keys.b), toggleBoostActiveStatus)
|
||||||
|
})
|
|
@ -1,6 +1,7 @@
|
||||||
:root {
|
:root {
|
||||||
--c-border: #eee;
|
--c-border: #eee;
|
||||||
--c-border-dark: #dccfcf;
|
--c-border-dark: #dccfcf;
|
||||||
|
--c-border-code: #ddd;
|
||||||
--c-danger: #FF3C1B;
|
--c-danger: #FF3C1B;
|
||||||
--c-danger-active: #B50900;
|
--c-danger-active: #B50900;
|
||||||
|
|
||||||
|
@ -38,6 +39,7 @@
|
||||||
--c-danger-active: #E02F00;
|
--c-danger-active: #E02F00;
|
||||||
|
|
||||||
--c-border: #222;
|
--c-border: #222;
|
||||||
|
--c-border-code: #333;
|
||||||
--c-border-dark: #545251;
|
--c-border-dark: #545251;
|
||||||
|
|
||||||
--rgb-bg-base: 17, 17, 17;
|
--rgb-bg-base: 17, 17, 17;
|
||||||
|
|
Loading…
Reference in New Issue