// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ /** * @packageDocumentation * @module commands */ import { ArrayExt } from '@lumino/algorithm'; import { JSONExt, ReadonlyJSONObject, ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { DisposableDelegate, IDisposable } from '@lumino/disposable'; import { Platform, Selector } from '@lumino/domutils'; import { getKeyboardLayout } from '@lumino/keyboard'; import { ISignal, Signal } from '@lumino/signaling'; import { VirtualElement } from '@lumino/virtualdom'; /** * An object which manages a collection of commands. * * #### Notes * A command registry can be used to populate a variety of action-based * widgets, such as command palettes, menus, and toolbars. */ export class CommandRegistry { /** * A signal emitted when a command has changed. * * #### Notes * This signal is useful for visual representations of commands which * need to refresh when the state of a relevant command has changed. */ get commandChanged(): ISignal { return this._commandChanged; } /** * A signal emitted when a command has executed. * * #### Notes * Care should be taken when consuming this signal. The command system is used * by many components for many user actions. Handlers registered with this * signal must return quickly to ensure the overall application remains responsive. */ get commandExecuted(): ISignal { return this._commandExecuted; } /** * A signal emitted when a key binding is changed. */ get keyBindingChanged(): ISignal< this, CommandRegistry.IKeyBindingChangedArgs > { return this._keyBindingChanged; } /** * A read-only array of the key bindings in the registry. */ get keyBindings(): ReadonlyArray { return this._keyBindings; } /** * List the ids of the registered commands. * * @returns A new array of the registered command ids. */ listCommands(): string[] { return Array.from(this._commands.keys()); } /** * Test whether a specific command is registered. * * @param id - The id of the command of interest. * * @returns `true` if the command is registered, `false` otherwise. */ hasCommand(id: string): boolean { return this._commands.has(id); } /** * Add a command to the registry. * * @param id - The unique id of the command. * * @param options - The options for the command. * * @returns A disposable which will remove the command. * * @throws An error if the given `id` is already registered. */ addCommand( id: string, options: CommandRegistry.ICommandOptions ): IDisposable { // Throw an error if the id is already registered. if (this._commands.has(id)) { throw new Error(`Command '${id}' already registered.`); } // Add the command to the registry. this._commands.set(id, Private.createCommand(options)); // Emit the `commandChanged` signal. this._commandChanged.emit({ id, type: 'added' }); // Return a disposable which will remove the command. return new DisposableDelegate(() => { // Remove the command from the registry. this._commands.delete(id); // Emit the `commandChanged` signal. this._commandChanged.emit({ id, type: 'removed' }); }); } /** * Notify listeners that the state of a command has changed. * * @param id - The id of the command which has changed. If more than * one command has changed, this argument should be omitted. * * @throws An error if the given `id` is not registered. * * #### Notes * This method should be called by the command author whenever the * application state changes such that the results of the command * metadata functions may have changed. * * This will cause the `commandChanged` signal to be emitted. */ notifyCommandChanged(id?: string): void { if (id !== undefined && !this._commands.has(id)) { throw new Error(`Command '${id}' is not registered.`); } this._commandChanged.emit({ id, type: id ? 'changed' : 'many-changed' }); } /** * Get the description for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The description for the command. */ describedBy( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): Promise { let cmd = this._commands.get(id); return Promise.resolve( cmd?.describedBy.call(undefined, args) ?? { args: null } ); } /** * Get the display label for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The display label for the command, or an empty string * if the command is not registered. */ label( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd?.label.call(undefined, args) ?? ''; } /** * Get the mnemonic index for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The mnemonic index for the command, or `-1` if the * command is not registered. */ mnemonic( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): number { let cmd = this._commands.get(id); return cmd ? cmd.mnemonic.call(undefined, args) : -1; } /** * Get the icon renderer for a specific command. * * DEPRECATED: if set to a string value, the .icon field will * function as an alias for the .iconClass field, for backwards * compatibility. In the future when this is removed, the default * return type will become undefined. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The icon renderer for the command or `undefined`. */ icon( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): VirtualElement.IRenderer | undefined { return this._commands.get(id)?.icon.call(undefined, args); } /** * Get the icon class for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The icon class for the command, or an empty string if * the command is not registered. */ iconClass( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd ? cmd.iconClass.call(undefined, args) : ''; } /** * Get the icon label for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The icon label for the command, or an empty string if * the command is not registered. */ iconLabel( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd ? cmd.iconLabel.call(undefined, args) : ''; } /** * Get the short form caption for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The caption for the command, or an empty string if the * command is not registered. */ caption( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd ? cmd.caption.call(undefined, args) : ''; } /** * Get the usage help text for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The usage text for the command, or an empty string if * the command is not registered. */ usage( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd ? cmd.usage.call(undefined, args) : ''; } /** * Get the extra class name for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The class name for the command, or an empty string if * the command is not registered. */ className( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): string { let cmd = this._commands.get(id); return cmd ? cmd.className.call(undefined, args) : ''; } /** * Get the dataset for a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns The dataset for the command, or an empty dataset if * the command is not registered. */ dataset( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): CommandRegistry.Dataset { let cmd = this._commands.get(id); return cmd ? cmd.dataset.call(undefined, args) : {}; } /** * Test whether a specific command is enabled. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns A boolean indicating whether the command is enabled, * or `false` if the command is not registered. */ isEnabled( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): boolean { let cmd = this._commands.get(id); return cmd ? cmd.isEnabled.call(undefined, args) : false; } /** * Test whether a specific command is toggled. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns A boolean indicating whether the command is toggled, * or `false` if the command is not registered. */ isToggled( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): boolean { let cmd = this._commands.get(id); return cmd ? cmd.isToggled.call(undefined, args) : false; } /** * Test whether a specific command is toggleable. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns A boolean indicating whether the command is toggleable, * or `false` if the command is not registered. */ isToggleable( id: string, args: ReadonlyJSONObject = JSONExt.emptyObject ): boolean { let cmd = this._commands.get(id); return cmd ? cmd.isToggleable : false; } /** * Test whether a specific command is visible. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns A boolean indicating whether the command is visible, * or `false` if the command is not registered. */ isVisible( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): boolean { let cmd = this._commands.get(id); return cmd ? cmd.isVisible.call(undefined, args) : false; } /** * Execute a specific command. * * @param id - The id of the command of interest. * * @param args - The arguments for the command. * * @returns A promise which resolves with the result of the command. * * #### Notes * The promise will reject if the command throws an exception, * or if the command is not registered. */ execute( id: string, args: ReadonlyPartialJSONObject = JSONExt.emptyObject ): Promise { // Reject if the command is not registered. let cmd = this._commands.get(id); if (!cmd) { return Promise.reject(new Error(`Command '${id}' not registered.`)); } // Execute the command and reject if an exception is thrown. let value: any; try { value = cmd.execute.call(undefined, args); } catch (err) { value = Promise.reject(err); } // Create the return promise which resolves the result. let result = Promise.resolve(value); // Emit the command executed signal. this._commandExecuted.emit({ id, args, result }); // Return the result promise to the caller. return result; } /** * Add a key binding to the registry. * * @param options - The options for creating the key binding. * * @returns A disposable which removes the added key binding. * * #### Notes * If multiple key bindings are registered for the same sequence, the * binding with the highest selector specificity is executed first. A * tie is broken by using the most recently added key binding. * * Ambiguous key bindings are resolved with a timeout. As an example, * suppose two key bindings are registered: one with the key sequence * `['Ctrl D']`, and another with `['Ctrl D', 'Ctrl W']`. If the user * presses `Ctrl D`, the first binding cannot be immediately executed * since the user may intend to complete the chord with `Ctrl W`. For * such cases, a timer is used to allow the chord to be completed. If * the chord is not completed before the timeout, the first binding * is executed. */ addKeyBinding(options: CommandRegistry.IKeyBindingOptions): IDisposable { // Create the binding for the given options. let binding = Private.createKeyBinding(options); // Add the key binding to the bindings array. this._keyBindings.push(binding); // Emit the `bindingChanged` signal. this._keyBindingChanged.emit({ binding, type: 'added' }); // Return a disposable which will remove the binding. return new DisposableDelegate(() => { // Remove the binding from the array. ArrayExt.removeFirstOf(this._keyBindings, binding); // Emit the `bindingChanged` signal. this._keyBindingChanged.emit({ binding, type: 'removed' }); }); } /** * Process a `'keydown'` event and invoke a matching key binding. * * @param event - The event object for a `'keydown'` event. * * #### Notes * This should be called in response to a `'keydown'` event in order * to invoke the command for the best matching key binding. * * The registry **does not** install its own listener for `'keydown'` * events. This allows the application full control over the nodes * and phase for which the registry processes `'keydown'` events. * * When the keydown event is processed, if the event target or any of its * ancestor nodes has a `data-lm-suppress-shortcuts` attribute, its keydown * events will not invoke commands. */ processKeydownEvent(event: KeyboardEvent): void { // Bail immediately if playing back keystrokes. if (event.defaultPrevented || this._replaying) { return; } // Get the normalized keystroke for the event. const keystroke = CommandRegistry.keystrokeForKeydownEvent(event); // If the keystroke is not valid for the keyboard layout, replay // any suppressed events and clear the pending state. if (!keystroke) { this._replayKeydownEvents(); this._clearPendingState(); return; } // Check that only mod key(s) have been pressed. if (CommandRegistry.isModifierKeyPressed(event)) { // Find the exact match for the modifier keys. let { exact } = Private.matchKeyBinding( this._keyBindings, [keystroke], event ); if (exact) { // If the mod keys match an exact shortcut, start a dedicated timer. event.preventDefault(); event.stopPropagation(); this._startModifierTimer(exact); } else { // Otherwise stop potential existing timer. this._clearModifierTimer(); } return; } // Add the keystroke to the current key sequence. this._keystrokes.push(keystroke); // Find the exact and partial matches for the key sequence. const { exact, partial } = Private.matchKeyBinding( this._keyBindings, this._keystrokes, event ); // Whether there is any partial match. const hasPartial = partial.length !== 0; // If there is no exact match and no partial match, replay // any suppressed events and clear the pending state. if (!exact && !hasPartial) { this._replayKeydownEvents(); this._clearPendingState(); return; } // Stop propagation of the event. If there is only a partial match, // the event will be replayed if a final exact match never occurs. if (exact?.preventDefault || partial.some(match => match.preventDefault)) { event.preventDefault(); event.stopPropagation(); } // Store the event for possible playback in the future and for // the use in execution hold check. this._keydownEvents.push(event); // If there is an exact match but no partial match, the exact match // can be dispatched immediately. The pending state is cleared so // the next key press starts from the default state. if (exact && !hasPartial) { this._executeKeyBinding(exact); this._clearPendingState(); return; } // If there is both an exact match and a partial match, the exact // match is stored for future dispatch in case the timer expires // before a more specific match is triggered. if (exact) { this._exactKeyMatch = exact; } // (Re)start the timer to dispatch the most recent exact match // in case the partial match fails to result in an exact match. this._startTimer(); } /** * Delay the execution of any command matched against the given 'keydown' event * until the `permission` to execute is granted. * * @param event - The event object for a `'keydown'` event. * @param permission - The promise with value indicating whether to proceed with the execution. * * ### Note * This enables the caller of `processKeydownEvent` to asynchronously prevent the * execution of the command based on external events. */ holdKeyBindingExecution(event: KeyboardEvent, permission: Promise) { this._holdKeyBindingPromises.set(event, permission); } /** * Process a ``keyup`` event to clear the timer on the modifier, if it exists. * * @param event - The event object for a `'keydown'` event. */ processKeyupEvent(event: KeyboardEvent): void { this._clearModifierTimer(); } /** * Start or restart the timeout on the modifier keys. * * This timeout will end only if the keys are hold. */ private _startModifierTimer(exact: CommandRegistry.IKeyBinding): void { this._clearModifierTimer(); this._timerModifierID = window.setTimeout(() => { this._executeKeyBinding(exact); }, Private.modifierkeyTimeOut); } /** * Clear the timeout on modifier keys. */ private _clearModifierTimer(): void { if (this._timerModifierID !== 0) { clearTimeout(this._timerModifierID); this._timerModifierID = 0; } } /** * Start or restart the pending timeout. */ private _startTimer(): void { this._clearTimer(); this._timerID = window.setTimeout(() => { this._onPendingTimeout(); }, Private.CHORD_TIMEOUT); } /** * Clear the pending timeout. */ private _clearTimer(): void { if (this._timerID !== 0) { clearTimeout(this._timerID); this._timerID = 0; } } /** * Replay the keydown events which were suppressed. */ private _replayKeydownEvents(): void { if (this._keydownEvents.length === 0) { return; } this._replaying = true; this._keydownEvents.forEach(Private.replayKeyEvent); this._replaying = false; } /** * Execute the command for the given key binding. * * If the command is missing or disabled, a warning will be logged. * * The execution will not proceed if any of the events leading to * the keybinding matching were held with the permission resolving to false. */ private async _executeKeyBinding( binding: CommandRegistry.IKeyBinding ): Promise { if (this._holdKeyBindingPromises.size !== 0) { // Copy keydown events list to ensure it is available in async code. const keydownEvents = [...this._keydownEvents]; // Wait until all hold requests on execution are lifted. const executionAllowed = ( await Promise.race([ Promise.all( keydownEvents.map( async event => this._holdKeyBindingPromises.get(event) ?? Promise.resolve(true) ) ), new Promise(resolve => { setTimeout(() => resolve([false]), Private.KEYBINDING_HOLD_TIMEOUT); }) ]) ).every(Boolean); // Clear the hold requests. this._holdKeyBindingPromises.clear(); // Do not proceed with the execution if any of the hold requests did not get the permission to proceed. if (!executionAllowed) { return; } } let { command, args } = binding; let newArgs: ReadonlyPartialJSONObject = { _luminoEvent: { type: 'keybinding', keys: binding.keys }, ...args }; if (!this.hasCommand(command) || !this.isEnabled(command, newArgs)) { let word = this.hasCommand(command) ? 'enabled' : 'registered'; let keys = binding.keys.join(', '); let msg1 = `Cannot execute key binding '${keys}':`; let msg2 = `command '${command}' is not ${word}.`; console.warn(`${msg1} ${msg2}`); return; } await this.execute(command, newArgs); } /** * Clear the internal pending state. */ private _clearPendingState(): void { this._clearTimer(); this._clearModifierTimer(); this._exactKeyMatch = null; this._keystrokes.length = 0; this._keydownEvents.length = 0; } /** * Handle the partial match timeout. */ private _onPendingTimeout(): void { this._timerID = 0; if (this._exactKeyMatch) { this._executeKeyBinding(this._exactKeyMatch); } else { this._replayKeydownEvents(); } this._clearPendingState(); } private _timerID = 0; private _timerModifierID = 0; private _replaying = false; private _keystrokes: string[] = []; private _keydownEvents: KeyboardEvent[] = []; private _keyBindings: CommandRegistry.IKeyBinding[] = []; private _exactKeyMatch: CommandRegistry.IKeyBinding | null = null; private _commands = new Map(); private _commandChanged = new Signal< this, CommandRegistry.ICommandChangedArgs >(this); private _commandExecuted = new Signal< this, CommandRegistry.ICommandExecutedArgs >(this); private _keyBindingChanged = new Signal< this, CommandRegistry.IKeyBindingChangedArgs >(this); private _holdKeyBindingPromises = new Map>(); } /** * The namespace for the `CommandRegistry` class statics. */ export namespace CommandRegistry { /** * A type alias for a user-defined command function. */ export type CommandFunc = (args: ReadonlyPartialJSONObject) => T; /** * A type alias for a simple immutable string dataset. */ export type Dataset = { readonly [key: string]: string }; /** * Commands description. */ export type Description = { args: ReadonlyJSONObject | null }; /** * An options object for creating a command. * * #### Notes * A command is an abstract representation of code to be executed along * with metadata for describing how the command should be displayed in * a visual representation. * * A command is a collection of functions, *not* methods. The command * registry will always invoke the command functions with a `thisArg` * which is `undefined`. */ export interface ICommandOptions { /** * The function to invoke when the command is executed. * * #### Notes * This should return the result of the command (if applicable) or * a promise which yields the result. The result is resolved as a * promise and that promise is returned to the code which executed * the command. * * This may be invoked even when `isEnabled` returns `false`. * * If called via a keybinding the passed args will include a `_luminoEvent` that specify the event type and keys pressed for customization. */ execute: CommandFunc>; /** * JSON Schemas describing the command. * * #### Notes * For now, the command arguments are the only one that can be * described. */ describedBy?: | Partial | CommandFunc | Promise>>; /** * The label for the command. * * #### Notes * This can be a string literal, or a function which returns the * label based on the provided command arguments. * * The label is often used as the primary text for the command. * * The default value is an empty string. */ label?: string | CommandFunc; /** * The index of the mnemonic character in the command's label. * * #### Notes * This can be an index literal, or a function which returns the * mnemonic index based on the provided command arguments. * * The mnemonic character is often used by menus to provide easy * single-key keyboard access for triggering a menu item. It is * typically rendered as an underlined character in the label. * * The default value is `-1`. */ mnemonic?: number | CommandFunc; /** * The icon renderer for the command. * * #### Notes * This can be an IRenderer object, or a function which returns the * renderer based on the provided command arguments. * * The default value is `undefined`. */ icon?: | VirtualElement.IRenderer | undefined | CommandFunc; /** * The icon class for the command. * * #### Notes * This class name will be added to the icon node for the visual * representation of the command. * * Multiple class names can be separated with white space. * * This can be a string literal, or a function which returns the * icon based on the provided command arguments. * * The default value is an empty string. */ iconClass?: string | CommandFunc; /** * The icon label for the command. * * #### Notes * This label will be added as text to the icon node for the visual * representation of the command. * * This can be a string literal, or a function which returns the * label based on the provided command arguments. * * The default value is an empty string. */ iconLabel?: string | CommandFunc; /** * The caption for the command. * * #### Notes * This should be a simple one line description of the command. It * is used by some visual representations to show quick info about * the command. * * This can be a string literal, or a function which returns the * caption based on the provided command arguments. * * The default value is an empty string. */ caption?: string | CommandFunc; /** * The usage text for the command. * * #### Notes * This should be a full description of the command, which includes * information about the structure of the arguments and the type of * the return value. It is used by some visual representations when * displaying complete help info about the command. * * This can be a string literal, or a function which returns the * usage text based on the provided command arguments. * * The default value is an empty string. */ usage?: string | CommandFunc; /** * The general class name for the command. * * #### Notes * This class name will be added to the primary node for the visual * representation of the command. * * Multiple class names can be separated with white space. * * This can be a string literal, or a function which returns the * class name based on the provided command arguments. * * The default value is an empty string. */ className?: string | CommandFunc; /** * The dataset for the command. * * #### Notes * The dataset values will be added to the primary node for the * visual representation of the command. * * This can be a dataset object, or a function which returns the * dataset object based on the provided command arguments. * * The default value is an empty dataset. */ dataset?: Dataset | CommandFunc; /** * A function which indicates whether the command is enabled. * * #### Notes * Visual representations may use this value to display a disabled * command as grayed-out or in some other non-interactive fashion. * * The default value is `() => true`. * * If called via a keybinding the passed args will include a `_luminoEvent` that specify the event type and keys pressed for customization */ isEnabled?: CommandFunc; /** * A function which indicates whether the command is toggled. * * #### Notes * Visual representations may use this value to display a toggled * command in a different form, such as a check mark icon for a * menu item or a depressed state for a toggle button. * * The default value is `() => false`. */ isToggled?: CommandFunc; /** * A function which indicates whether the command is toggleable. * * #### Notes * Visual representations may use this value to display a toggled command in * a different form, such as a check box for a menu item or a depressed * state for a toggle button. This attribute also allows for accessible * interfaces to notify the user that the command corresponds to some state. * * The default value is `true` if an `isToggled` function is given, `false` * otherwise. */ isToggleable?: boolean; /** * A function which indicates whether the command is visible. * * #### Notes * Visual representations may use this value to hide or otherwise * not display a non-visible command. * * The default value is `() => true`. */ isVisible?: CommandFunc; } /** * An arguments object for the `commandChanged` signal. */ export interface ICommandChangedArgs { /** * The id of the associated command. * * This will be `undefined` when the type is `'many-changed'`. */ readonly id: string | undefined; /** * Whether the command was added, removed, or changed. */ readonly type: 'added' | 'removed' | 'changed' | 'many-changed'; } /** * An arguments object for the `commandExecuted` signal. */ export interface ICommandExecutedArgs { /** * The id of the associated command. */ readonly id: string; /** * The arguments object passed to the command. */ readonly args: ReadonlyPartialJSONObject; /** * The promise which resolves with the result of the command. */ readonly result: Promise; } /** * An options object for creating a key binding. */ export interface IKeyBindingOptions { /** * The default key sequence for the key binding. * * A key sequence is composed of one or more keystrokes, where each * keystroke is a combination of modifiers and a primary key. * * Most key sequences will contain a single keystroke. Key sequences * with multiple keystrokes are called "chords", and are useful for * implementing modal input (ala Vim). * * Each keystroke in the sequence should be of the form: * `[ [ [ ]]]` * * The supported modifiers are: `Accel`, `Alt`, `Cmd`, `Ctrl`, and * `Shift`. The `Accel` modifier is translated to `Cmd` on Mac and * `Ctrl` on all other platforms. The `Cmd` modifier is ignored on * non-Mac platforms. * * Keystrokes are case sensitive. * * **Examples:** `['Accel C']`, `['Shift F11']`, `['D', 'D']` */ keys: string[]; /** * The CSS selector for the key binding. * * The key binding will only be invoked when the selector matches a * node on the propagation path of the keydown event. This allows * the key binding to be restricted to user-defined contexts. * * The selector must not contain commas. */ selector: string; /** * The id of the command to execute when the binding is matched. */ command: string; /** * The arguments for the command, if necessary. * * The default value is an empty object. If a command is activated by _executeKeyBinding, `args = {_luminoEvent: {type: , keys: }}` */ args?: ReadonlyPartialJSONObject; /** * The key sequence to use when running on Windows. * * If provided, this will override `keys` on Windows platforms. */ winKeys?: string[]; /** * The key sequence to use when running on Mac. * * If provided, this will override `keys` on Mac platforms. */ macKeys?: string[]; /** * The key sequence to use when running on Linux. * * If provided, this will override `keys` on Linux platforms. */ linuxKeys?: string[]; /** * Whether to prevent default action of the keyboard events during sequence matching. * * The default value is `true`. */ preventDefault?: boolean; } /** * An object which represents a key binding. * * #### Notes * A key binding is an immutable object created by a registry. */ export interface IKeyBinding { /** * The key sequence for the binding. */ readonly keys: ReadonlyArray; /** * The CSS selector for the binding. */ readonly selector: string; /** * The command executed when the binding is matched. */ readonly command: string; /** * The arguments for the command. */ readonly args: ReadonlyPartialJSONObject; /** * Whether to prevent default action of the keyboard events during sequence matching. * * The default value is `true`. */ readonly preventDefault?: boolean; } /** * An arguments object for the `keyBindingChanged` signal. */ export interface IKeyBindingChangedArgs { /** * The key binding which was changed. */ readonly binding: IKeyBinding; /** * Whether the key binding was added or removed. */ readonly type: 'added' | 'removed'; } /** * An object which holds the results of parsing a keystroke. */ export interface IKeystrokeParts { /** * Whether `'Cmd'` appears in the keystroke. */ cmd: boolean; /** * Whether `'Ctrl'` appears in the keystroke. */ ctrl: boolean; /** * Whether `'Alt'` appears in the keystroke. */ alt: boolean; /** * Whether `'Shift'` appears in the keystroke. */ shift: boolean; /** * The primary key for the keystroke. */ key: string; } /** * Parse a keystroke into its constituent components. * * @param keystroke - The keystroke of interest. * * @returns The parsed components of the keystroke. * * #### Notes * The keystroke should be of the form: * `[ [ [ ]]]` * * The supported modifiers are: `Accel`, `Alt`, `Cmd`, `Ctrl`, and * `Shift`. The `Accel` modifier is translated to `Cmd` on Mac and * `Ctrl` on all other platforms. * * The parsing is tolerant and will not throw exceptions. Notably: * - Duplicate modifiers are ignored. * - Extra primary keys are ignored. * - The order of modifiers and primary key is irrelevant. * - The keystroke parts should be separated by whitespace. * - The keystroke is case sensitive. */ export function parseKeystroke(keystroke: string): IKeystrokeParts { let key = ''; let alt = false; let cmd = false; let ctrl = false; let shift = false; for (let token of keystroke.split(/\s+/)) { if (token === 'Accel') { if (Platform.IS_MAC) { cmd = true; } else { ctrl = true; } } else if (token === 'Alt') { alt = true; } else if (token === 'Cmd') { cmd = true; } else if (token === 'Ctrl') { ctrl = true; } else if (token === 'Shift') { shift = true; } else if (token.length > 0) { key = token; } } return { cmd, ctrl, alt, shift, key }; } /** * Normalize a keystroke into a canonical representation. * * @param keystroke - The keystroke of interest. * * @returns The normalized representation of the keystroke. * * #### Notes * This normalizes the keystroke by removing duplicate modifiers and * extra primary keys, and assembling the parts in a canonical order. * * The `Cmd` modifier is ignored on non-Mac platforms. */ export function normalizeKeystroke(keystroke: string): string { let mods = ''; let parts = parseKeystroke(keystroke); if (parts.ctrl) { mods += 'Ctrl '; } if (parts.alt) { mods += 'Alt '; } if (parts.shift) { mods += 'Shift '; } if (parts.cmd && Platform.IS_MAC) { mods += 'Cmd '; } if (!parts.key) { return mods.trim(); } return mods + parts.key; } /** * Get the platform-specific normalized keys for an options object. * * @param options - The options for the key binding. * * @returns Array of combined, normalized keys. */ export function normalizeKeys(options: IKeyBindingOptions): string[] { let keys: string[]; if (Platform.IS_WIN) { keys = options.winKeys || options.keys; } else if (Platform.IS_MAC) { keys = options.macKeys || options.keys; } else { keys = options.linuxKeys || options.keys; } return keys.map(normalizeKeystroke); } /** * Format keystrokes for display on the local system. * * If a list of keystrokes is provided, it will be displayed as * a comma-separated string * * @param keystroke The keystrokes to format * @returns The keystrokes representation */ export function formatKeystroke( keystroke: string | readonly string[] ): string { return typeof keystroke === 'string' ? formatSingleKey(keystroke) : keystroke.map(formatSingleKey).join(', '); function formatSingleKey(key: string) { let mods = []; let separator = Platform.IS_MAC ? ' ' : '+'; let parts = parseKeystroke(key); if (parts.ctrl) { mods.push('Ctrl'); } if (parts.alt) { mods.push('Alt'); } if (parts.shift) { mods.push('Shift'); } if (Platform.IS_MAC && parts.cmd) { mods.push('Cmd'); } mods.push(parts.key); return mods.map(Private.formatKey).join(separator); } } /** * Check if `'keydown'` event is caused by pressing a modifier key that should be ignored. * * @param event - The event object for a `'keydown'` event. * * @returns `true` if modifier key was pressed, `false` otherwise. */ export function isModifierKeyPressed(event: KeyboardEvent): boolean { let layout = getKeyboardLayout(); let key = layout.keyForKeydownEvent(event); return layout.isModifierKey(key); } /** * Create a normalized keystroke for a `'keydown'` event. * * @param event - The event object for a `'keydown'` event. * * @returns A normalized keystroke, or an empty string if the event * does not represent a valid keystroke for the given layout. */ export function keystrokeForKeydownEvent(event: KeyboardEvent): string { let layout = getKeyboardLayout(); let key = layout.keyForKeydownEvent(event); let mods = []; if (event.ctrlKey) { mods.push('Ctrl'); } if (event.altKey) { mods.push('Alt'); } if (event.shiftKey) { mods.push('Shift'); } if (event.metaKey && Platform.IS_MAC) { mods.push('Cmd'); } if (!layout.isModifierKey(key)) { mods.push(key); } // for purely modifier key strings return mods.join(' '); } } /** * The namespace for the module implementation details. */ namespace Private { /** * The timeout in ms for triggering a key binding chord. */ export const CHORD_TIMEOUT = 1000; /** * The timeout in ms for stopping the hold on keybinding execution. */ export const KEYBINDING_HOLD_TIMEOUT = 1000; /** * The timeout in ms for triggering a modifer key binding. */ export const modifierkeyTimeOut = 500; /** * A convenience type alias for a command func. */ export type CommandFunc = CommandRegistry.CommandFunc; /** * A convenience type alias for a command dataset. */ export type Dataset = CommandRegistry.Dataset; /** * A normalized command object. */ export interface ICommand { readonly execute: CommandFunc; readonly describedBy: CommandFunc< CommandRegistry.Description | Promise >; readonly label: CommandFunc; readonly mnemonic: CommandFunc; readonly icon: CommandFunc; readonly iconClass: CommandFunc; readonly iconLabel: CommandFunc; readonly caption: CommandFunc; readonly usage: CommandFunc; readonly className: CommandFunc; readonly dataset: CommandFunc; readonly isEnabled: CommandFunc; readonly isToggled: CommandFunc; readonly isToggleable: boolean; readonly isVisible: CommandFunc; } /** * Create a normalized command from an options object. */ export function createCommand( options: CommandRegistry.ICommandOptions ): ICommand { return { execute: options.execute, describedBy: asFunc< CommandRegistry.Description | Promise >( typeof options.describedBy === 'function' ? (options.describedBy as CommandFunc< CommandRegistry.Description | Promise >) : { args: null, ...options.describedBy }, () => { return { args: null }; } ), label: asFunc(options.label, emptyStringFunc), mnemonic: asFunc(options.mnemonic, negativeOneFunc), icon: asFunc(options.icon, undefinedFunc), iconClass: asFunc(options.iconClass, emptyStringFunc), iconLabel: asFunc(options.iconLabel, emptyStringFunc), caption: asFunc(options.caption, emptyStringFunc), usage: asFunc(options.usage, emptyStringFunc), className: asFunc(options.className, emptyStringFunc), dataset: asFunc(options.dataset, emptyDatasetFunc), isEnabled: options.isEnabled || trueFunc, isToggled: options.isToggled || falseFunc, isToggleable: options.isToggleable || !!options.isToggled, isVisible: options.isVisible || trueFunc }; } /** * Create a key binding object from key binding options. */ export function createKeyBinding( options: CommandRegistry.IKeyBindingOptions ): CommandRegistry.IKeyBinding { return { keys: CommandRegistry.normalizeKeys(options), selector: validateSelector(options), command: options.command, args: options.args || JSONExt.emptyObject, preventDefault: options.preventDefault ?? true }; } /** * An object which holds the results of a key binding match. */ export interface IMatchResult { /** * The best key binding which exactly matches the key sequence. */ exact: CommandRegistry.IKeyBinding | null; /** * The key bindings which partially match the sequence. */ partial: CommandRegistry.IKeyBinding[]; } /** * Find the key bindings which match a key sequence. * * This returns a match result which contains the best exact matching * binding, and a flag which indicates if there are partial matches. */ export function matchKeyBinding( bindings: ReadonlyArray, keys: ReadonlyArray, event: KeyboardEvent ): IMatchResult { // The current best exact match. let exact: CommandRegistry.IKeyBinding | null = null; // Partial matches. let partial = []; // The match distance for the exact match. let distance = Infinity; // The specificity for the exact match. let specificity = 0; // Iterate over the bindings and search for the best match. for (let i = 0, n = bindings.length; i < n; ++i) { // Lookup the current binding. let binding = bindings[i]; // Check whether the key binding sequence is a match. let sqm = matchSequence(binding.keys, keys); // If there is no match, the binding is ignored. if (sqm === SequenceMatch.None) { continue; } // If it is a partial match and no other partial match has been // found, ensure the selector matches and set the partial flag. if (sqm === SequenceMatch.Partial) { if (targetDistance(binding.selector, event) !== -1) { partial.push(binding); } continue; } // Ignore the match if the selector doesn't match, or if the // matched node is farther away than the current best match. let td = targetDistance(binding.selector, event); if (td === -1 || td > distance) { continue; } // Get the specificity for the selector. let sp = Selector.calculateSpecificity(binding.selector); // Update the best match if this match is stronger. if (!exact || td < distance || sp >= specificity) { exact = binding; distance = td; specificity = sp; } } // Return the match result. return { exact, partial }; } /** * Replay a keyboard event. * * This synthetically dispatches a clone of the keyboard event. */ export function replayKeyEvent(event: KeyboardEvent): void { event.target!.dispatchEvent(cloneKeyboardEvent(event)); } export function formatKey(key: string): string { if (Platform.IS_MAC) { return MAC_DISPLAY.hasOwnProperty(key) ? MAC_DISPLAY[key] : key; } else { return WIN_DISPLAY.hasOwnProperty(key) ? WIN_DISPLAY[key] : key; } } const MAC_DISPLAY: { [key: string]: string } = { Backspace: '⌫', Tab: '⇥', Enter: '⏎', Shift: '⇧', Ctrl: '⌃', Alt: '⌥', Escape: '⎋', PageUp: '⇞', PageDown: '⇟', End: '↘', Home: '↖', ArrowLeft: '←', ArrowUp: '↑', ArrowRight: '→', ArrowDown: '↓', Delete: '⌦', Cmd: '⌘' }; const WIN_DISPLAY: { [key: string]: string } = { Escape: 'Esc', PageUp: 'Page Up', PageDown: 'Page Down', ArrowLeft: 'Left', ArrowUp: 'Up', ArrowRight: 'Right', ArrowDown: 'Down', Delete: 'Del' }; /** * A singleton empty string function. */ const emptyStringFunc = () => ''; /** * A singleton `-1` number function */ const negativeOneFunc = () => -1; /** * A singleton true boolean function. */ const trueFunc = () => true; /** * A singleton false boolean function. */ const falseFunc = () => false; /** * A singleton empty dataset function. */ const emptyDatasetFunc = () => ({}); /** * A singleton undefined function */ const undefinedFunc = () => undefined; /** * Cast a value or command func to a command func. */ function asFunc( value: undefined | T | CommandFunc, dfault: CommandFunc ): CommandFunc { if (value === undefined) { return dfault; } if (typeof value === 'function') { return value as CommandFunc; } return () => value; } /** * Validate the selector for an options object. * * This returns the validated selector, or throws if the selector is * invalid or contains commas. */ function validateSelector( options: CommandRegistry.IKeyBindingOptions ): string { if (options.selector.indexOf(',') !== -1) { throw new Error(`Selector cannot contain commas: ${options.selector}`); } if (!Selector.isValid(options.selector)) { throw new Error(`Invalid selector: ${options.selector}`); } return options.selector; } /** * An enum which describes the possible sequence matches. */ const enum SequenceMatch { None, Exact, Partial } /** * Test whether a key binding sequence matches a key sequence. * * Returns a `SequenceMatch` value indicating the type of match. */ function matchSequence( bindKeys: ReadonlyArray, userKeys: ReadonlyArray ): SequenceMatch { if (bindKeys.length < userKeys.length) { return SequenceMatch.None; } for (let i = 0, n = userKeys.length; i < n; ++i) { if (bindKeys[i] !== userKeys[i]) { return SequenceMatch.None; } } if (bindKeys.length > userKeys.length) { return SequenceMatch.Partial; } return SequenceMatch.Exact; } /** * Find the distance from the target node to the first matching node. * * This traverses the event path from `target` to `currentTarget` and * computes the distance from `target` to the first node which matches * the CSS selector. If no match is found, `-1` is returned. */ function targetDistance(selector: string, event: KeyboardEvent): number { let targ = event.target as Element | null; let curr = event.currentTarget as Element | null; for (let dist = 0; targ !== null; targ = targ.parentElement, ++dist) { if (targ.hasAttribute('data-lm-suppress-shortcuts')) { return -1; } if (Selector.matches(targ, selector)) { return dist; } if (targ === curr) { return -1; } } return -1; } /** * Clone a keyboard event. */ function cloneKeyboardEvent(event: KeyboardEvent): KeyboardEvent { // A custom event is required because Chrome nulls out the // `keyCode` field in user-generated `KeyboardEvent` types. let clone = document.createEvent('Event') as any; let bubbles = event.bubbles || true; let cancelable = event.cancelable || true; clone.initEvent(event.type || 'keydown', bubbles, cancelable); clone.key = event.key || ''; clone.keyCode = event.keyCode || 0; clone.which = event.keyCode || 0; clone.ctrlKey = event.ctrlKey || false; clone.altKey = event.altKey || false; clone.shiftKey = event.shiftKey || false; clone.metaKey = event.metaKey || false; clone.view = event.view || window; return clone as KeyboardEvent; } }