From b946b6455f6c212e4eb5923a1d30de6fa2d869af Mon Sep 17 00:00:00 2001 From: pandadev <70103896+0PandaDEV@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:33:40 +0100 Subject: [PATCH] feat: implement keyboard context management and shortcuts for improved user interaction --- pages/index.vue | 122 +++++++++++----------- pages/settings.vue | 101 +++++++++++-------- plugins/keyboard.ts | 239 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 109 deletions(-) create mode 100644 plugins/keyboard.ts diff --git a/pages/index.vue b/pages/index.vue index 0accd10..60eb990 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -49,9 +49,12 @@ onClick: pasteSelectedItem, }" :secondary-action="{ text: 'Actions', - icon: IconsK, + icon: IconsKey, + input: 'K', showModifier: true, + onClick: toggleActionsMenu, }" /> + @@ -73,22 +76,17 @@ import type { InfoColor, InfoCode, } from "~/types/types"; -import { Key, keyboard } from "wrdu-keyboard"; -import { - selectedGroupIndex, - selectedItemIndex, - selectedElement, - useSelectedResult, -} from "~/lib/selectedResult"; import IconsEnter from "~/components/Icons/Enter.vue"; -import IconsK from "~/components/Icons/K.vue"; +import IconsKey from "~/components/Icons/Key.vue"; +import ActionsMenu from "~/components/ActionsMenu.vue"; interface GroupedHistory { label: string; items: HistoryItem[]; } -const { $history } = useNuxtApp(); +const { $history, $keyboard, $selectedResult } = useNuxtApp(); +const { selectedGroupIndex, selectedItemIndex, selectedElement, useSelectedResult } = $selectedResult; const CHUNK_SIZE = 50; const SCROLL_THRESHOLD = 100; @@ -113,9 +111,24 @@ const imageLoadError = ref(false); const imageLoading = ref(false); const pageTitle = ref(""); const pageOgImage = ref(""); +const isActionsMenuVisible = ref(false); const topBar = ref<{ searchInput: HTMLInputElement | null } | null>(null); +const toggleActionsMenu = () => { + nextTick(() => { + isActionsMenuVisible.value = !isActionsMenuVisible.value; + if (isActionsMenuVisible.value) { + $keyboard.enableContext('actionsMenu'); + } + }); +}; + +const closeActionsMenu = () => { + isActionsMenuVisible.value = false; + $keyboard.disableContext('actionsMenu'); +}; + const isSameDay = (date1: Date, date2: Date): boolean => { return ( date1.getFullYear() === date2.getFullYear() && @@ -527,70 +540,47 @@ const setupEventListeners = async (): Promise => { } focusSearchInput(); - keyboard.clear(); - keyboard.prevent.down([Key.DownArrow], () => { - selectNext(); + $keyboard.clearAll(); + $keyboard.setupAppShortcuts({ + onNavigateDown: selectNext, + onNavigateUp: selectPrevious, + onSelect: pasteSelectedItem, + onEscape: () => { + if (isActionsMenuVisible.value) { + closeActionsMenu(); + } else { + hideApp(); + } + }, + onToggleActions: toggleActionsMenu, + contextName: 'main', + priority: $keyboard.PRIORITY.LOW }); - - keyboard.prevent.down([Key.UpArrow], () => { - selectPrevious(); - }); - - keyboard.prevent.down([Key.Enter], () => { - pasteSelectedItem(); - }); - - keyboard.prevent.down([Key.Escape], () => { - hideApp(); - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.K], () => { }); - keyboard.prevent.down([Key.RightMeta, Key.K], () => { }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.K], () => { }); - keyboard.prevent.down([Key.RightControl, Key.K], () => { }); - break; - } + $keyboard.enableContext('main'); }); await listen("tauri://blur", () => { searchInput.value?.blur(); - keyboard.clear(); + $keyboard.clearAll(); + $keyboard.disableContext('main'); }); - keyboard.prevent.down([Key.DownArrow], () => { - selectNext(); + $keyboard.setupAppShortcuts({ + onNavigateDown: selectNext, + onNavigateUp: selectPrevious, + onSelect: pasteSelectedItem, + onEscape: () => { + if (isActionsMenuVisible.value) { + closeActionsMenu(); + } else { + hideApp(); + } + }, + onToggleActions: toggleActionsMenu, + contextName: 'main', + priority: $keyboard.PRIORITY.LOW }); - - keyboard.prevent.down([Key.UpArrow], () => { - selectPrevious(); - }); - - keyboard.prevent.down([Key.Enter], () => { - pasteSelectedItem(); - }); - - keyboard.prevent.down([Key.Escape], () => { - hideApp(); - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.K], () => { }); - keyboard.prevent.down([Key.RightMeta, Key.K], () => { }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.K], () => { }); - keyboard.prevent.down([Key.RightControl, Key.K], () => { }); - break; - } + $keyboard.enableContext('main'); }; const hideApp = async (): Promise => { diff --git a/pages/settings.vue b/pages/settings.vue index f67a52b..f291861 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -79,7 +79,7 @@ import { platform } from "@tauri-apps/plugin-os"; import { useRouter } from "vue-router"; import { KeyValues, KeyLabels } from "../types/keys"; import { disable, enable } from "@tauri-apps/plugin-autostart"; -import { Key, keyboard } from "wrdu-keyboard"; +import { useNuxtApp } from "#app"; import BottomBar from "../components/BottomBar.vue"; import IconsEnter from "~/components/Icons/Enter.vue"; @@ -92,7 +92,7 @@ const os = ref(""); const router = useRouter(); const showEmptyKeybindError = ref(false); const autostart = ref(false); -const { $settings } = useNuxtApp(); +const { $settings, $keyboard } = useNuxtApp(); const modifierKeySet = new Set([ KeyValues.AltLeft, @@ -174,56 +174,71 @@ const toggleAutostart = async () => { os.value = platform(); onMounted(async () => { - keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { - if (isKeybindInputFocused.value) { - onKeyDown(event); + $keyboard.setupKeybindCapture({ + onCapture: (key: string) => { + if (isKeybindInputFocused.value) { + const keyValue = key as KeyValues; + + if (isModifier(keyValue)) { + activeModifiers.add(keyValue); + } else if (!keybind.value.includes(keyValue)) { + keybind.value = keybind.value.filter((k) => isModifier(k)); + keybind.value.push(keyValue); + } + + updateKeybind(); + showEmptyKeybindError.value = false; + } + }, + onComplete: () => { + if (isKeybindInputFocused.value) { + keybindInput.value?.blur(); + } else { + router.push("/"); + } } }); - keyboard.prevent.down([Key.Escape], () => { - if (isKeybindInputFocused.value) { - keybindInput.value?.blur(); - } else { - router.push("/"); - } - }); - - switch (os.value) { - case "macos": - keyboard.prevent.down([Key.LeftMeta, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - - keyboard.prevent.down([Key.RightMeta, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - break; - - case "linux": - case "windows": - keyboard.prevent.down([Key.LeftControl, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - - keyboard.prevent.down([Key.RightControl, Key.Enter], () => { - if (!isKeybindInputFocused.value) { - saveKeybind(); - } - }); - break; + if (os.value === "macos") { + $keyboard.on("settings", [$keyboard.Key.LeftMeta, $keyboard.Key.Enter], () => { + if (!isKeybindInputFocused.value) { + saveKeybind(); + } + }, { priority: $keyboard.PRIORITY.MEDIUM }); + + $keyboard.on("settings", [$keyboard.Key.RightMeta, $keyboard.Key.Enter], () => { + if (!isKeybindInputFocused.value) { + saveKeybind(); + } + }, { priority: $keyboard.PRIORITY.MEDIUM }); + } else { + $keyboard.on("settings", [$keyboard.Key.LeftControl, $keyboard.Key.Enter], () => { + if (!isKeybindInputFocused.value) { + saveKeybind(); + } + }, { priority: $keyboard.PRIORITY.MEDIUM }); + + $keyboard.on("settings", [$keyboard.Key.RightControl, $keyboard.Key.Enter], () => { + if (!isKeybindInputFocused.value) { + saveKeybind(); + } + }, { priority: $keyboard.PRIORITY.MEDIUM }); } + $keyboard.on("settings", [$keyboard.Key.Escape], () => { + if (!isKeybindInputFocused.value) { + router.push("/"); + } + }, { priority: $keyboard.PRIORITY.MEDIUM }); + + $keyboard.enableContext("settings"); + autostart.value = (await $settings.getSetting("autostart")) === "true"; }); onUnmounted(() => { - keyboard.clear(); + $keyboard.disableContext("settings"); + $keyboard.clearAll(); }); diff --git a/plugins/keyboard.ts b/plugins/keyboard.ts new file mode 100644 index 0000000..a1f3ab0 --- /dev/null +++ b/plugins/keyboard.ts @@ -0,0 +1,239 @@ +import { Key, keyboard } from "wrdu-keyboard"; +import { platform } from "@tauri-apps/plugin-os"; + +type KeyboardHandler = (event: KeyboardEvent) => void; + +const activeContexts = new Set(); +const handlersByContext: Record< + string, + Array<{ + keys: Key[]; + callback: KeyboardHandler; + prevent: boolean; + priority?: number; + }> +> = {}; + +const PRIORITY = { + HIGH: 100, + MEDIUM: 50, + LOW: 0, +}; + +let currentOS = "windows"; +const initOS = async () => { + currentOS = await platform(); +}; + +const useKeyboard = { + PRIORITY, + + registerContext: (contextName: string) => { + if (!handlersByContext[contextName]) { + handlersByContext[contextName] = []; + } + }, + + enableContext: (contextName: string) => { + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + activeContexts.add(contextName); + + initKeyboardHandlers(); + }, + + disableContext: (contextName: string) => { + activeContexts.delete(contextName); + + initKeyboardHandlers(); + }, + + on: ( + contextName: string, + keys: Key[], + callback: KeyboardHandler, + options: { prevent?: boolean; priority?: number } = {} + ) => { + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + handlersByContext[contextName].push({ + keys, + callback, + prevent: options.prevent ?? true, + priority: options.priority ?? PRIORITY.LOW, + }); + + if (activeContexts.has(contextName)) { + initKeyboardHandlers(); + } + }, + + clearAll: () => { + keyboard.clear(); + }, + + setupAppShortcuts: (options: { + onNavigateUp?: () => void; + onNavigateDown?: () => void; + onSelect?: () => void; + onEscape?: () => void; + onToggleActions?: () => void; + contextName?: string; + priority?: number; + }) => { + const { + onNavigateUp, + onNavigateDown, + onSelect, + onEscape, + onToggleActions, + contextName = "app", + priority = PRIORITY.LOW, + } = options; + + if (!handlersByContext[contextName]) { + useKeyboard.registerContext(contextName); + } + + if (onNavigateUp) { + useKeyboard.on(contextName, [Key.UpArrow], () => onNavigateUp(), { + priority, + }); + } + + if (onNavigateDown) { + useKeyboard.on(contextName, [Key.DownArrow], () => onNavigateDown(), { + priority, + }); + } + + if (onSelect) { + useKeyboard.on(contextName, [Key.Enter], () => onSelect(), { priority }); + } + + if (onEscape) { + useKeyboard.on(contextName, [Key.Escape], () => onEscape(), { priority }); + } + + if (onToggleActions) { + const togglePriority = PRIORITY.HIGH; + + if (currentOS === "macos") { + useKeyboard.on( + contextName, + [Key.LeftMeta, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + useKeyboard.on( + contextName, + [Key.RightMeta, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + } else { + useKeyboard.on( + contextName, + [Key.LeftControl, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + useKeyboard.on( + contextName, + [Key.RightControl, Key.K], + () => onToggleActions(), + { priority: togglePriority } + ); + } + } + }, + + setupKeybindCapture: (options: { + onCapture: (key: string) => void; + onComplete: () => void; + }) => { + const { onCapture, onComplete } = options; + + keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { + if (event.code === "Escape") { + onComplete(); + return; + } + onCapture(event.code); + }); + }, +}; + +const initKeyboardHandlers = () => { + keyboard.clear(); + + let allHandlers: Array<{ keys: Key[], callback: KeyboardHandler, prevent: boolean, priority: number, contextName: string }> = []; + + for (const contextName of activeContexts) { + const handlers = handlersByContext[contextName] || []; + allHandlers = [...allHandlers, ...handlers.map(handler => ({ + ...handler, + priority: handler.priority ?? PRIORITY.LOW, + contextName + }))]; + } + + allHandlers.sort((a, b) => b.priority - a.priority); + + const handlersByKeyCombination: Record> = {}; + + allHandlers.forEach(handler => { + const keyCombo = handler.keys.join('+'); + if (!handlersByKeyCombination[keyCombo]) { + handlersByKeyCombination[keyCombo] = []; + } + handlersByKeyCombination[keyCombo].push(handler); + }); + + Object.values(handlersByKeyCombination).forEach(handlers => { + const handler = handlers[0]; + + const wrappedCallback: KeyboardHandler = (event) => { + const isMetaCombo = handler.keys.length > 1 && + (handler.keys.includes(Key.LeftMeta) || + handler.keys.includes(Key.RightMeta) || + handler.keys.includes(Key.LeftControl) || + handler.keys.includes(Key.RightControl)); + + const isNavigationKey = event.key === 'ArrowUp' || + event.key === 'ArrowDown' || + event.key === 'Enter' || + event.key === 'Escape'; + + const isInInput = event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement; + + if (isMetaCombo || isNavigationKey || !isInInput) { + handler.callback(event); + } + }; + + if (handler.prevent) { + keyboard.prevent.down(handler.keys, wrappedCallback); + } else { + keyboard.down(handler.keys, wrappedCallback); + } + }); +}; + +export default defineNuxtPlugin(async () => { + await initOS(); + + initKeyboardHandlers(); + + return { + provide: { + keyboard: { + ...useKeyboard, + Key, + }, + }, + }; +});