fix: created keyboard shortcut composable to handle shortcut implementations

This commit is contained in:
Shariq Ansari 2025-09-02 21:58:03 +05:30
parent 11d1b3a67a
commit f2ce3165dd
3 changed files with 124 additions and 103 deletions

View File

@ -363,6 +363,7 @@ import {
validateTimeRange,
parseEventDoc,
} from '@/composables/event'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import {
TextInput,
Switch,
@ -376,7 +377,7 @@ import {
CalendarActiveEvent as activeEvent,
createDocumentResource,
} from 'frappe-ui'
import { ref, computed, watch, h, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, h } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -671,68 +672,33 @@ function updateEvent(_e) {
Object.assign(_event.value, _e)
}
// Keyboard shortcuts
function isTypingEvent(e) {
const el = e.target
if (!el) return false
const tag = el.tagName
const editable = el.isContentEditable
return (
editable ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(el.closest && el.closest('[contenteditable="true"]'))
)
}
function keydownHandler(e) {
if (!show.value) return
// Esc always closes the panel
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (!['details', 'edit'].includes(props.mode)) return
if (isTypingEvent(e)) return
// Enter in details mode -> switch to edit
if (e.key === 'Enter' && props.mode === 'details') {
e.preventDefault()
editDetails()
return
}
// Delete (no modifier) -> delete event
if (e.key === 'Delete' || e.key === 'Backspace') {
// Avoid capturing Backspace if it would navigate away when no focus
e.preventDefault()
deleteEvent()
return
}
// Cmd/Ctrl + D -> duplicate event
if (
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'd'
) {
e.preventDefault()
duplicateEvent()
}
}
onMounted(() => {
window.addEventListener('keydown', keydownHandler)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keydownHandler)
})
defineExpose({ updateEvent })
// Keyboard shortcuts
useKeyboardShortcuts({
active: () => show.value,
shortcuts: [
{ keys: 'Escape', action: () => close() },
{
keys: 'Enter',
guard: () =>
['details', 'edit'].includes(props.mode) && props.mode === 'details',
action: () => editDetails(),
},
{
keys: ['Delete', 'Backspace'],
guard: () => ['details', 'edit'].includes(props.mode),
action: () => deleteEvent(),
},
{
match: (e) =>
['details', 'edit'].includes(props.mode) &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'd',
action: () => duplicateEvent(),
},
],
})
</script>

View File

@ -0,0 +1,75 @@
import { onMounted, onBeforeUnmount, unref } from 'vue'
/**
* Generic global keyboard shortcuts composable.
*
* Usage:
* useKeyboardShortcuts({
* active: () => true, // boolean | () => boolean (reactive allowed)
* shortcuts: [
* { keys: 'Escape', action: close },
* { keys: ['Delete', 'Backspace'], action: onDelete },
* { match: e => (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd', action: duplicate }
* ],
* ignoreTyping: true // skip when focus is in input/textarea/contenteditable (default true)
* })
*/
export function useKeyboardShortcuts(options) {
const {
active = true,
shortcuts = [],
ignoreTyping = true,
target = typeof window !== 'undefined' ? window : null,
} = options || {}
function isTypingEvent(e) {
if (!ignoreTyping) return false
const el = e.target
if (!el) return false
const tag = el.tagName
return (
el.isContentEditable ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(el.closest && el.closest('[contenteditable="true"]'))
)
}
function matchShortcut(def, e) {
if (def.match) return def.match(e)
let keys = def.keys
if (!keys) return false
if (!Array.isArray(keys)) keys = [keys]
return keys.some((k) => k === e.key)
}
function handler(e) {
if (!target) return
const isActive = typeof active === 'function' ? active() : unref(active)
if (!isActive) return
if (isTypingEvent(e)) return
for (const def of shortcuts) {
if (!def) continue
if (def.guard && !def.guard(e)) continue
if (matchShortcut(def, e)) {
if (def.preventDefault !== false) e.preventDefault()
if (def.stopPropagation) e.stopPropagation()
def.action && def.action(e)
break
}
}
}
onMounted(() => {
target && target.addEventListener('keydown', handler)
})
onBeforeUnmount(() => {
target && target.removeEventListener('keydown', handler)
})
return {
stop: () => target && target.removeEventListener('keydown', handler),
}
}

View File

@ -131,6 +131,7 @@ import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import { sessionStore } from '@/stores/session'
import { globalStore } from '@/stores/global'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import {
Calendar,
createListResource,
@ -140,7 +141,7 @@ import {
CalendarActiveEvent as activeEvent,
call,
} from 'frappe-ui'
import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
import { onMounted, ref, computed } from 'vue'
const { user } = sessionStore()
const { $dialog } = globalStore()
@ -327,44 +328,23 @@ onMounted(() => {
})
// Global shortcut: Cmd/Ctrl + E -> new event (when not already creating/editing)
function isTypingEvent(e) {
const el = e.target
if (!el) return false
const tag = el.tagName
const editable = el.isContentEditable
return (
editable ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(el.closest && el.closest('[contenteditable="true"]'))
)
}
function calendarKeydown(e) {
if (isTypingEvent(e)) return
if (
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'e'
) {
if (isCreateDisabled.value) return
e.preventDefault()
newEvent({
date: dayjs().format('YYYY-MM-DD'),
time: dayjs().format('HH:mm'),
isFullDay: false,
})
}
}
onMounted(() => {
window.addEventListener('keydown', calendarKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', calendarKeydown)
useKeyboardShortcuts({
shortcuts: [
{
match: (e) =>
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'e',
guard: () => !isCreateDisabled.value,
action: () =>
newEvent({
date: dayjs().format('YYYY-MM-DD'),
time: dayjs().format('HH:mm'),
isFullDay: false,
}),
},
],
})
function showDetails(e, reloadEvent = false) {