diff --git a/frontend/package.json b/frontend/package.json
index a68720a..c2976bb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -93,6 +93,7 @@
"swiper": "^8.4.7",
"tailwindcss": "^3.3.1",
"thumbhash": "^0.1.1",
+ "tinykeys": "^2.1.0",
"tippy.js": "^6.3.7",
"tslib": "^2.5.0",
"typescript": "~5.0.4",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a575a41..aea9154 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -1,4 +1,4 @@
-lockfileVersion: '6.1'
+lockfileVersion: '6.0'
settings:
autoInstallPeers: true
@@ -247,6 +247,9 @@ devDependencies:
thumbhash:
specifier: ^0.1.1
version: 0.1.1
+ tinykeys:
+ specifier: ^2.1.0
+ version: 2.1.0
tippy.js:
specifier: ^6.3.7
version: 6.3.7
@@ -5674,6 +5677,13 @@ packages:
globrex: 0.1.2
dev: true
+ /tinykeys@2.1.0:
+ resolution:
+ {
+ integrity: sha512-/MESnqBD1xItZJn5oGQ4OsNORQgJfPP96XSGoyu4eLpwpL0ifO0SYR5OD76u0YMhMXsqkb0UqvI9+yXTh4xv8Q==
+ }
+ dev: true
+
/tinyqueue@2.0.3:
resolution:
{
diff --git a/frontend/src/lib/components/commandpalette.svelte b/frontend/src/lib/components/commandpalette.svelte
new file mode 100644
index 0000000..f2b94fd
--- /dev/null
+++ b/frontend/src/lib/components/commandpalette.svelte
@@ -0,0 +1,279 @@
+
+
+
+
+{#if open}
+
+{/if}
diff --git a/frontend/src/lib/tinykeys.ts b/frontend/src/lib/tinykeys.ts
new file mode 100644
index 0000000..409996e
--- /dev/null
+++ b/frontend/src/lib/tinykeys.ts
@@ -0,0 +1,273 @@
+/*
+MIT License
+
+Copyright (c) 2020 Jamie Kyle
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+COPIED FROM https://github.com/jamiebuilds/tinykeys/blob/111955cb6604fb5b8c4f152cb75b7f2cb63da913/src/tinykeys.ts
+*/
+
+type KeyBindingPress = [string[], string];
+
+/**
+ * A map of keybinding strings to event handlers.
+ */
+export interface KeyBindingMap {
+ // eslint-disable-next-line no-unused-vars
+ [keybinding: string]: (event: KeyboardEvent) => void;
+}
+
+export interface KeyBindingHandlerOptions {
+ /**
+ * Keybinding sequences will wait this long between key presses before
+ * cancelling (default: 1000).
+ *
+ * **Note:** Setting this value too low (i.e. `300`) will be too fast for many
+ * of your users.
+ */
+ timeout?: number;
+}
+
+/**
+ * Options to configure the behavior of keybindings.
+ */
+export interface KeyBindingOptions extends KeyBindingHandlerOptions {
+ /**
+ * Key presses will listen to this event (default: "keydown").
+ */
+ event?: 'keydown' | 'keyup';
+}
+
+/**
+ * These are the modifier keys that change the meaning of keybindings.
+ *
+ * Note: Ignoring "AltGraph" because it is covered by the others.
+ */
+const KEYBINDING_MODIFIER_KEYS = ['Shift', 'Meta', 'Alt', 'Control'];
+
+/**
+ * Keybinding sequences should timeout if individual key presses are more than
+ * 1s apart by default.
+ */
+const DEFAULT_TIMEOUT = 1000;
+
+/**
+ * Keybinding sequences should bind to this event by default.
+ */
+const DEFAULT_EVENT = 'keydown';
+
+/**
+ * Platform detection code.
+ * @see https://github.com/jamiebuilds/tinykeys/issues/184
+ */
+const PLATFORM = typeof navigator === 'object' ? navigator.platform : '';
+const APPLE_DEVICE = /Mac|iPod|iPhone|iPad/.test(PLATFORM);
+
+/**
+ * An alias for creating platform-specific keybinding aliases.
+ */
+const MOD = APPLE_DEVICE ? 'Meta' : 'Control';
+
+/**
+ * Meaning of `AltGraph`, from MDN:
+ * - Windows: Both Alt and Ctrl keys are pressed, or AltGr key is pressed
+ * - Mac: ⌥ Option key pressed
+ * - Linux: Level 3 Shift key (or Level 5 Shift key) pressed
+ * - Android: Not supported
+ * @see https://github.com/jamiebuilds/tinykeys/issues/185
+ */
+const ALT_GRAPH_ALIASES = PLATFORM === 'Win32' ? ['Control', 'Alt'] : APPLE_DEVICE ? ['Alt'] : [];
+
+/**
+ * There's a bug in Chrome that causes event.getModifierState not to exist on
+ * KeyboardEvent's for F1/F2/etc keys.
+ */
+function getModifierState(event: KeyboardEvent, mod: string) {
+ return typeof event.getModifierState === 'function'
+ ? event.getModifierState(mod) ||
+ (ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState('AltGraph'))
+ : false;
+}
+
+/**
+ * Parses a "Key Binding String" into its parts
+ *
+ * grammar = ``
+ * = ` ...`
+ * = `` or `+`
+ * = `++...`
+ */
+export function parseKeybinding(str: string): KeyBindingPress[] {
+ return str
+ .trim()
+ .split(' ')
+ .map((press) => {
+ let mods = press.split(/\b\+/);
+ const key = mods.pop() as string;
+ mods = mods.map((mod) => (mod === '$mod' ? MOD : mod));
+ return [mods, key];
+ });
+}
+
+/**
+ * This tells us if a series of events matches a key binding sequence either
+ * partially or exactly.
+ */
+function match(event: KeyboardEvent, press: KeyBindingPress): boolean {
+ // prettier-ignore
+ return !(
+ // Allow either the `event.key` or the `event.code`
+ // MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
+ // MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
+ (
+ press[1].toUpperCase() !== event.key.toUpperCase() &&
+ press[1] !== event.code
+ ) ||
+
+ // Ensure all the modifiers in the keybinding are pressed.
+ press[0].find(mod => {
+ return !getModifierState(event, mod)
+ }) ||
+
+ // KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a
+ // keybinding. So if they are pressed but aren't part of the current
+ // keybinding press, then we don't have a match.
+ KEYBINDING_MODIFIER_KEYS.find(mod => {
+ return !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod)
+ })
+ )
+}
+
+/**
+ * Creates an event listener for handling keybindings.
+ *
+ * @example
+ * ```js
+ * import { createKeybindingsHandler } from "../src/keybindings"
+ *
+ * let handler = createKeybindingsHandler({
+ * "Shift+d": () => {
+ * alert("The 'Shift' and 'd' keys were pressed at the same time")
+ * },
+ * "y e e t": () => {
+ * alert("The keys 'y', 'e', 'e', and 't' were pressed in order")
+ * },
+ * "$mod+d": () => {
+ * alert("Either 'Control+d' or 'Meta+d' were pressed")
+ * },
+ * })
+ *
+ * window.addEvenListener("keydown", handler)
+ * ```
+ */
+export function createKeybindingsHandler(
+ keyBindingMap: KeyBindingMap,
+ options: KeyBindingHandlerOptions = {}
+): EventListener {
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
+
+ const keyBindings = Object.keys(keyBindingMap).map((key) => {
+ return [parseKeybinding(key), keyBindingMap[key]] as const;
+ });
+
+ const possibleMatches = new Map();
+ let timer: number | null = null;
+
+ return (event) => {
+ // Ensure and stop any event that isn't a full keyboard event.
+ // Autocomplete option navigation and selection would fire a instanceof Event,
+ // instead of the expected KeyboardEvent
+ if (!(event instanceof KeyboardEvent)) {
+ return;
+ }
+
+ keyBindings.forEach((keyBinding) => {
+ const sequence = keyBinding[0];
+ const callback = keyBinding[1];
+
+ const prev = possibleMatches.get(sequence);
+ const remainingExpectedPresses = prev ? prev : sequence;
+ const currentExpectedPress = remainingExpectedPresses[0];
+
+ const matches = match(event, currentExpectedPress);
+
+ if (!matches) {
+ // Modifier keydown events shouldn't break sequences
+ // Note: This works because:
+ // - non-modifiers will always return false
+ // - if the current keypress is a modifier then it will return true when we check its state
+ // MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState
+ if (!getModifierState(event, event.key)) {
+ possibleMatches.delete(sequence);
+ }
+ } else if (remainingExpectedPresses.length > 1) {
+ possibleMatches.set(sequence, remainingExpectedPresses.slice(1));
+ } else {
+ possibleMatches.delete(sequence);
+ callback(event);
+ }
+ });
+
+ if (timer) {
+ clearTimeout(timer);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ timer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout);
+ };
+}
+
+/**
+ * Subscribes to keybindings.
+ *
+ * Returns an unsubscribe method.
+ *
+ * @example
+ * ```js
+ * import { tinykeys } from "../src/tinykeys"
+ *
+ * tinykeys(window, {
+ * "Shift+d": () => {
+ * alert("The 'Shift' and 'd' keys were pressed at the same time")
+ * },
+ * "y e e t": () => {
+ * alert("The keys 'y', 'e', 'e', and 't' were pressed in order")
+ * },
+ * "$mod+d": () => {
+ * alert("Either 'Control+d' or 'Meta+d' were pressed")
+ * },
+ * })
+ * ```
+ */
+export function tinykeys(
+ target: Window | HTMLElement,
+ keyBindingMap: KeyBindingMap,
+ options: KeyBindingOptions = {}
+): () => void {
+ const event = options.event ?? DEFAULT_EVENT;
+ const onKeyEvent = createKeybindingsHandler(keyBindingMap, options);
+
+ target.addEventListener(event, onKeyEvent);
+
+ return () => {
+ target.removeEventListener(event, onKeyEvent);
+ };
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 73abf80..9db0e8f 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -11,6 +11,7 @@
import { BrowserTracing } from '@sentry/tracing';
import { initLocalizationContext } from '$lib/i18n';
import { browser } from '$app/environment';
+ import CommandPalette from '$lib/components/commandpalette.svelte';
// import Alert from '$lib/modals/alert.svelte';
/* afterNavigate(() => {
@@ -91,6 +92,7 @@
{:else}
{/if}
+