feat: implement keyboard context management and shortcuts for improved user interaction

This commit is contained in:
pandadev 2025-03-16 20:33:40 +01:00
parent af64b77b74
commit b946b6455f
No known key found for this signature in database
GPG key ID: C39629DACB8E762F
3 changed files with 353 additions and 109 deletions

View file

@ -49,9 +49,12 @@
onClick: pasteSelectedItem, onClick: pasteSelectedItem,
}" :secondary-action="{ }" :secondary-action="{
text: 'Actions', text: 'Actions',
icon: IconsK, icon: IconsKey,
input: 'K',
showModifier: true, showModifier: true,
onClick: toggleActionsMenu,
}" /> }" />
<ActionsMenu :selected-item="selectedItem" :is-visible="isActionsMenuVisible" @close="closeActionsMenu" />
</main> </main>
</template> </template>
@ -73,22 +76,17 @@ import type {
InfoColor, InfoColor,
InfoCode, InfoCode,
} from "~/types/types"; } 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 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 { interface GroupedHistory {
label: string; label: string;
items: HistoryItem[]; items: HistoryItem[];
} }
const { $history } = useNuxtApp(); const { $history, $keyboard, $selectedResult } = useNuxtApp();
const { selectedGroupIndex, selectedItemIndex, selectedElement, useSelectedResult } = $selectedResult;
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
const SCROLL_THRESHOLD = 100; const SCROLL_THRESHOLD = 100;
@ -113,9 +111,24 @@ const imageLoadError = ref<boolean>(false);
const imageLoading = ref<boolean>(false); const imageLoading = ref<boolean>(false);
const pageTitle = ref<string>(""); const pageTitle = ref<string>("");
const pageOgImage = ref<string>(""); const pageOgImage = ref<string>("");
const isActionsMenuVisible = ref<boolean>(false);
const topBar = ref<{ searchInput: HTMLInputElement | null } | null>(null); 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 => { const isSameDay = (date1: Date, date2: Date): boolean => {
return ( return (
date1.getFullYear() === date2.getFullYear() && date1.getFullYear() === date2.getFullYear() &&
@ -527,70 +540,47 @@ const setupEventListeners = async (): Promise<void> => {
} }
focusSearchInput(); focusSearchInput();
keyboard.clear(); $keyboard.clearAll();
keyboard.prevent.down([Key.DownArrow], () => { $keyboard.setupAppShortcuts({
selectNext(); onNavigateDown: selectNext,
onNavigateUp: selectPrevious,
onSelect: pasteSelectedItem,
onEscape: () => {
if (isActionsMenuVisible.value) {
closeActionsMenu();
} else {
hideApp();
}
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.LOW
}); });
$keyboard.enableContext('main');
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;
}
}); });
await listen("tauri://blur", () => { await listen("tauri://blur", () => {
searchInput.value?.blur(); searchInput.value?.blur();
keyboard.clear(); $keyboard.clearAll();
$keyboard.disableContext('main');
}); });
keyboard.prevent.down([Key.DownArrow], () => { $keyboard.setupAppShortcuts({
selectNext(); onNavigateDown: selectNext,
onNavigateUp: selectPrevious,
onSelect: pasteSelectedItem,
onEscape: () => {
if (isActionsMenuVisible.value) {
closeActionsMenu();
} else {
hideApp();
}
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.LOW
}); });
$keyboard.enableContext('main');
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;
}
}; };
const hideApp = async (): Promise<void> => { const hideApp = async (): Promise<void> => {

View file

@ -79,7 +79,7 @@ import { platform } from "@tauri-apps/plugin-os";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { KeyValues, KeyLabels } from "../types/keys"; import { KeyValues, KeyLabels } from "../types/keys";
import { disable, enable } from "@tauri-apps/plugin-autostart"; 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 BottomBar from "../components/BottomBar.vue";
import IconsEnter from "~/components/Icons/Enter.vue"; import IconsEnter from "~/components/Icons/Enter.vue";
@ -92,7 +92,7 @@ const os = ref("");
const router = useRouter(); const router = useRouter();
const showEmptyKeybindError = ref(false); const showEmptyKeybindError = ref(false);
const autostart = ref(false); const autostart = ref(false);
const { $settings } = useNuxtApp(); const { $settings, $keyboard } = useNuxtApp();
const modifierKeySet = new Set([ const modifierKeySet = new Set([
KeyValues.AltLeft, KeyValues.AltLeft,
@ -174,56 +174,71 @@ const toggleAutostart = async () => {
os.value = platform(); os.value = platform();
onMounted(async () => { onMounted(async () => {
keyboard.prevent.down([Key.All], (event: KeyboardEvent) => { $keyboard.setupKeybindCapture({
if (isKeybindInputFocused.value) { onCapture: (key: string) => {
onKeyDown(event); 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 (os.value === "macos") {
if (isKeybindInputFocused.value) { $keyboard.on("settings", [$keyboard.Key.LeftMeta, $keyboard.Key.Enter], () => {
keybindInput.value?.blur(); if (!isKeybindInputFocused.value) {
} else { 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("/"); router.push("/");
} }
}); }, { priority: $keyboard.PRIORITY.MEDIUM });
switch (os.value) { $keyboard.enableContext("settings");
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;
}
autostart.value = (await $settings.getSetting("autostart")) === "true"; autostart.value = (await $settings.getSetting("autostart")) === "true";
}); });
onUnmounted(() => { onUnmounted(() => {
keyboard.clear(); $keyboard.disableContext("settings");
$keyboard.clearAll();
}); });
</script> </script>

239
plugins/keyboard.ts Normal file
View file

@ -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<string>();
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<string, Array<typeof allHandlers[0]>> = {};
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,
},
},
};
});