1import { useDevice } from "./device";
2import { useEventListener } from "@vueuse/core";
3import { ref } from "vue";
4
5interface ShortcutBinding {
6 key: string;
7 shift?: boolean;
8 ctrl?: boolean;
9 alt?: boolean;
10 meta?: boolean; // Cmd on Mac
11}
12
13export const shortcutsList = ref<ShortcutBinding[]>([]);
14
15// Registers a keyboard shortcut and its callback
16// binding: can take either a string (key) or a ShortcutBinding object, e.g., 'k' or { key: 'k', cmnd: true }
17export const useShortcut = (
18 binding: string | ShortcutBinding,
19 cb: Function
20) => {
21 const shortcutBinding = parseBinding(binding);
22
23 registerShortcut(shortcutBinding);
24
25 const handleKeydown = (e: KeyboardEvent) => {
26 if (!isEventRegistered(e, shortcutBinding)) {
27 return;
28 }
29
30 if (disableShortcuts()) {
31 return; // Don't trigger shortcuts when typing, or in modals/menus
32 }
33
34 if (validateKeyCombination(e, shortcutBinding)) {
35 try {
36 e.preventDefault();
37 cb();
38 } catch (error) {
39 console.error("Error executing shortcut:", binding, error);
40 }
41 }
42 };
43
44 useEventListener(document, "keydown", handleKeydown);
45};
46
47function parseBinding(binding: string | ShortcutBinding): ShortcutBinding {
48 if (typeof binding === "string")
49 return {
50 key: binding,
51 shift: false,
52 ctrl: false,
53 alt: false,
54 meta: false,
55 };
56 else {
57 binding = {
58 shift: false,
59 ctrl: false,
60 alt: false,
61 meta: false,
62 ...binding,
63 };
64 }
65
66 if (!binding.meta) return binding;
67 const { isMac } = useDevice();
68 if (isMac) {
69 // on Mac, treat ctrl as meta
70 if (binding.ctrl) {
71 binding.meta = true;
72 binding.ctrl = false;
73 }
74 } else {
75 // on non-Mac, treat meta as ctrl
76 if (binding.meta) {
77 binding.ctrl = true;
78 binding.meta = false;
79 }
80 }
81 return binding;
82}
83
84function registerShortcut(binding: ShortcutBinding) {
85 const exists = shortcutsList.value.some((shortcut) => {
86 return (
87 shortcut.key === binding.key &&
88 shortcut.shift === binding.shift &&
89 shortcut.ctrl === binding.ctrl &&
90 shortcut.alt === binding.alt &&
91 shortcut.meta === binding.meta
92 );
93 });
94
95 if (!exists) {
96 shortcutsList.value.push(binding);
97 }
98}
99
100function isEventRegistered(
101 e: KeyboardEvent,
102 shortcutBinding: ShortcutBinding
103): boolean {
104 return shortcutsList.value.some((shortcut) => {
105 return (
106 shortcut.key === shortcutBinding.key &&
107 shortcut.shift === shortcutBinding.shift &&
108 shortcut.ctrl === shortcutBinding.ctrl &&
109 shortcut.alt === shortcutBinding.alt &&
110 shortcut.meta === shortcutBinding.meta
111 );
112 });
113}
114
115function disableShortcuts(): boolean {
116 const activeElement = document.activeElement;
117 return Boolean(
118 activeElement instanceof HTMLInputElement ||
119 activeElement instanceof HTMLTextAreaElement ||
120 (activeElement as any)?.contentEditable === "true" ||
121 activeElement?.closest("input") ||
122 activeElement?.closest("textarea") ||
123 activeElement?.closest("[contenteditable]") ||
124 activeElement?.closest(".dropdown-options") || // Dropdown search
125 activeElement?.closest('[role="dialog"]') ||
126 activeElement?.closest('[role="menu"]') ||
127 activeElement?.classList.contains("form-control") // Form inputs
128 );
129}
130
131function validateKeyCombination(
132 e: KeyboardEvent,
133 shortcutBinding: ShortcutBinding
134): boolean {
135 let matches = false;
136
137 if (
138 e.key === "Shift" ||
139 e.key === "Control" ||
140 e.key === "Alt" ||
141 e.key === "Meta"
142 ) {
143 return; // Ignore modifier key events
144 }
145
146 const shiftMatches = shortcutBinding.shift ? e.shiftKey : !e.shiftKey;
147 const ctrlMatches = shortcutBinding.ctrl ? e.ctrlKey : !e.ctrlKey;
148 const altMatches = shortcutBinding.alt ? e.altKey : !e.altKey;
149 const metaMatches = shortcutBinding.meta ? e.metaKey : !e.metaKey;
150
151 const normalizedEventKey = normalizeKey(e.key.toLowerCase());
152 const normalizedBindingKey = shortcutBinding.key.toLowerCase();
153 const keyMatches =
154 normalizedEventKey === normalizedBindingKey ||
155 e.key.toLowerCase() === normalizedBindingKey;
156
157 matches =
158 keyMatches && shiftMatches && ctrlMatches && altMatches && metaMatches;
159
160 return matches;
161}
162
163// Normalize keys that have different values with Shift modifier
164// e.g., "." becomes ">" on Windows/Linux when Shift is pressed, but remains "." on Mac
165function normalizeKey(key: string): string {
166 const shiftKeyMap: Record<string, string> = {
167 ">": ".",
168 "<": ",",
169 "?": "/",
170 ":": ";",
171 '"': "'",
172 "{": "[",
173 "}": "]",
174 "|": "\\",
175 "+": "=",
176 _: "-",
177 "!": "1",
178 "@": "2",
179 "#": "3",
180 $: "4",
181 "%": "5",
182 "^": "6",
183 "&": "7",
184 "*": "8",
185 "(": "9",
186 ")": "0",
187 };
188 return shiftKeyMap[key] || key;
189}
190