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:
parent
c8349df8b6
commit
bc47826690
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface IFrontendSettings {
|
|||
alwaysShowBucketTaskCount: boolean
|
||||
sidebarWidth: number | null
|
||||
commentSortOrder: 'asc' | 'desc'
|
||||
desktopQuickEntryShortcut: string
|
||||
}
|
||||
|
||||
export interface IExtraSettingsLink {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
|||
alwaysShowBucketTaskCount: false,
|
||||
sidebarWidth: null,
|
||||
commentSortOrder: 'asc',
|
||||
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
|
||||
}
|
||||
extraSettingsLinks = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')}`)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue