UNPKG

@solid-primitives/keyboard

Version:

A library of reactive promitives helping handling user's keyboard input.

304 lines (303 loc) 10.5 kB
import { makeEventListener } from "@solid-primitives/event-listener"; import { createSingletonRoot } from "@solid-primitives/rootless"; import { arrayEquals } from "@solid-primitives/utils"; import { createEffect, createMemo, createSignal, on, untrack } from "solid-js"; import { isServer } from "solid-js/web"; function equalsKeyHoldSequence(sequence, model) { for (let i = sequence.length - 1; i >= 0; i--) { const _model = model.slice(0, i + 1); if (!arrayEquals(sequence[i], _model)) return false; } return true; } /** * Provides a signal with the last keydown event. * * The signal is `null` initially, and is reset to that after a timeout. * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#useKeyDownEvent * * @returns * Returns a signal of the last keydown event * ```ts * Accessor<KeyboardEvent | null> * ``` * * @example * ```ts * const event = useKeyDownEvent(); * * createEffect(() => { * const e = event(); * console.log(e) // => KeyboardEvent | null * * if (e) { * console.log(e.key) // => "Q" | "ALT" | ... or null * e.preventDefault(); // prevent default behavior or last keydown event * } * }) * ``` */ export const useKeyDownEvent = /*#__PURE__*/ createSingletonRoot(() => { if (isServer) { return () => null; } const [event, setEvent] = createSignal(null); makeEventListener(window, "keydown", e => { setEvent(e); setTimeout(() => setEvent(null)); }); return event; }); /** * Provides a signal with the list of currently held keys, ordered from least recent to most recent. * * This is a [singleton root primitive](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot). *(signals and event-listeners are reused across dependents)* * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#useKeyDownList * * @returns * Returns a signal of a list of keys * ```ts * Accessor<string[]> * ``` * * @example * ```ts * const keys = useKeyDownList(); * createEffect(() => { * console.log(keys()) // => ["ALT", "CONTROL", "Q", "A"] * }) * ``` */ export const useKeyDownList = /*#__PURE__*/ createSingletonRoot(() => { if (isServer) { const keys = () => []; // this is for backwards compatibility // TODO remove in the next major version keys[0] = keys; keys[1] = { event: () => null }; keys[Symbol.iterator] = function* () { yield keys[0]; yield keys[1]; }; return keys; } const [pressedKeys, setPressedKeys] = createSignal([]), reset = () => setPressedKeys([]), event = useKeyDownEvent(); makeEventListener(window, "keydown", e => { // e.key may be undefined when used with <datalist> el // gh issue: https://github.com/solidjs-community/solid-primitives/issues/246 if (e.repeat || typeof e.key !== "string") return; const key = e.key.toUpperCase(), currentKeys = pressedKeys(); if (currentKeys.includes(key)) return; const keys = [...currentKeys, key]; // if the modifier is pressed before we start listening // we should add it to the list if (currentKeys.length === 0 && key !== "ALT" && key !== "CONTROL" && key !== "META" && key !== "SHIFT") { if (e.shiftKey) keys.unshift("SHIFT"); if (e.altKey) keys.unshift("ALT"); if (e.ctrlKey) keys.unshift("CONTROL"); if (e.metaKey) keys.unshift("META"); } setPressedKeys(keys); }); makeEventListener(window, "keyup", e => { if (typeof e.key !== "string") return; const key = e.key.toUpperCase(); setPressedKeys(prev => prev.filter(_key => _key !== key)); }); makeEventListener(window, "blur", reset); makeEventListener(window, "contextmenu", e => { e.defaultPrevented || reset(); }); // this is for backwards compatibility // TODO remove in the next major version pressedKeys[0] = pressedKeys; pressedKeys[1] = { event }; pressedKeys[Symbol.iterator] = function* () { yield pressedKeys[0]; yield pressedKeys[1]; }; return pressedKeys; }); /** * Provides a signal with the currently held single key. Pressing any other key at the same time will reset the signal to `null`. * * This is a [singleton root primitive](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot). *(signals and event-listeners are reused across dependents)* * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#useCurrentlyHeldKey * * @returns * ```ts * Accessor<string | null> * ``` * * @example * ```ts * const key = useCurrentlyHeldKey(); * createEffect(() => { * console.log(key()) // => "Q" | "ALT" | ... or null * }) * ``` */ export const useCurrentlyHeldKey = /*#__PURE__*/ createSingletonRoot(() => { if (isServer) { return () => null; } const keys = useKeyDownList(); let prevKeys = untrack(keys); return createMemo(() => { const _keys = keys(); const prev = prevKeys; prevKeys = _keys; if (prev.length === 0 && _keys.length === 1) return _keys[0]; return null; }); }); /** * Provides a signal with a sequence of currently held keys, as they were pressed down and up. * * This is a [singleton root primitive](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot). *(signals and event-listeners are reused across dependents)* * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#useKeyDownSequence * * @returns * ```ts * Accessor<string[][]> * // [["CONTROL"], ["CONTROL", "Q"], ["CONTROL", "Q", "A"]] * ``` * * @example * ```ts * const sequence = useKeyDownSequence(); * createEffect(() => { * console.log(sequence()) // => string[][] * }) * ``` */ export const useKeyDownSequence = /*#__PURE__*/ createSingletonRoot(() => { if (isServer) { return () => []; } const keys = useKeyDownList(); return createMemo(prev => { if (keys().length === 0) return []; return [...prev, keys()]; }, []); }); /** * Provides a `boolean` signal indicating if provided {@link key} is currently being held down. * Holding multiple keys at the same time will return `false` — holding only the specified one will return `true`. * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createKeyHold * * @param key The key to check for. * @options The options for the key hold. * - `preventDefault` — Controlls in the keydown event should have it's default action prevented. Enabled by default. * @returns * ```ts * Accessor<boolean> * ``` * * @example * ```ts * const isHeld = createKeyHold("ALT"); * createEffect(() => { * console.log(isHeld()) // => boolean * }) * ``` */ export function createKeyHold(key, options = {}) { if (isServer) { return () => false; } key = key.toUpperCase(); const { preventDefault = true } = options, event = useKeyDownEvent(), heldKey = useCurrentlyHeldKey(); return createMemo(() => heldKey() === key && (preventDefault && event()?.preventDefault(), true)); } /** * Creates a keyboard shotcut observer. The provided {@link callback} will be called when the specified {@link keys} are pressed. * * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyboard#createShortcut * * @param keys The sequence of keys to watch for. * @param callback The callback to call when the keys are pressed. * @options The options for the shortcut. * - `preventDefault` — Controlls in the keydown event should have it's default action prevented. Enabled by default. * - `requireReset` — If `true`, the shortcut will only be triggered once until all of the keys stop being pressed. Disabled by default. * * @example * ```ts * createShortcut(["CONTROL", "SHIFT", "C"], () => { * console.log("Ctrl+Shift+C was pressed"); * }); * ``` */ export function createShortcut(keys, callback, options = {}) { if (isServer || !keys.length) { return; } keys = keys.map(key => key.toUpperCase()); const { preventDefault = true } = options, event = useKeyDownEvent(), sequence = useKeyDownSequence(); let reset = false; // allow to check the sequence only once the user has released all keys const handleSequenceWithReset = (sequence) => { if (!sequence.length) return (reset = false); if (reset) return; const e = event(); if (sequence.length < keys.length) { // optimistically preventDefault behavior if we yet don't have enough keys if (equalsKeyHoldSequence(sequence, keys.slice(0, sequence.length))) { preventDefault && e && e.preventDefault(); } else { reset = true; } } else { reset = true; if (equalsKeyHoldSequence(sequence, keys)) { preventDefault && e && e.preventDefault(); callback(e); } } }; // allow checking the sequence even if the user is still holding down keys const handleSequenceWithoutReset = (sequence) => { const last = sequence.at(-1); if (!last) return; const e = event(); // optimistically preventDefault behavior if we yet don't have enough keys if (preventDefault && last.length < keys.length) { if (arrayEquals(last, keys.slice(0, keys.length - 1))) { e && e.preventDefault(); } return; } if (arrayEquals(last, keys)) { const prev = sequence.at(-2); if (!prev || arrayEquals(prev, keys.slice(0, keys.length - 1))) { preventDefault && e && e.preventDefault(); callback(e); } } }; createEffect(on(sequence, options.requireReset ? handleSequenceWithReset : handleSequenceWithoutReset)); }