diff --git a/frontend/src/components/Calendar/CalendarEventPanel.vue b/frontend/src/components/Calendar/CalendarEventPanel.vue index b21db429..90c30387 100644 --- a/frontend/src/components/Calendar/CalendarEventPanel.vue +++ b/frontend/src/components/Calendar/CalendarEventPanel.vue @@ -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(), + }, + ], +}) diff --git a/frontend/src/composables/useKeyboardShortcuts.js b/frontend/src/composables/useKeyboardShortcuts.js new file mode 100644 index 00000000..a3bed36f --- /dev/null +++ b/frontend/src/composables/useKeyboardShortcuts.js @@ -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), + } +} diff --git a/frontend/src/pages/Calendar.vue b/frontend/src/pages/Calendar.vue index a958586d..6d971c3a 100644 --- a/frontend/src/pages/Calendar.vue +++ b/frontend/src/pages/Calendar.vue @@ -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) {