feat(frontend): add configurable quick entry shortcut setting

Add desktopQuickEntryShortcut to frontend settings with a Desktop
App section in General settings, only visible when running in the
Electron app. The setting syncs to the desktop main process via
IPC whenever settings are loaded or saved.
This commit is contained in:
kolaente 2026-03-31 23:36:34 +02:00 committed by kolaente
parent c8349df8b6
commit bc47826690
8 changed files with 253 additions and 2 deletions

View File

@ -11,5 +11,6 @@ contextBridge.exposeInMainWorld('vikunjaDesktop', {
ipcRenderer.on('oauth:error', (_event, error) => callback(error))
},
refreshToken: (apiUrl, refreshToken) => ipcRenderer.invoke('oauth:refresh-token', apiUrl, refreshToken),
updateQuickEntryShortcut: (shortcut) => ipcRenderer.send('desktop:update-quick-entry-shortcut', shortcut),
isDesktop: true,
})

View File

@ -0,0 +1,208 @@
<template>
<div class="shortcut-recorder">
<button
class="input recorder-button"
:class="{'is-recording': recording}"
@click="startRecording"
@keydown.prevent="onKeyDown"
@blur="stopRecording"
>
<template v-if="recording">
<span class="recording-hint">{{ $t('user.settings.desktop.shortcutRecorderRecording') }}</span>
</template>
<template v-else-if="displayKeys.length > 0">
<kbd
v-for="(key, i) in displayKeys"
:key="i"
>
{{ key }}
</kbd>
</template>
<template v-else>
<span class="placeholder">{{ $t('user.settings.desktop.shortcutRecorderPlaceholder') }}</span>
</template>
</button>
<BaseButton
v-if="modelValue"
class="clear-button"
@click="clear"
>
<Icon icon="times" />
</BaseButton>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps<{
modelValue: string,
}>()
const emit = defineEmits<{
'update:modelValue': [value: string],
}>()
const recording = ref(false)
const isMac = navigator.platform.toUpperCase().includes('MAC')
// Map KeyboardEvent properties to Electron accelerator format
function eventToAccelerator(event: KeyboardEvent): string | null {
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {
return null
}
const parts: string[] = []
if (event.ctrlKey || event.metaKey) parts.push('CmdOrCtrl')
if (event.altKey) parts.push('Alt')
if (event.shiftKey) parts.push('Shift')
// Need at least one modifier for a global shortcut
if (parts.length === 0) return null
const key = mapKey(event)
if (key) parts.push(key)
else return null
return parts.join('+')
}
function mapKey(event: KeyboardEvent): string | null {
// Letters
if (/^Key[A-Z]$/.test(event.code)) {
return event.code.slice(3)
}
// Digits
if (/^Digit[0-9]$/.test(event.code)) {
return event.code.slice(5)
}
// Function keys
if (/^F\d{1,2}$/.test(event.code)) {
return event.code
}
// Special keys
const specialMap: Record<string, string> = {
Space: 'Space',
Enter: 'Enter',
Backspace: 'Backspace',
Delete: 'Delete',
Tab: 'Tab',
Escape: 'Escape',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Semicolon: ';',
Quote: '\'',
Backquote: '`',
Backslash: '\\',
Comma: ',',
Period: '.',
Slash: '/',
}
return specialMap[event.code] ?? null
}
// Convert Electron accelerator string to display-friendly key names
function acceleratorToDisplayKeys(accelerator: string): string[] {
if (!accelerator) return []
return accelerator.split('+').map(part => {
if (part === 'CmdOrCtrl') return isMac ? '\u2318' : 'Ctrl'
if (part === 'Shift') return isMac ? '\u21E7' : 'Shift'
if (part === 'Alt') return isMac ? '\u2325' : 'Alt'
if (part === 'Space') return '\u2423'
return part
})
}
const displayKeys = computed(() => acceleratorToDisplayKeys(props.modelValue))
function startRecording() {
recording.value = true
}
function stopRecording() {
recording.value = false
}
function onKeyDown(event: KeyboardEvent) {
if (!recording.value) {
startRecording()
}
const accelerator = eventToAccelerator(event)
if (accelerator) {
emit('update:modelValue', accelerator)
recording.value = false
}
}
function clear() {
emit('update:modelValue', '')
}
</script>
<style lang="scss" scoped>
.shortcut-recorder {
display: flex;
align-items: center;
gap: .5rem;
}
.recorder-button {
display: inline-flex;
align-items: center;
gap: .25rem;
cursor: pointer;
min-inline-size: 150px;
text-align: start;
&.is-recording {
border-color: var(--primary);
box-shadow: 0 0 0 0.125em rgba(var(--primary-rgb), 0.25);
}
}
kbd {
padding: .1rem .4rem;
border: 1px solid var(--grey-300);
background: var(--grey-100);
border-radius: 3px;
font-size: .85rem;
font-family: inherit;
line-height: 1.5;
& + kbd {
margin-inline-start: .15rem;
}
}
.recording-hint {
color: var(--primary);
font-size: .85rem;
}
.placeholder {
color: var(--grey-400);
}
.clear-button {
color: var(--grey-500);
padding: .25rem;
&:hover {
color: var(--danger);
}
}
</style>

