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} +
+
+
+

+ {bg_text} +

+ +
+
+ {#each visible_items as vi, i} +
(selected = i)} + on:mousedown={execute_action} + > +
+

{vi.title}

+

+ /{vi.command} + {#if vi.args} + {#each vi.args as arg} + {{arg}}{/each} + {/if} +

+
+

{vi.description}

+
+ {/each} +
+
+
+{/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} +