| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // This module provides hotkey detection using type-safe human-readable strings. |
| // |
| // The basic premise is this: Let's say you have a KeyboardEvent |event|, and |
| // you wanted to check whether it contains the hotkey 'Ctrl+O', you can execute |
| // the following function: |
| // |
| // checkHotkey('Shift+O', event); |
| // |
| // ...which will evaluate to true if 'Shift+O' is discovered in the event. |
| // |
| // This will only trigger when O is pressed while the Shift key is held, not O |
| // on it's own, and not if other modifiers such as Alt or Ctrl were also held. |
| // |
| // Modifiers include 'Shift', 'Ctrl', 'Alt', and 'Mod': |
| // - 'Shift' and 'Ctrl' are fairly self explanatory. |
| // - 'Alt' is 'option' on Macs. |
| // - 'Mod' is a special modifier which means 'Ctrl' on PC and 'Cmd' on Mac. |
| // Modifiers may be combined in various ways - check the |Modifier| type. |
| // |
| // By default hotkeys will not register when the event target is inside an |
| // editable element, such as <textarea> and some <input>s. |
| // Prefixing a hotkey with a bang '!' relaxes is requirement, meaning the hotkey |
| // will register inside editable fields. |
| |
| // E.g. '!Mod+Shift+P' will register when pressed when a text box has focus but |
| // 'Mod+Shift+P' (no bang) will not. |
| // Warning: Be careful using this with single key hotkeys, e.g. '!P' is usually |
| // never what you want! |
| // |
| // Some single-key hotkeys like '?' and '!' normally cannot be activated in |
| // without also pressing shift key, so the shift requirement is relaxed for |
| // these keys. |
| |
| import {elementIsEditable} from './dom_utils'; |
| |
| type Alphabet = |
| | 'A' |
| | 'B' |
| | 'C' |
| | 'D' |
| | 'E' |
| | 'F' |
| | 'G' |
| | 'H' |
| | 'I' |
| | 'J' |
| | 'K' |
| | 'L' |
| | 'M' |
| | 'N' |
| | 'O' |
| | 'P' |
| | 'Q' |
| | 'R' |
| | 'S' |
| | 'T' |
| | 'U' |
| | 'V' |
| | 'W' |
| | 'X' |
| | 'Y' |
| | 'Z'; |
| type Number = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; |
| type Special = |
| | 'Enter' |
| | 'Escape' |
| | 'Delete' |
| | '/' |
| | '?' |
| | '!' |
| | 'Space' |
| | 'ArrowUp' |
| | 'ArrowDown' |
| | 'ArrowLeft' |
| | 'ArrowRight' |
| | '[' |
| | ']'; |
| export type Key = Alphabet | Number | Special; |
| export type Modifier = |
| | '' |
| | 'Mod+' |
| | 'Shift+' |
| | 'Ctrl+' |
| | 'Alt+' |
| | 'Mod+Shift+' |
| | 'Mod+Alt+' |
| | 'Mod+Shift+Alt+' |
| | 'Ctrl+Shift+' |
| | 'Ctrl+Alt' |
| | 'Ctrl+Shift+Alt'; |
| type AllowInEditable = '!' | ''; |
| export type Hotkey = `${AllowInEditable}${Modifier}${Key}`; |
| |
| // The following list of keys cannot be pressed wither with or without the |
| // presence of the Shift modifier on most keyboard layouts. Thus we should |
| // ignore shift in these cases. |
| const shiftExceptions = [ |
| '0', |
| '1', |
| '2', |
| '3', |
| '4', |
| '5', |
| '6', |
| '7', |
| '8', |
| '9', |
| '/', |
| '?', |
| '!', |
| '[', |
| ']', |
| ]; |
| |
| const macModifierStrings: ReadonlyMap<Modifier, string> = new Map< |
| Modifier, |
| string |
| >([ |
| ['', ''], |
| ['Mod+', '⌘'], |
| ['Shift+', '⇧'], |
| ['Ctrl+', '⌃'], |
| ['Alt+', '⌥'], |
| ['Mod+Shift+', '⌘⇧'], |
| ['Mod+Alt+', '⌘⌥'], |
| ['Mod+Shift+Alt+', '⌘⇧⌥'], |
| ['Ctrl+Shift+', '⌃⇧'], |
| ['Ctrl+Alt', '⌃⌥'], |
| ['Ctrl+Shift+Alt', '⌃⇧⌥'], |
| ]); |
| |
| const pcModifierStrings: ReadonlyMap<Modifier, string> = new Map< |
| Modifier, |
| string |
| >([ |
| ['', ''], |
| ['Mod+', 'Ctrl+'], |
| ['Mod+Shift+', 'Ctrl+Shift+'], |
| ['Mod+Alt+', 'Ctrl+Alt+'], |
| ['Mod+Shift+Alt+', 'Ctrl+Shift+Alt+'], |
| ]); |
| |
| // Represents a deconstructed hotkey. |
| export interface HotkeyParts { |
| // The name of the primary key of this hotkey. |
| key: Key; |
| |
| // All the modifiers as one chunk. E.g. 'Mod+Shift+'. |
| modifier: Modifier; |
| |
| // Whether this hotkey should register when the event target is inside an |
| // editable field. |
| allowInEditable: boolean; |
| } |
| |
| // Deconstruct a hotkey from its string representation into its constituent |
| // parts. |
| export function parseHotkey(hotkey: Hotkey): HotkeyParts | undefined { |
| const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/; |
| const result = hotkey.match(regex); |
| |
| if (!result) { |
| return undefined; |
| } |
| |
| return { |
| allowInEditable: result[1] === '!', |
| modifier: result[2] as Modifier, |
| key: result[3] as Key, |
| }; |
| } |
| |
| // Print the hotkey in a human readable format. |
| export function formatHotkey( |
| hotkey: Hotkey, |
| spoof?: Platform, |
| ): string | undefined { |
| const parsed = parseHotkey(hotkey); |
| return parsed && formatHotkeyParts(parsed, spoof); |
| } |
| |
| function formatHotkeyParts( |
| {modifier, key}: HotkeyParts, |
| spoof?: Platform, |
| ): string { |
| return `${formatModifier(modifier, spoof)}${key}`; |
| } |
| |
| function formatModifier(modifier: Modifier, spoof?: Platform): string { |
| const platform = spoof || getPlatform(); |
| const strings = platform === 'Mac' ? macModifierStrings : pcModifierStrings; |
| return strings.get(modifier) ?? modifier; |
| } |
| |
| // Like |KeyboardEvent| but all fields apart from |key| are optional. |
| export type KeyboardEventLike = Pick<KeyboardEvent, 'key'> & |
| Partial<KeyboardEvent>; |
| |
| // Check whether |hotkey| is present in the keyboard event |event|. |
| export function checkHotkey( |
| hotkey: Hotkey, |
| event: KeyboardEventLike, |
| spoofPlatform?: Platform, |
| ): boolean { |
| const result = parseHotkey(hotkey); |
| if (!result) { |
| return false; |
| } |
| |
| const {key, allowInEditable} = result; |
| const {target = null} = event; |
| |
| const inEditable = elementIsEditable(target); |
| if (inEditable && !allowInEditable) { |
| return false; |
| } |
| return compareKeys(event, key) && checkMods(event, result, spoofPlatform); |
| } |
| |
| // Return true if |key| matches the event's key. |
| function compareKeys(e: KeyboardEventLike, key: Key): boolean { |
| return e.key.toLowerCase() === key.toLowerCase(); |
| } |
| |
| // Return true if modifiers specified in |mods| match those in the event. |
| function checkMods( |
| event: KeyboardEventLike, |
| hotkey: HotkeyParts, |
| spoofPlatform?: Platform, |
| ): boolean { |
| const platform = spoofPlatform ?? getPlatform(); |
| |
| const {key, modifier} = hotkey; |
| |
| const { |
| ctrlKey = false, |
| altKey = false, |
| shiftKey = false, |
| metaKey = false, |
| } = event; |
| |
| const wantShift = modifier.includes('Shift'); |
| const wantAlt = modifier.includes('Alt'); |
| const wantCtrl = |
| platform === 'Mac' |
| ? modifier.includes('Ctrl') |
| : modifier.includes('Ctrl') || modifier.includes('Mod'); |
| const wantMeta = platform === 'Mac' && modifier.includes('Mod'); |
| |
| // For certain keys we relax the shift requirement, as they usually cannot be |
| // pressed without the shift key on English keyboards. |
| const shiftOk = |
| shiftExceptions.includes(key as string) || shiftKey === wantShift; |
| |
| return ( |
| metaKey === wantMeta && |
| Boolean(shiftOk) && |
| altKey === wantAlt && |
| ctrlKey === wantCtrl |
| ); |
| } |
| |
| export type Platform = 'Mac' | 'PC'; |
| |
| // Get the current platform (PC or Mac). |
| export function getPlatform(): Platform { |
| return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC'; |
| } |
| |
| // Returns a cross-platform check for whether the event has "Mod" key pressed |
| // (e.g. as a part of Mod-Click UX pattern). |
| // On Mac, Mod-click is actually Command-click and on PC it's Control-click, |
| // so this function handles this for all platforms. |
| export function hasModKey(event: { |
| readonly metaKey: boolean; |
| readonly ctrlKey: boolean; |
| }): boolean { |
| if (getPlatform() === 'Mac') { |
| return event.metaKey; |
| } else { |
| return event.ctrlKey; |
| } |
| } |
| |
| export function modKey(): {metaKey?: boolean; ctrlKey?: boolean} { |
| if (getPlatform() === 'Mac') { |
| return {metaKey: true}; |
| } else { |
| return {ctrlKey: true}; |
| } |
| } |