View File

@ -135,7 +135,13 @@
"taskAndNotifications": "Projects & Tasks",
"privacy": "Privacy",
"localization": "Localization",
"appearance": "Appearance & Behavior"
"appearance": "Appearance & Behavior",
"desktop": "Desktop App"
},
"desktop": {
"quickEntryShortcut": "Quick Entry Shortcut",
"shortcutRecorderPlaceholder": "Click to set shortcut",
"shortcutRecorderRecording": "Press a key combination…"
},
"totp": {
"title": "Two Factor Authentication",

View File

@ -24,6 +24,7 @@ export interface IFrontendSettings {
alwaysShowBucketTaskCount: boolean
sidebarWidth: number | null
commentSortOrder: 'asc' | 'desc'
desktopQuickEntryShortcut: string
}
export interface IExtraSettingsLink {

View File

@ -35,6 +35,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
alwaysShowBucketTaskCount: false,
sidebarWidth: null,
commentSortOrder: 'asc',
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
}
extraSettingsLinks = {}

View File

@ -141,10 +141,15 @@ export const useAuthStore = defineStore('auth', () => {
backgroundBrightness: 100,
sidebarWidth: null,
commentSortOrder: 'asc',
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
...newSettings.frontendSettings,
},
})
// console.log('settings from auth store', {...settings.value.frontendSettings})
// Sync the quick entry shortcut to the desktop app when settings are loaded
window.vikunjaDesktop?.updateQuickEntryShortcut(
settings.value.frontendSettings.desktopQuickEntryShortcut || '',
)
}
function setAuthenticated(newAuthenticated: boolean) {

View File

@ -10,6 +10,7 @@ export interface VikunjaDesktop {
onOAuthTokens: (callback: (tokens: OAuthTokens) => void) => void
onOAuthError: (callback: (error: string) => void) => void
refreshToken: (apiUrl: string, refreshToken: string) => Promise<OAuthTokens>
updateQuickEntryShortcut: (shortcut: string) => void
}
declare global {

View File

@ -342,6 +342,30 @@
</div>
</Card>
<Card
v-if="isDesktop"
:title="$t('user.settings.sections.desktop')"
class="general-settings section-block"
:loading="loading"
>
<div class="field-group">
<div class="field">
<label
:for="`quickEntryShortcut${id}`"
class="two-col"
>
<span>
{{ $t('user.settings.desktop.quickEntryShortcut') }}
</span>
<ShortcutRecorder
v-model="settings.frontendSettings.desktopQuickEntryShortcut"
@update:modelValue="updateSettings"
/>
</label>
</div>
</div>
</Card>
<Card
:title="$t('user.settings.sections.privacy')"
class="general-settings section-block"
@ -412,9 +436,13 @@ import {PRIORITIES} from '@/constants/priorities'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KINDS} from '@/types/IRelationKind'
import {isDesktopApp} from '@/helpers/desktopAuth'
import ShortcutRecorder from '@/components/misc/ShortcutRecorder.vue'
defineOptions({name: 'UserSettingsGeneral'})
const isDesktop = isDesktopApp()
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.general.title')} - ${t('user.settings.title')}`)