Last active
March 24, 2026 04:18
-
-
Save nickyonge/728b4563fdb4600ffa57527e345de314 to your computer and use it in GitHub Desktop.
Input handler that unifies element input states (hover, active, focus, etc) into callbacks and attributes, built with 100% vanilla JS [WIP]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** INPUT HANDLER | |
| * | |
| * A script that Input handler that unifies element input states | |
| * (hover, active, focus, etc) into callbacks and attributes. | |
| * Built with 100% vanilla JS | |
| * | |
| * Presented as-is under The Unlicense, see below for license info. | |
| */ | |
| // @ts-check | |
| // TODO: documentation, summaries, provide example usage | |
| // TODO: improved and importable types + declarations | |
| // TODO: add separate callbacks/attributes for specific clicks | |
| // TODO: allow disabling context menu (+auto disable if using right click) | |
| // TODO: delineate between 'mouse' and 'pen' inputs for 'pointer' events | |
| // TODO: globally accessible IsElementHighlighted, IsElementActive, etc checks | |
| /** | |
| * @typedef {'keyboard'|'pointer'} InputMode | |
| * Mode used for input, either `"keyboard"` or | |
| * `"pointer"`, which can be touch or mouse. | |
| */ | |
| /** | |
| * @typedef {Object} InputState | |
| * @property {boolean} highlighted | |
| * @property {boolean} active | |
| * @property {boolean} hovered | |
| * @property {boolean} focusWithin | |
| * @property {boolean} focusVisibleWithin | |
| * @property {boolean} pointerHeld | |
| * @property {boolean} keyHeld | |
| * @property {InputMode} lastInputMode | |
| */ | |
| /** | |
| * @typedef {Object} InputHandler | |
| * @property {HTMLElement} element | |
| * @property {() => InputState} getState | |
| * @property {() => void} destroy | |
| */ | |
| /** | |
| * @typedef {Object} InputHandlerTarget | |
| * @property {InputHandler} inputHandler | |
| * @property {(event:KeyboardEvent) => void} onKeyDown | |
| * @property {(event:KeyboardEvent) => void} onKeyUp | |
| * @property {(event:PointerEvent) => void} onPointerDown | |
| */ | |
| /** | |
| * @typedef {Object} InputMouseButtons | |
| * @property {boolean} [left=true] | |
| * @property {boolean} [middle=false] | |
| * @property {boolean} [right=false] | |
| */ | |
| /** | |
| * @typedef {Object} InputEventData | |
| * @property {InputMode} source | |
| * @property {PointerEvent | KeyboardEvent} originalEvent | |
| * @property {InputState} state | |
| */ | |
| /** | |
| * @typedef {Object} QueuedInputEventData | |
| * @property {InputMode} source | |
| * @property {PointerEvent | KeyboardEvent} originalEvent | |
| */ | |
| /** | |
| * @typedef {Object} InputHandlerOptions | |
| * @property {InputAttributesOptions} [attributes] | |
| * @property {InputMouseButtons} [mouseButtons] | |
| * @property {((state:InputState) => void) | null} [onChange] | |
| * @property {((state:InputEventData) => void) | null} [onSelect] | |
| * @property {((highlighted:boolean,state:InputState) => void) | null} [onHighlightChange] | |
| * @property {((active:boolean,state:InputState) => void) | null} [onActiveChange] | |
| * @property {((hovered:boolean,state:InputState) => void) | null} [onHoveredChange] | |
| * @property {((inputMode:InputMode,state:InputState) => void) | null} [onInputModeChange] | |
| * @property {((element:HTMLElement) => void) | null} [onDestroyed] | |
| * @property {boolean} [activeAttrOverridesHoverAndHighlightAttrs=DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS] | |
| * @property {string[]} [activeKeys=[' ', 'Enter']] | |
| * @property {boolean} [outputErrors=true] | |
| * @property {boolean} [enforceDirectFocus=false] | |
| * @property {boolean} [allowKeySelectOnUnfocusedHighlight=false] | |
| */ | |
| /** | |
| * @typedef {Object} InputAttribute | |
| * @property {string} name | |
| * @property {boolean} [enabled] | |
| * @property {boolean} [usePrefix=true] | |
| */ | |
| /** | |
| * @typedef {Object} InputAttributesOptions | |
| * @property {string|null} [prefix=DEFAULT_ATTR_OPTIONS_PREFIX] | |
| * @property {boolean} [enabled=DEFAULT_ATTR_OPTIONS_ENABLED] | |
| * @property {InputAttribute} [highlighted] | |
| * @property {InputAttribute} [active] | |
| * @property {InputAttribute} [hovered] | |
| * @property {InputAttribute} [focusWithin] | |
| * @property {InputAttribute} [focusVisibleWithin] | |
| * @property {InputAttribute} [pointerHeld] | |
| * @property {InputAttribute} [keyHeld] | |
| * @property {InputAttribute} [lastInputMode] | |
| */ | |
| /** | |
| * Attaches an {@linkcode InputHandler} to the given element and returns it. | |
| * | |
| * This will cohesively monitor both keyboard and mouse input, and can add/remove | |
| * CSS classes to accurately control appearance. | |
| * | |
| * Returns `null` on error. | |
| * @param {HTMLElement} element | |
| * @param {InputHandlerOptions} options | |
| * @returns {InputHandler|null} | |
| */ | |
| export function AttachInputHandler(element, { | |
| attributes, | |
| mouseButtons, | |
| onSelect = null, | |
| onChange = null, | |
| onHighlightChange = null, | |
| onActiveChange = null, | |
| onHoveredChange = null, | |
| onInputModeChange = null, | |
| onDestroyed = null, | |
| activeAttrOverridesHoverAndHighlightAttrs = DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS, | |
| activeKeys = [' ', 'Enter'], | |
| outputErrors = true, | |
| enforceDirectFocus = false, | |
| allowKeySelectOnUnfocusedHighlight = true, | |
| } = {}) { | |
| // nullcheck | |
| if (element == null || !(element instanceof HTMLElement)) { | |
| if (outputErrors) { | |
| console.error('Can\'t attach input handler to null element'); | |
| } | |
| return null; | |
| } | |
| // check if already being handled | |
| if (IsElementHandled(element)) { | |
| if (outputErrors) { | |
| console.error(`Can't attach input handler to element ${element}, handler already added to it`, element); | |
| } | |
| return null; | |
| } | |
| // ensure direct focusing is enabled | |
| if (enforceDirectFocus) { | |
| if (element.tabIndex < 0 && !element.hasAttribute('tabindex')) { | |
| element.tabIndex = 0; | |
| } | |
| } | |
| // ensure attribute and mouseButton values are all present | |
| attributes = ValidateAttributesOptions(attributes); | |
| mouseButtons = ValidateMouseButtons(mouseButtons); | |
| /** flag to ensure queue is only {@link ProcessQueue processed} once per tick */ | |
| let processingQueue = false; | |
| /** | |
| * number of times {@linkcode ProcessQueue} has been run | |
| * @see {@linkcode MAX_PROCESS_QUEUE_PASSES} is the limit (if `0`, process infinitely) | |
| */ | |
| let processQueuePasses = 0; | |
| /** flag to ensure UpdateState is called after DOM changes */ | |
| let queuedUpdate = false; | |
| /** flag ensuring onChange is invoked once, set via {@linkcode QueueChange} */ | |
| let queuedChange = false; | |
| /** @type {Set<() => void>} */ | |
| const queuedCallbacks = new Set(); | |
| /** @type {QueuedInputEventData|null} */ | |
| let queuedSelectInputData = null; | |
| /** @param {string|null} prefix @param {InputAttribute} attribute @returns {string} */ | |
| function GetAttributeName(prefix, attribute) { return attribute.usePrefix && prefix != null ? `${prefix}${attribute.name}` : attribute.name; } | |
| // calculate & declare names for used attributes | |
| const highlightedAttributeName = attributes != null && attributes.highlighted != null ? GetAttributeName(attributes.prefix, attributes.highlighted) : DEFAULT_ATTR_NAME_HIGHLIGHTED; | |
| const activeAttributeName = attributes != null && attributes.active != null ? GetAttributeName(attributes.prefix, attributes.active) : DEFAULT_ATTR_NAME_ACTIVE; | |
| const hoveredAttributeName = attributes != null && attributes.hovered != null ? GetAttributeName(attributes.prefix, attributes.hovered) : DEFAULT_ATTR_NAME_HOVERED; | |
| const focusWithinAttributeName = attributes != null && attributes.focusWithin != null ? GetAttributeName(attributes.prefix, attributes.focusWithin) : DEFAULT_ATTR_NAME_FOCUS_WITHIN; | |
| const focusVisibleWithinAttributeName = attributes != null && attributes.focusVisibleWithin != null ? GetAttributeName(attributes.prefix, attributes.focusVisibleWithin) : DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN; | |
| const pointerHeldAttributeName = attributes != null && attributes.pointerHeld != null ? GetAttributeName(attributes.prefix, attributes.pointerHeld) : DEFAULT_ATTR_NAME_POINTER_HELD; | |
| const keyHeldAttributeName = attributes != null && attributes.keyHeld != null ? GetAttributeName(attributes.prefix, attributes.keyHeld) : DEFAULT_ATTR_NAME_KEY_HELD; | |
| const lastInputModeAttributeName = attributes != null && attributes.lastInputMode != null ? GetAttributeName(attributes.prefix, attributes.lastInputMode) : DEFAULT_ATTR_NAME_LAST_INPUT_MODE; | |
| // TODO: these names are snapshotted at instantiation time, and can't be changed later | |
| /** | |
| * Map of all attributes and their current settings, | |
| * updates whenever attributes change | |
| * @type {Map<string,[string,boolean]>} | |
| */ | |
| const currentAttributes = new Map([ | |
| [DEFAULT_ATTR_NAME_HIGHLIGHTED, [highlightedAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_ACTIVE, [activeAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_HOVERED, [hoveredAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_FOCUS_WITHIN, [focusWithinAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN, [focusVisibleWithinAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_POINTER_HELD, [pointerHeldAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_KEY_HELD, [keyHeldAttributeName, false]], | |
| [DEFAULT_ATTR_NAME_LAST_INPUT_MODE, [lastInputModeAttributeName, false]], | |
| ]); | |
| /** This element/handler's current state @type {InputState} */ | |
| const state = DefaultState(); | |
| /** The {@linkcode PointerEvent.pointerId} tracked for this element */ | |
| let pointerActiveId = null; | |
| /** currently-held keys @type {Set<string>} */ | |
| const heldKeys = new Set(); | |
| /** Keys that can affect keyboard navigation, and thus cause focus/blur */ | |
| const keyboardNavKeys = new Set([ | |
| 'Tab', | |
| 'ArrowUp', | |
| 'ArrowDown', | |
| 'ArrowLeft', | |
| 'ArrowRight', | |
| ]); | |
| /** Checks if the given `event.key` is included in {@linkcode activeKeys} @param {KeyboardEvent} event */ | |
| function isActiveKey(event) { | |
| return activeKeys.includes(event.key); | |
| } | |
| /** Check if element or its children are the `document.activeElement` @returns {boolean} */ | |
| function elementContainsFocus() { | |
| const activeElement = document.activeElement; | |
| return activeElement != null && element.contains(activeElement); | |
| } | |
| /** Check if element essentially has CSS `:focus-visible` @returns {boolean} */ | |
| function IsFocusVisibleWithin() { | |
| const activeElement = document.activeElement; | |
| if (!activeElement || !element.contains(activeElement)) { return false; } | |
| // use :focus-visible primarily (note: not all browser's support it) | |
| if (typeof activeElement.matches === 'function') { | |
| try { | |
| if (activeElement.matches(':focus-visible')) { return true; } | |
| } catch { | |
| // nw | |
| } | |
| } | |
| // fallback, focus is visible-like when focus came from keyboard | |
| return state.lastInputMode === 'keyboard'; | |
| } | |
| /** @param {InputMode} inputMode */ | |
| function SetInputMode(inputMode) { | |
| if (state.lastInputMode === inputMode) { return; } | |
| state.lastInputMode = inputMode; | |
| queuedCallbacks.add(() => { | |
| if (onInputModeChange != null && typeof onInputModeChange === 'function') { | |
| onInputModeChange(inputMode, { ...state }); | |
| } | |
| }); | |
| QueueChange(); | |
| } | |
| /** @param {boolean} hovered */ | |
| function SetHovered(hovered) { | |
| if (state.hovered === hovered) { return; } | |
| state.hovered = hovered; | |
| if (onHoveredChange != null && typeof onHoveredChange === 'function') { | |
| queuedCallbacks.add(() => { | |
| onHoveredChange(hovered, { ...state }); | |
| }); | |
| } | |
| QueueChange(); | |
| } | |
| /** @param {boolean} pointerHeld */ | |
| function SetPointerHeld(pointerHeld) { | |
| if (state.pointerHeld === pointerHeld) { return; } | |
| state.pointerHeld = pointerHeld; | |
| QueueChange(); | |
| } | |
| /** confirm a change has occurred, ensure queue will be processed */ | |
| function QueueChange() { | |
| queuedChange = true; | |
| ProcessQueue(); | |
| } | |
| /** Re-determine the current {@linkcode InputState} based on activity */ | |
| function UpdateState() { | |
| // snapshot previous state for callbacks | |
| const wasHighlighted = state.highlighted; | |
| const wasActive = state.active; | |
| const wasFocusWithin = state.focusWithin; | |
| const wasFocusVisibleWithin = state.focusVisibleWithin; | |
| const wasKeyHeld = state.keyHeld; | |
| // calculate new states (calculate in order) | |
| const newFocusWithin = elementContainsFocus(); | |
| const newFocusVisibleWithin = IsFocusVisibleWithin(); | |
| const newKeyHeld = heldKeys.size > 0; | |
| const newActive = state.pointerHeld || | |
| (newKeyHeld && newFocusVisibleWithin); | |
| const newHighlighted = state.hovered || newFocusVisibleWithin; | |
| // check for changes | |
| const changedHighlighted = wasHighlighted !== newHighlighted; | |
| const changedActive = wasActive !== newActive; | |
| const changedFocusWithin = wasFocusWithin !== newFocusWithin; | |
| const changedFocusVisibleWithin = wasFocusVisibleWithin !== newFocusVisibleWithin; | |
| const changedKeyHeld = wasKeyHeld !== newKeyHeld; | |
| // determine if invoking any callbacks | |
| const anyCallbacks = | |
| changedHighlighted || | |
| changedActive || | |
| changedFocusWithin || | |
| changedFocusVisibleWithin || | |
| changedKeyHeld; | |
| // update current state | |
| state.highlighted = newHighlighted; | |
| state.active = newActive; | |
| state.focusWithin = newFocusWithin; | |
| state.focusVisibleWithin = newFocusVisibleWithin; | |
| state.keyHeld = newKeyHeld; | |
| // check and update attributes | |
| if (attributes != null) { | |
| // determine current attributes states | |
| const highlightedEnabled = attributes.enabled && attributes.highlighted.enabled; | |
| const activeEnabled = attributes.enabled && attributes.active.enabled; | |
| const hoveredEnabled = attributes.enabled && attributes.hovered.enabled; | |
| const focusWithinEnabled = attributes.enabled && attributes.focusWithin.enabled; | |
| const focusVisibleWithinEnabled = attributes.enabled && attributes.focusVisibleWithin.enabled; | |
| const pointerHeldEnabled = attributes.enabled && attributes.pointerHeld.enabled; | |
| const keyHeldEnabled = attributes.enabled && attributes.keyHeld.enabled; | |
| const lastInputModeEnabled = attributes.enabled && attributes.lastInputMode.enabled; | |
| // determine active/highlight/hover in case of activeAttrOverridesHoverAndHighlightAttrs | |
| const activeStateTrue = activeEnabled && attributes.active.enabled && state.active; | |
| const activeOverride = activeAttrOverridesHoverAndHighlightAttrs && activeStateTrue; | |
| const hoveredStateTrue = hoveredEnabled && !activeOverride && attributes.hovered.enabled && state.hovered; | |
| const highlightStateTrue = highlightedEnabled && !activeOverride && attributes.highlighted.enabled && state.highlighted; | |
| // update map | |
| currentAttributes.get(DEFAULT_ATTR_NAME_HIGHLIGHTED)[1] = highlightStateTrue; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_ACTIVE)[1] = activeStateTrue; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_HOVERED)[1] = hoveredStateTrue; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_WITHIN)[1] = focusWithinEnabled && attributes.focusWithin.enabled && state.focusWithin; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN)[1] = focusVisibleWithinEnabled && attributes.focusVisibleWithin.enabled && state.focusVisibleWithin; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_POINTER_HELD)[1] = pointerHeldEnabled && attributes.pointerHeld.enabled && state.pointerHeld; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_KEY_HELD)[1] = keyHeldEnabled && attributes.keyHeld.enabled && state.keyHeld; | |
| currentAttributes.get(DEFAULT_ATTR_NAME_LAST_INPUT_MODE)[1] = lastInputModeEnabled && attributes.lastInputMode.enabled; | |
| } | |
| // update element attributes | |
| element.toggleAttribute(highlightedAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_HIGHLIGHTED)[1]); | |
| element.toggleAttribute(activeAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_ACTIVE)[1]); | |
| element.toggleAttribute(hoveredAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_HOVERED)[1]); | |
| element.toggleAttribute(focusWithinAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_WITHIN)[1]); | |
| element.toggleAttribute(focusVisibleWithinAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN)[1]); | |
| element.toggleAttribute(pointerHeldAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_POINTER_HELD)[1]); | |
| element.toggleAttribute(keyHeldAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_KEY_HELD)[1]); | |
| if (currentAttributes.get(DEFAULT_ATTR_NAME_LAST_INPUT_MODE)[1]) { | |
| element.setAttribute(lastInputModeAttributeName, state.lastInputMode); | |
| } else { | |
| element.removeAttribute(lastInputModeAttributeName); | |
| } | |
| // check for significant callback changes | |
| if (!anyCallbacks) { return; } | |
| // invoke callbacks | |
| const onHighlightedCallback = changedHighlighted && | |
| (onHighlightChange != null && typeof onHighlightChange === 'function'); | |
| const onActiveCallback = changedActive && | |
| (onActiveChange != null && typeof onActiveChange === 'function'); | |
| if (onHighlightedCallback) { | |
| queuedCallbacks.add(() => { onHighlightChange(newHighlighted, { ...state }) }); | |
| } | |
| if (onActiveCallback) { | |
| queuedCallbacks.add(() => { onActiveChange(newActive, { ...state }) }); | |
| } | |
| // confirm change queued | |
| QueueChange(); | |
| } | |
| /** @param {KeyboardEvent} event */ | |
| function _onKeyDown(event) { | |
| SetInputMode('keyboard'); | |
| if (isActiveKey(event) && elementContainsFocus()) { | |
| heldKeys.add(event.key); | |
| } | |
| const focusVisibleNow = IsFocusVisibleWithin(); | |
| const selectionValid = focusVisibleNow || | |
| (allowKeySelectOnUnfocusedHighlight && state.hovered); | |
| // activate selection when focused | |
| if (!event.repeat && isActiveKey(event) && selectionValid) { | |
| QueueSelect('keyboard', event); | |
| } | |
| // note: tab/arrow nav can change focus-visible without active key hold | |
| if (keyboardNavKeys.has(event.key)) { | |
| // wait a tick to process keyboard input, update state | |
| queuedUpdate = true; | |
| ProcessQueue(); | |
| } else { | |
| UpdateState(); | |
| } | |
| } | |
| /** @param {KeyboardEvent} event */ | |
| function _onKeyUp(event) { | |
| if (heldKeys.has(event.key)) { | |
| heldKeys.delete(event.key); | |
| } | |
| UpdateState(); | |
| } | |
| function _onGlobalPointerDown() { | |
| SetInputMode('pointer'); | |
| UpdateState(); | |
| } | |
| /** @param {PointerEvent} event */ | |
| function _onPointerEnter(event) { | |
| if (event.pointerType === 'mouse' || event.pointerType === 'pen') { | |
| SetHovered(true); | |
| UpdateState(); | |
| } | |
| } | |
| /** @param {PointerEvent} event */ | |
| function _onPointerLeave(event) { | |
| if (event.pointerType === 'mouse' || event.pointerType === 'pen') { | |
| SetHovered(false); | |
| UpdateState(); | |
| } | |
| } | |
| /** @param {PointerEvent} event */ | |
| function _onLocalPointerDown(event) { | |
| if (!event.isPrimary) { return; } | |
| // determine if mouse button is valid | |
| switch (event.button) { | |
| case 0: | |
| // left | |
| const left = mouseButtons != null ? mouseButtons.left === true : DEFAULT_MOUSE_BUTTONS_LEFT; | |
| if (!left) { return; } | |
| break; | |
| case 1: | |
| // middle | |
| const middle = mouseButtons != null ? mouseButtons.middle === true : DEFAULT_MOUSE_BUTTONS_LEFT; | |
| if (!middle) { return; } | |
| break; | |
| case 2: | |
| // right | |
| const right = mouseButtons != null ? mouseButtons.right === true : DEFAULT_MOUSE_BUTTONS_LEFT; | |
| if (!right) { return; } | |
| break; | |
| } | |
| SetInputMode('pointer'); | |
| pointerActiveId = event.pointerId; | |
| SetPointerHeld(true); | |
| // keep receiving pointerup/pointercancel even if pointer moves away | |
| try { | |
| element.setPointerCapture(event.pointerId); | |
| } catch { | |
| // some elements/browsers don't allow this; ignore | |
| } | |
| UpdateState(); | |
| } | |
| /** @param {PointerEvent} event */ | |
| function _onPointerUp(event) { | |
| if (pointerActiveId !== null && event.pointerId !== pointerActiveId) { return; } | |
| const shouldSelect = | |
| state.pointerHeld && | |
| IsPointInsideElement(element, event.clientX, event.clientY); | |
| _endPointerHold(event); | |
| if (shouldSelect) { | |
| QueueSelect('pointer', event); | |
| } | |
| } | |
| /** @param {PointerEvent} event */ | |
| function _endPointerHold(event) { | |
| if (pointerActiveId !== null && event.pointerId !== pointerActiveId) { return; } | |
| SetPointerHeld(false); | |
| pointerActiveId = null; | |
| try { | |
| if (event.pointerId != null && element.hasPointerCapture?.(event.pointerId)) { | |
| element.releasePointerCapture(event.pointerId); | |
| } | |
| } catch { | |
| // ignore | |
| } | |
| UpdateState(); | |
| } | |
| function _onFocusIn() { | |
| // wait a tick so DOM settles | |
| queuedUpdate = true; | |
| ProcessQueue(); | |
| } | |
| function _onFocusOut() { | |
| // wait a tick so DOM settles | |
| queuedUpdate = true | |
| ProcessQueue(); | |
| } | |
| /** | |
| * @param {InputMode} source | |
| * @param {PointerEvent | KeyboardEvent} originalEvent | |
| */ | |
| function QueueSelect(source, originalEvent) { | |
| /** @type {QueuedInputEventData} */ | |
| const inputData = { | |
| source, | |
| originalEvent | |
| }; | |
| queuedSelectInputData = inputData; | |
| ProcessQueue(); | |
| } | |
| // per-element listeners | |
| element.addEventListener('pointerenter', _onPointerEnter); | |
| element.addEventListener('pointerleave', _onPointerLeave); | |
| element.addEventListener('pointerdown', _onLocalPointerDown); | |
| element.addEventListener('pointerup', _onPointerUp); | |
| element.addEventListener('pointercancel', _endPointerHold); | |
| element.addEventListener('lostpointercapture', _endPointerHold); | |
| element.addEventListener('focusin', _onFocusIn); | |
| element.addEventListener('focusout', _onFocusOut); | |
| /** @type {InputHandler} */ | |
| const inputHandler = { | |
| element, | |
| getState() { return { ...state } }, | |
| destroy() { | |
| Untarget(element); | |
| element.removeEventListener('pointerenter', _onPointerEnter); | |
| element.removeEventListener('pointerleave', _onPointerLeave); | |
| element.removeEventListener('pointerdown', _onLocalPointerDown); | |
| element.removeEventListener('pointerup', _onPointerUp); | |
| element.removeEventListener('pointercancel', _endPointerHold); | |
| element.removeEventListener('lostpointercapture', _endPointerHold); | |
| element.removeEventListener('focusin', _onFocusIn); | |
| element.removeEventListener('focusout', _onFocusOut); | |
| if (onDestroyed != null && typeof onDestroyed === 'function') { | |
| onDestroyed(element); | |
| } | |
| } | |
| }; | |
| // send initial update | |
| UpdateState(); | |
| // add target to global listeners | |
| Target(inputHandler, _onKeyDown, _onKeyUp, _onGlobalPointerDown); | |
| /** | |
| * Process queued tasks in order, | |
| * 1. UpdateState | |
| * 2. General callbacks | |
| * 2a. Type-specific callbacks | |
| * 2b. `onChange` callback | |
| * 3. `onSelect` callback | |
| */ | |
| function ProcessQueue() { | |
| // check if already processing | |
| if (processingQueue) { return; } | |
| // locally queued data snapshots, so that the queue functions | |
| // are able to themselves add queued data | |
| let _queuedUpdateSnapshot = false; | |
| let _queuedChangeSnapshot = false; | |
| /** @type {QueuedInputEventData|null} */ | |
| let _queuedSelectInputDataSnapshot = null; | |
| /** @type {(() => void)[]} */ | |
| let _queuedCallbacksSnapshot = []; | |
| /** | |
| * create snapshots of all the data for processing this queue, | |
| * and clear the existing queue */ | |
| const SnapshotQueuedData = () => { | |
| _queuedUpdateSnapshot = queuedUpdate; | |
| _queuedChangeSnapshot = queuedChange; | |
| _queuedSelectInputDataSnapshot = queuedSelectInputData; | |
| _queuedCallbacksSnapshot = [...queuedCallbacks]; | |
| queuedUpdate = false; | |
| queuedChange = false; | |
| queuedSelectInputData = null; | |
| queuedCallbacks.clear(); | |
| } | |
| /** done! */ | |
| const EndProcessingQueue = () => { | |
| processQueuePasses = 0; | |
| processingQueue = false; | |
| } | |
| /** Is any work queued? (Note: checks actual queues, not local snapshots) */ | |
| const IsAnythingQueued = () => { | |
| return queuedUpdate || queuedChange || | |
| queuedSelectInputData != null || | |
| queuedCallbacks.size > 0; | |
| } | |
| /** queued onSelect callback */ | |
| const ProcessOnSelectCallback = () => { | |
| // queued onSelect | |
| if (_queuedSelectInputDataSnapshot != null) { | |
| if (onSelect != null && typeof onSelect === 'function') { | |
| /** @type {InputEventData} */ | |
| const inputEventData = { | |
| // copy source/originalEvent from queued data | |
| ..._queuedSelectInputDataSnapshot, | |
| // add most recent source | |
| state: { ...state } | |
| }; | |
| onSelect(inputEventData); | |
| } | |
| } | |
| // done, recheck queue for 2nd pass, in case data changed on 1st pass | |
| processingQueue = false; | |
| if (IsAnythingQueued()) { | |
| queueMicrotask(ProcessQueue); | |
| } else { | |
| // fully done | |
| EndProcessingQueue(); | |
| } | |
| } | |
| /** queued onChange / non-select callbacks */ | |
| const ProcessGeneralCallbacks = () => { | |
| // queued callbacks | |
| if (_queuedCallbacksSnapshot.length > 0) { | |
| for (const callback of _queuedCallbacksSnapshot) { | |
| if (callback != null) { | |
| callback(); | |
| } | |
| } | |
| } | |
| // queued onChange | |
| if (_queuedChangeSnapshot) { | |
| if (onChange != null && typeof onChange === 'function') { | |
| onChange({ ...state }) | |
| } | |
| } | |
| // next step, onselect | |
| queueMicrotask(ProcessOnSelectCallback); | |
| } | |
| /** queued state update */ | |
| const ProcessUpdateState = () => { | |
| // queued state update | |
| if (_queuedUpdateSnapshot) { | |
| UpdateState(); | |
| } | |
| // next step, state calbacks | |
| queueMicrotask(ProcessGeneralCallbacks); | |
| } | |
| const BeginProcessingQueue = () => { | |
| if (!IsAnythingQueued()) { | |
| EndProcessingQueue(); | |
| return; | |
| } | |
| SnapshotQueuedData(); | |
| ProcessUpdateState(); | |
| } | |
| // check if max queue exceeded work is still pending - if so, | |
| if (MAX_PROCESS_QUEUE_PASSES > 0 && processQueuePasses >= MAX_PROCESS_QUEUE_PASSES) { | |
| // hit the process limit! can't continue | |
| if (outputErrors && IsAnythingQueued()) { | |
| const warning = | |
| 'WARNING: passes on queued InputHandler work have exceeded ' + | |
| `the max of ${MAX_PROCESS_QUEUE_PASSES} and work still remains, ` + | |
| 'investigate for infinite loops. Remaining work: \n' + | |
| `queuedUpdate: ${queuedUpdate}, \n` + | |
| `queuedChange: ${queuedChange}, \n` + | |
| `queuedSelectInputData != null: ${queuedSelectInputData != null}, \n` + | |
| `queuedCallbacks.size: ${queuedCallbacks.size} ` + | |
| `${queuedCallbacks.size === 0 ? '' : `\nqueuedCallbacks: ${queuedCallbacks}`}`; | |
| console.trace(warning); | |
| } | |
| EndProcessingQueue(); | |
| return; | |
| } | |
| // increment queue pass count, start processing | |
| processQueuePasses++; | |
| processingQueue = true; | |
| // friendly warning | |
| if (processQueuePasses === 100) { | |
| console.warn('Hey babe, this is the hundredth ProcessQueue pass, maybe you should look into that...'); | |
| } | |
| // begin processing queue | |
| queueMicrotask(BeginProcessingQueue); | |
| } | |
| // done! return | |
| return inputHandler; | |
| } | |
| /** | |
| * @type {Set<InputHandlerTarget>} | |
| */ | |
| const inputHandlerTargets = new Set(); | |
| /** | |
| * Check if the given element is targeted by `InputHandler` | |
| * @param {HTMLElement} element | |
| * @returns {boolean} | |
| */ | |
| export function IsElementHandled(element) { | |
| return GetIHTarget(element) != null; | |
| } | |
| /** | |
| * Get the {@linkcode InputHandler} object linked to the given element. | |
| * If the element isn't targeted by the `InputHandler`, returns `null` | |
| * @param {HTMLElement} element | |
| * @returns {InputHandler|null} | |
| * @see {@linkcode IsElementHandled} | |
| */ | |
| export function GetInputHandler(element) { | |
| const ihTarget = GetIHTarget(element); | |
| if (ihTarget == null || ihTarget.inputHandler == null) { | |
| return null; | |
| } | |
| return ihTarget.inputHandler; | |
| } | |
| /** | |
| * Get the {@linkcode InputHandlerTarget} related to the given element. | |
| * If none found (eg the element isn't targeted), returns `null` | |
| * @param {HTMLElement} element | |
| * @returns {InputHandlerTarget|null} | |
| */ | |
| function GetIHTarget(element) { | |
| for (const target of inputHandlerTargets) { | |
| if (!target || !target.inputHandler || !target.inputHandler.element) { continue; } | |
| if (target.inputHandler.element === element) { return target; } | |
| } | |
| return null; | |
| } | |
| /** | |
| * | |
| * @param {InputHandler} inputHandler | |
| * @param {(event:KeyboardEvent) => void} onKeyDown | |
| * @param {(event:KeyboardEvent) => void} onKeyUp | |
| * @param {(event:PointerEvent) => void} onPointerDown | |
| * @returns | |
| */ | |
| function Target(inputHandler, onKeyDown, onKeyUp, onPointerDown) { | |
| if (IsElementHandled(inputHandler.element)) { return; } | |
| inputHandlerTargets.add({ inputHandler, onKeyDown, onKeyUp, onPointerDown }) | |
| if (!_globalEventListenersAdded) { | |
| _globalEventListenersAdded = true; | |
| document.addEventListener('keydown', onGlobalKeyDown, true); | |
| document.addEventListener('keyup', onGlobalKeyUp, true); | |
| document.addEventListener('pointerdown', onGlobalPointerDown, true); | |
| } | |
| } | |
| /** | |
| * Untargets the given element from the InputHandler, | |
| * removing it from {@linkcode inputHandlerTargets} | |
| * and (optionally) if no targets are left, removing | |
| * global event listeners. | |
| * @param {HTMLElement} element | |
| */ | |
| function Untarget(element) { | |
| const target = GetIHTarget(element); | |
| if (target != null) { | |
| inputHandlerTargets.delete(target); | |
| } | |
| if (REMOVE_GLOBAL_LISTENERS_ON_ZERO_TARGETS && inputHandlerTargets.size === 0) { | |
| if (_globalEventListenersAdded) { | |
| document.removeEventListener('keydown', onGlobalKeyDown, true); | |
| document.removeEventListener('keyup', onGlobalKeyUp, true); | |
| document.removeEventListener('pointerdown', onGlobalPointerDown, true); | |
| _globalEventListenersAdded = false; | |
| } | |
| } | |
| } | |
| /** @param {KeyboardEvent} event */ | |
| function onGlobalKeyDown(event) { | |
| for (const target of inputHandlerTargets) { | |
| if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION && | |
| !target.inputHandler.element.isConnected) { | |
| continue; | |
| } | |
| if (typeof target.onKeyDown === 'function') { | |
| target.onKeyDown(event); | |
| } | |
| } | |
| } | |
| /** @param {KeyboardEvent} event */ | |
| function onGlobalKeyUp(event) { | |
| for (const target of inputHandlerTargets) { | |
| if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION && | |
| !target.inputHandler.element.isConnected) { | |
| continue; | |
| } | |
| if (typeof target.onKeyUp === 'function') { | |
| target.onKeyUp(event); | |
| } | |
| } | |
| } | |
| /** @param {PointerEvent} event */ | |
| function onGlobalPointerDown(event) { | |
| for (const target of inputHandlerTargets) { | |
| if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION && | |
| !target.inputHandler.element.isConnected) { | |
| continue; | |
| } | |
| if (typeof target.onPointerDown === 'function') { | |
| target.onPointerDown(event); | |
| } | |
| } | |
| } | |
| /** | |
| * Checks if a given point is within the given | |
| * `DOMRect` (or `HTMLElement`'s `DOMRect`) | |
| * - **Note:** It's presumed that coordinates | |
| * and the rect share the same coordinate space. | |
| * @param {DOMRect|HTMLElement} rect | |
| * @param {number} clientX | |
| * @param {number} clientY | |
| * @returns {boolean} | |
| */ | |
| function IsPointInsideElement(rect, clientX, clientY) { | |
| if (rect == null) { return false; } | |
| if (rect instanceof HTMLElement) { rect = rect.getBoundingClientRect(); } | |
| return ( | |
| clientX >= rect.left && | |
| clientX <= rect.right && | |
| clientY >= rect.top && | |
| clientY <= rect.bottom | |
| ); | |
| } | |
| /** Gets an {@linkcode InputState} with default params @returns {InputState} */ | |
| const DefaultState = () => { | |
| return { | |
| highlighted: DEFAULT_VALUE_HIGHLIGHTED, | |
| active: DEFAULT_VALUE_ACTIVE, | |
| hovered: DEFAULT_VALUE_HOVERED, | |
| focusWithin: DEFAULT_VALUE_FOCUS_WITHIN, | |
| focusVisibleWithin: DEFAULT_VALUE_FOCUS_VISIBLE_WITHIN, | |
| pointerHeld: DEFAULT_VALUE_POINTER_HELD, | |
| keyHeld: DEFAULT_VALUE_KEY_HELD, | |
| lastInputMode: DEFAULT_VALUE_LAST_INPUT_MODE, | |
| }; | |
| } | |
| /** | |
| * Ensures an {@linkcode InputMouseButtons} object has | |
| * both any custom values and all default values for | |
| * anything missing. | |
| * @param {InputMouseButtons|null|undefined} mouseButtons | |
| * @returns {InputMouseButtons} | |
| */ | |
| const ValidateMouseButtons = (mouseButtons) => { | |
| const defaultMouseButtons = DefaultMouseButtons(); | |
| const { | |
| left = defaultMouseButtons.left, | |
| middle = defaultMouseButtons.middle, | |
| right = defaultMouseButtons.right, | |
| } = mouseButtons != null ? mouseButtons : {}; | |
| return { left, middle, right }; | |
| } | |
| /** @returns {InputMouseButtons} */ | |
| const DefaultMouseButtons = () => { | |
| return { | |
| left: DEFAULT_MOUSE_BUTTONS_LEFT, | |
| middle: DEFAULT_MOUSE_BUTTONS_MIDDLE, | |
| right: DEFAULT_MOUSE_BUTTONS_RIGHT, | |
| } | |
| } | |
| /** | |
| * Ensures an {@linkcode InputAttributesOptions} has | |
| * both any custom values and all default values for | |
| * anything missing. | |
| * @param {InputAttributesOptions|null|undefined} attributes | |
| * @returns {InputAttributesOptions} | |
| */ | |
| const ValidateAttributesOptions = (attributes) => { | |
| const defaultAttributes = DefaultAttributesOptions(); | |
| if (attributes == null) { return defaultAttributes; } | |
| return { | |
| ...defaultAttributes, | |
| ...attributes, | |
| highlighted: { | |
| ...defaultAttributes.highlighted, | |
| ...attributes.highlighted, | |
| }, | |
| active: { | |
| ...defaultAttributes.active, | |
| ...attributes.active, | |
| }, | |
| hovered: { | |
| ...defaultAttributes.hovered, | |
| ...attributes.hovered, | |
| }, | |
| focusWithin: { | |
| ...defaultAttributes.focusWithin, | |
| ...attributes.focusWithin, | |
| }, | |
| focusVisibleWithin: { | |
| ...defaultAttributes.focusVisibleWithin, | |
| ...attributes.focusVisibleWithin, | |
| }, | |
| pointerHeld: { | |
| ...defaultAttributes.pointerHeld, | |
| ...attributes.pointerHeld, | |
| }, | |
| keyHeld: { | |
| ...defaultAttributes.keyHeld, | |
| ...attributes.keyHeld, | |
| }, | |
| lastInputMode: { | |
| ...defaultAttributes.lastInputMode, | |
| ...attributes.lastInputMode, | |
| }, | |
| }; | |
| } | |
| /** @returns {InputAttributesOptions} */ | |
| const DefaultAttributesOptions = () => { | |
| return { | |
| prefix: DEFAULT_ATTR_OPTIONS_PREFIX, | |
| enabled: DEFAULT_ATTR_OPTIONS_ENABLED, | |
| highlighted: DefaultAttribute(DEFAULT_ATTR_NAME_HIGHLIGHTED, DEFAULT_ATTR_ENABLED_HIGHLIGHTED), | |
| active: DefaultAttribute(DEFAULT_ATTR_NAME_ACTIVE, DEFAULT_ATTR_ENABLED_ACTIVE), | |
| hovered: DefaultAttribute(DEFAULT_ATTR_NAME_HOVERED, DEFAULT_ATTR_ENABLED_HOVERED), | |
| focusWithin: DefaultAttribute(DEFAULT_ATTR_NAME_FOCUS_WITHIN, DEFAULT_ATTR_ENABLED_FOCUS_WITHIN), | |
| focusVisibleWithin: DefaultAttribute(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN, DEFAULT_ATTR_ENABLED_FOCUS_VISIBLE_WITHIN), | |
| pointerHeld: DefaultAttribute(DEFAULT_ATTR_NAME_POINTER_HELD, DEFAULT_ATTR_ENABLED_POINTER_HELD), | |
| keyHeld: DefaultAttribute(DEFAULT_ATTR_NAME_KEY_HELD, DEFAULT_ATTR_ENABLED_KEY_HELD), | |
| lastInputMode: DefaultAttribute(DEFAULT_ATTR_NAME_LAST_INPUT_MODE, DEFAULT_ATTR_ENABLED_LAST_INPUT_MODE), | |
| } | |
| } | |
| /** | |
| * @param {string} name | |
| * @param {boolean} [enabled=DEFAULT_ATTR_ENABLED] | |
| * @param {boolean} [usePrefix=DEFAULT_ATTR_USE_PREFIX] | |
| * @returns {InputAttribute} | |
| */ | |
| const DefaultAttribute = (name, enabled = DEFAULT_ATTR_ENABLED, usePrefix = DEFAULT_ATTR_USE_PREFIX) => { | |
| return { | |
| name, | |
| enabled, | |
| usePrefix, | |
| } | |
| } | |
| /** Default value for {@link InputState.hovered} @readonly */ | |
| export const DEFAULT_VALUE_HIGHLIGHTED = false; | |
| /** Default value for {@link InputState.focusWithin} @readonly */ | |
| export const DEFAULT_VALUE_ACTIVE = false; | |
| /** Default value for {@link InputState.focusVisibleWithin} @readonly */ | |
| export const DEFAULT_VALUE_HOVERED = false; | |
| /** Default value for {@link InputState.pointerHeld} @readonly */ | |
| export const DEFAULT_VALUE_FOCUS_WITHIN = false; | |
| /** Default value for {@link InputState.keyHeld} @readonly */ | |
| export const DEFAULT_VALUE_FOCUS_VISIBLE_WITHIN = false; | |
| /** Default value for {@link InputState.active} @readonly */ | |
| export const DEFAULT_VALUE_POINTER_HELD = false; | |
| /** Default value for {@link InputState.highlighted} @readonly */ | |
| export const DEFAULT_VALUE_KEY_HELD = false; | |
| /** Default value for {@link InputState.lastInputMode} @readonly */ | |
| export const DEFAULT_VALUE_LAST_INPUT_MODE = 'pointer'; | |
| /** Default {@link InputAttribute.name name} for the `highlighted` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_HIGHLIGHTED = 'highlight'; | |
| /** Default {@link InputAttribute.name name} for the `active` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_ACTIVE = 'active'; | |
| /** Default {@link InputAttribute.name name} for the `hovered` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_HOVERED = 'hover'; | |
| /** Default {@link InputAttribute.name name} for the `focusWithin` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_FOCUS_WITHIN = 'focus-within'; | |
| /** Default {@link InputAttribute.name name} for the `focusVisibleWithin` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN = 'focus-visible-within'; | |
| /** Default {@link InputAttribute.name name} for the `pointerHeld` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_POINTER_HELD = 'pointer-held'; | |
| /** Default {@link InputAttribute.name name} for the `keyHeld` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_KEY_HELD = 'key-held'; | |
| /** Default {@link InputAttribute.name name} for the `lastInputMode` {@linkcode InputAttribute} @readonly */ | |
| export const DEFAULT_ATTR_NAME_LAST_INPUT_MODE = 'input-mode'; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `highlighted` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_HIGHLIGHTED = true; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `active` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_ACTIVE = true; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `hovered` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_HOVERED = false; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `keyHeld` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_KEY_HELD = false; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `focusWithin` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_FOCUS_WITHIN = false; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `focusVisibleWithin` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_FOCUS_VISIBLE_WITHIN = false; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `pointerHeld` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_POINTER_HELD = false; | |
| /** Default {@linkcode InputAttribute.enabled enabled} value for the `lastInputMode` {@linkcode InputAttribute} */ | |
| export let DEFAULT_ATTR_ENABLED_LAST_INPUT_MODE = false; | |
| /** Default {@linkcode InputAttributesOptions.prefix} value to use */ | |
| export let DEFAULT_ATTR_OPTIONS_PREFIX = ''; | |
| /** Default {@linkcode InputAttributesOptions.enabled} value to use */ | |
| export let DEFAULT_ATTR_OPTIONS_ENABLED = true; | |
| /** Default {@linkcode InputAttribute.enabled} value to use */ | |
| export let DEFAULT_ATTR_ENABLED = true; | |
| /** Default {@linkcode InputAttribute.usePrefix} value to use */ | |
| export let DEFAULT_ATTR_USE_PREFIX = true; | |
| /** Default {@linkcode InputHandlerOptions.activeAttrOverridesHoverAndHighlightAttrs} value to use */ | |
| export let DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS = true; | |
| /** Default {@linkcode InputMouseButtons.left} value to use */ | |
| export let DEFAULT_MOUSE_BUTTONS_LEFT = true; | |
| /** Default {@linkcode InputMouseButtons.middle} value to use */ | |
| export let DEFAULT_MOUSE_BUTTONS_MIDDLE = false; | |
| /** Default {@linkcode InputMouseButtons.right} value to use */ | |
| export let DEFAULT_MOUSE_BUTTONS_RIGHT = false; | |
| /** track whether or not global event listeners are added @type {boolean} */ | |
| let _globalEventListenersAdded = false; | |
| /** | |
| * When the last element is removed from input handling, | |
| * should global event listeners be removed? | |
| */ | |
| const REMOVE_GLOBAL_LISTENERS_ON_ZERO_TARGETS = false; | |
| /** | |
| * When passing global events to {@linkcode InputHandlerTarget} | |
| * instances, should the event invocation be stopped if the | |
| * target element is NOT connected to the DOM? | |
| */ | |
| const EVENT_INVOCATION_REQUIRES_DOM_CONNECTION = true; | |
| /** | |
| * Max number of times {@linkcode ProcessQueue} can run in one tick. | |
| * If `0`, it can run infinitely (warning output at hundredth pass) | |
| */ | |
| const MAX_PROCESS_QUEUE_PASSES = 3; | |
| /** Hosted on GitHub Gist | |
| * https://gist.github.com/nickyonge/728b4563fdb4600ffa57527e345de314 */ | |
| /** | |
| * LICENSE INFO | |
| * | |
| * This script was written by Nick Yonge, 2026, and is released | |
| * under the terms of The Unlicense: | |
| * | |
| * This is free and unencumbered software released into the public domain. | |
| * | |
| * Anyone is free to copy, modify, publish, use, compile, sell, or | |
| * distribute this software, either in source code form or as a compiled | |
| * binary, for any purpose, commercial or non-commercial, and by any | |
| * means. | |
| * | |
| * In jurisdictions that recognize copyright laws, the author or authors | |
| * of this software dedicate any and all copyright interest in the | |
| * software to the public domain. We make this dedication for the benefit | |
| * of the public at large and to the detriment of our heirs and | |
| * successors. We intend this dedication to be an overt act of | |
| * relinquishment in perpetuity of all present and future rights to this | |
| * software under copyright law. | |
| * | |
| * 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 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. | |
| * | |
| * For more information, please refer to <https://unlicense.org/> | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment