| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../common/common.js'; |
| import * as Host from '../host/host.js'; |
| import * as Platform from '../platform/platform.js'; |
| import * as Root from '../root/root.js'; |
| |
| import {Action} from './Action.js'; // eslint-disable-line no-unused-vars |
| import {ActionRegistry} from './ActionRegistry.js'; // eslint-disable-line no-unused-vars |
| import {Context} from './Context.js'; |
| import {Dialog} from './Dialog.js'; |
| import {Descriptor, KeyboardShortcut, Modifiers, Type} from './KeyboardShortcut.js'; // eslint-disable-line no-unused-vars |
| import {isEditing} from './UIUtils.js'; |
| |
| /** @type {!ShortcutRegistry} */ |
| let shortcutRegistryInstance; |
| |
| export class ShortcutRegistry { |
| /** |
| * @param {!ActionRegistry} actionRegistry |
| */ |
| constructor(actionRegistry) { |
| this._actionRegistry = actionRegistry; |
| /** @type {!Platform.Multimap.<string, !KeyboardShortcut>} */ |
| this._actionToShortcut = new Platform.Multimap(); |
| this._keyMap = new ShortcutTreeNode(0, 0); |
| /** @type {?ShortcutTreeNode} */ |
| this._activePrefixKey = null; |
| /** @type {?number} */ |
| this._activePrefixTimeout = null; |
| /** @type {?function():Promise<void>} */ |
| this._consumePrefix = null; |
| /** @type {!Set.<string>} */ |
| this._devToolsDefaultShortcutActions = new Set(); |
| /** @type {!Platform.Multimap.<string, !KeyboardShortcut>} */ |
| this._disabledDefaultShortcutsForAction = new Platform.Multimap(); |
| this._keybindSetSetting = Common.Settings.Settings.instance().moduleSetting('activeKeybindSet'); |
| this._keybindSetSetting.addChangeListener(event => { |
| Host.userMetrics.keybindSetSettingChanged(event.data); |
| this._registerBindings(); |
| }); |
| this._userShortcutsSetting = Common.Settings.Settings.instance().moduleSetting('userShortcuts'); |
| this._userShortcutsSetting.addChangeListener(this._registerBindings, this); |
| |
| this._registerBindings(); |
| } |
| |
| /** |
| * @param {{forceNew: ?boolean, actionRegistry: ?ActionRegistry}} opts |
| */ |
| static instance(opts = {forceNew: null, actionRegistry: null}) { |
| const {forceNew, actionRegistry} = opts; |
| if (!shortcutRegistryInstance || forceNew) { |
| if (!actionRegistry) { |
| throw new Error('Missing actionRegistry for shortcutRegistry'); |
| } |
| shortcutRegistryInstance = new ShortcutRegistry(actionRegistry); |
| } |
| |
| return shortcutRegistryInstance; |
| } |
| |
| /** |
| * @param {number} key |
| * @param {!Object.<string, function():Promise.<boolean>>=} handlers |
| * @return {!Array.<!Action>} |
| */ |
| _applicableActions(key, handlers = {}) { |
| /** @type {!Array<string>} */ |
| let actions = []; |
| const keyMap = this._activePrefixKey || this._keyMap; |
| const keyNode = keyMap.getNode(key); |
| if (keyNode) { |
| actions = keyNode.actions(); |
| } |
| const applicableActions = this._actionRegistry.applicableActions(actions, Context.instance()); |
| if (keyNode) { |
| for (const actionId of Object.keys(handlers)) { |
| if (keyNode.actions().indexOf(actionId) >= 0) { |
| const action = this._actionRegistry.action(actionId); |
| if (action) { |
| applicableActions.push(action); |
| } |
| } |
| } |
| } |
| return applicableActions; |
| } |
| |
| /** |
| * @param {string} action |
| * @return {!Array.<!KeyboardShortcut>} |
| */ |
| shortcutsForAction(action) { |
| return [...this._actionToShortcut.get(action)]; |
| } |
| |
| /** |
| * @param {!Array.<!Descriptor>} descriptors |
| */ |
| actionsForDescriptors(descriptors) { |
| /** @type {?ShortcutTreeNode} */ |
| let keyMapNode = this._keyMap; |
| for (const {key} of descriptors) { |
| if (!keyMapNode) { |
| return []; |
| } |
| keyMapNode = keyMapNode.getNode(key); |
| } |
| return keyMapNode ? keyMapNode.actions() : []; |
| } |
| |
| /** |
| * @return {!Array<number>} |
| */ |
| globalShortcutKeys() { |
| const keys = []; |
| for (const node of this._keyMap.chords().values()) { |
| const actions = node.actions(); |
| const applicableActions = this._actionRegistry.applicableActions(actions, Context.instance()); |
| if (applicableActions.length || node.hasChords()) { |
| keys.push(node.key()); |
| } |
| } |
| return keys; |
| } |
| |
| /** |
| * @param {!Array.<string>} actionIds |
| * @return {!Array.<number>} |
| */ |
| keysForActions(actionIds) { |
| const keys = actionIds.flatMap( |
| action => [...this._actionToShortcut.get(action)].flatMap( |
| shortcut => shortcut.descriptors.map(descriptor => descriptor.key))); |
| return [...(new Set(keys))]; |
| } |
| |
| /** |
| * @param {string} actionId |
| * @return {string|undefined} |
| */ |
| shortcutTitleForAction(actionId) { |
| for (const shortcut of this._actionToShortcut.get(actionId)) { |
| return shortcut.title(); |
| } |
| return undefined; |
| } |
| |
| /** |
| * @param {!KeyboardEvent} event |
| * @param {!Object.<string, function():Promise.<boolean>>=} handlers |
| */ |
| handleShortcut(event, handlers) { |
| this.handleKey(KeyboardShortcut.makeKeyFromEvent(event), event.key, event, handlers); |
| } |
| |
| /** |
| * @param {string} actionId |
| * return {boolean} |
| */ |
| actionHasDefaultShortcut(actionId) { |
| return this._devToolsDefaultShortcutActions.has(actionId); |
| } |
| |
| /** |
| * @param {!Element} element |
| * @param {!Object.<string, function():Promise.<boolean>>} handlers |
| * @return {function(!Event): void} |
| */ |
| addShortcutListener(element, handlers) { |
| // We only want keys for these specific actions to get handled this |
| // way; all others should be allowed to bubble up. |
| const allowlistKeyMap = new ShortcutTreeNode(0, 0); |
| const shortcuts = Object.keys(handlers).flatMap(action => [...this._actionToShortcut.get(action)]); |
| shortcuts.forEach(shortcut => { |
| allowlistKeyMap.addKeyMapping(shortcut.descriptors.map(descriptor => descriptor.key), shortcut.action); |
| }); |
| |
| /** |
| * @param {!Event} event |
| */ |
| const listener = event => { |
| const key = KeyboardShortcut.makeKeyFromEvent(/** @type {!KeyboardEvent} */ (event)); |
| const keyMap = this._activePrefixKey ? allowlistKeyMap.getNode(this._activePrefixKey.key()) : allowlistKeyMap; |
| if (!keyMap) { |
| return; |
| } |
| if (keyMap.getNode(key)) { |
| this.handleShortcut(/** @type {!KeyboardEvent} */ (event), handlers); |
| } |
| }; |
| element.addEventListener('keydown', listener); |
| return listener; |
| } |
| |
| /** |
| * @param {number} key |
| * @param {string} domKey |
| * @param {!KeyboardEvent=} event |
| * @param {!Object.<string, function():Promise.<boolean>>=} handlers |
| */ |
| async handleKey(key, domKey, event, handlers) { |
| const keyModifiers = key >> 8; |
| const hasHandlersOrPrefixKey = !!handlers || !!this._activePrefixKey; |
| const keyMapNode = this._keyMap.getNode(key); |
| const maybeHasActions = this._applicableActions(key, handlers).length > 0 || (keyMapNode && keyMapNode.hasChords()); |
| if ((!hasHandlersOrPrefixKey && isPossiblyInputKey()) || !maybeHasActions || |
| KeyboardShortcut.isModifier(KeyboardShortcut.keyCodeAndModifiersFromKey(key).keyCode)) { |
| return; |
| } |
| if (event) { |
| event.consume(true); |
| } |
| if (!hasHandlersOrPrefixKey && Dialog.hasInstance()) { |
| return; |
| } |
| |
| if (this._activePrefixTimeout) { |
| clearTimeout(this._activePrefixTimeout); |
| const handled = await maybeExecuteActionForKey.call(this); |
| this._activePrefixKey = null; |
| this._activePrefixTimeout = null; |
| if (handled) { |
| return; |
| } |
| if (this._consumePrefix) { |
| await this._consumePrefix(); |
| } |
| } |
| if (keyMapNode && keyMapNode.hasChords()) { |
| this._activePrefixKey = keyMapNode; |
| this._consumePrefix = async () => { |
| this._activePrefixKey = null; |
| this._activePrefixTimeout = null; |
| await maybeExecuteActionForKey.call(this); |
| }; |
| this._activePrefixTimeout = window.setTimeout(this._consumePrefix, KeyTimeout); |
| } else { |
| await maybeExecuteActionForKey.call(this); |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| function isPossiblyInputKey() { |
| if (!event || !isEditing() || /^F\d+|Control|Shift|Alt|Meta|Escape|Win|U\+001B$/.test(domKey)) { |
| return false; |
| } |
| |
| if (!keyModifiers) { |
| return true; |
| } |
| |
| const modifiers = Modifiers; |
| // Undo/Redo will also cause input, so textual undo should take precedence over DevTools undo when editing. |
| if (Host.Platform.isMac()) { |
| if (KeyboardShortcut.makeKey('z', modifiers.Meta) === key) { |
| return true; |
| } |
| if (KeyboardShortcut.makeKey('z', modifiers.Meta | modifiers.Shift) === key) { |
| return true; |
| } |
| } else { |
| if (KeyboardShortcut.makeKey('z', modifiers.Ctrl) === key) { |
| return true; |
| } |
| if (KeyboardShortcut.makeKey('y', modifiers.Ctrl) === key) { |
| return true; |
| } |
| if (!Host.Platform.isWin() && KeyboardShortcut.makeKey('z', modifiers.Ctrl | modifiers.Shift) === key) { |
| return true; |
| } |
| } |
| |
| if ((keyModifiers & (modifiers.Ctrl | modifiers.Alt)) === (modifiers.Ctrl | modifiers.Alt)) { |
| return Host.Platform.isWin(); |
| } |
| |
| return !hasModifier(modifiers.Ctrl) && !hasModifier(modifiers.Alt) && !hasModifier(modifiers.Meta); |
| } |
| |
| /** |
| * @param {number} mod |
| * @return {boolean} |
| */ |
| function hasModifier(mod) { |
| return !!(keyModifiers & mod); |
| } |
| |
| /** |
| * @return {!Promise.<boolean>}; |
| * @this {!ShortcutRegistry} |
| */ |
| async function maybeExecuteActionForKey() { |
| const actions = this._applicableActions(key, handlers); |
| if (!actions.length) { |
| return false; |
| } |
| for (const action of actions) { |
| let handled; |
| if (handlers && handlers[action.id()]) { |
| handled = await handlers[action.id()](); |
| } |
| if (!handlers) { |
| handled = await action.execute(); |
| } |
| if (handled) { |
| Host.userMetrics.keyboardShortcutFired(action.id()); |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| |
| /** |
| * @param {!KeyboardShortcut} shortcut |
| */ |
| registerUserShortcut(shortcut) { |
| for (const otherShortcut of this._disabledDefaultShortcutsForAction.get(shortcut.action)) { |
| if (otherShortcut.descriptorsMatch(shortcut.descriptors) && |
| otherShortcut.hasKeybindSet(this._keybindSetSetting.get())) { |
| // this user shortcut is the same as a disabled default shortcut, |
| // so we should just enable the default |
| this.removeShortcut(otherShortcut); |
| return; |
| } |
| } |
| for (const otherShortcut of this._actionToShortcut.get(shortcut.action)) { |
| if (otherShortcut.descriptorsMatch(shortcut.descriptors) && |
| otherShortcut.hasKeybindSet(this._keybindSetSetting.get())) { |
| // don't allow duplicate shortcuts |
| return; |
| } |
| } |
| this._addShortcutToSetting(shortcut); |
| } |
| |
| /** |
| * @param {!KeyboardShortcut} shortcut |
| */ |
| removeShortcut(shortcut) { |
| if (shortcut.type === Type.DefaultShortcut || shortcut.type === Type.KeybindSetShortcut) { |
| this._addShortcutToSetting(shortcut.changeType(Type.DisabledDefault)); |
| } else { |
| this._removeShortcutFromSetting(shortcut); |
| } |
| } |
| |
| /** |
| * @param {string} actionId |
| * @return {!Set.<!KeyboardShortcut>} |
| */ |
| disabledDefaultsForAction(actionId) { |
| return this._disabledDefaultShortcutsForAction.get(actionId); |
| } |
| |
| /** |
| * @param {!KeyboardShortcut} shortcut |
| */ |
| _addShortcutToSetting(shortcut) { |
| const userShortcuts = this._userShortcutsSetting.get(); |
| userShortcuts.push(shortcut); |
| this._userShortcutsSetting.set(userShortcuts); |
| } |
| |
| /** |
| * @param {!KeyboardShortcut} shortcut |
| */ |
| _removeShortcutFromSetting(shortcut) { |
| const userShortcuts = this._userShortcutsSetting.get(); |
| const index = userShortcuts.findIndex(shortcut.equals, shortcut); |
| if (index !== -1) { |
| userShortcuts.splice(index, 1); |
| this._userShortcutsSetting.set(userShortcuts); |
| } |
| } |
| |
| /** |
| * @param {!KeyboardShortcut} shortcut |
| */ |
| _registerShortcut(shortcut) { |
| this._actionToShortcut.set(shortcut.action, shortcut); |
| this._keyMap.addKeyMapping(shortcut.descriptors.map(descriptor => descriptor.key), shortcut.action); |
| } |
| |
| _registerBindings() { |
| this._actionToShortcut.clear(); |
| this._keyMap.clear(); |
| const keybindSet = this._keybindSetSetting.get(); |
| const extensions = Root.Runtime.Runtime.instance().extensions('action'); |
| this._disabledDefaultShortcutsForAction.clear(); |
| this._devToolsDefaultShortcutActions.clear(); |
| /** @type {!Array<!{keyCode: number, modifiers: number}>} */ |
| const forwardedKeys = []; |
| if (Root.Runtime.experiments.isEnabled('keyboardShortcutEditor')) { |
| /** @type {!Array<!{action: string, descriptors: !Array.<!Descriptor>, type: !Type}>} */ |
| const userShortcuts = this._userShortcutsSetting.get(); |
| for (const userShortcut of userShortcuts) { |
| const shortcut = KeyboardShortcut.createShortcutFromSettingObject(userShortcut); |
| if (shortcut.type === Type.DisabledDefault) { |
| this._disabledDefaultShortcutsForAction.set(shortcut.action, shortcut); |
| } else { |
| if (ForwardedActions.has(shortcut.action)) { |
| forwardedKeys.push( |
| ...shortcut.descriptors.map(descriptor => KeyboardShortcut.keyCodeAndModifiersFromKey(descriptor.key))); |
| } |
| this._registerShortcut(shortcut); |
| } |
| } |
| } |
| extensions.forEach(registerExtension, this); |
| Host.InspectorFrontendHost.InspectorFrontendHostInstance.setWhitelistedShortcuts(JSON.stringify(forwardedKeys)); |
| |
| /** |
| * @param {!Root.Runtime.Extension} extension |
| * @this {ShortcutRegistry} |
| */ |
| function registerExtension(extension) { |
| const descriptor = extension.descriptor(); |
| const bindings = descriptor.bindings; |
| for (let i = 0; bindings && i < bindings.length; ++i) { |
| const keybindSets = bindings[i].keybindSets; |
| if (!platformMatches(bindings[i].platform) || !keybindSetsMatch(keybindSets)) { |
| continue; |
| } |
| const keys = bindings[i].shortcut.split(/\s+/); |
| const shortcutDescriptors = keys.map(KeyboardShortcut.makeDescriptorFromBindingShortcut); |
| if (shortcutDescriptors.length > 0) { |
| const actionId = /** @type {string} */ (descriptor.actionId); |
| |
| if (this._isDisabledDefault(shortcutDescriptors, actionId)) { |
| this._devToolsDefaultShortcutActions.add(actionId); |
| continue; |
| } |
| |
| if (ForwardedActions.has(actionId)) { |
| forwardedKeys.push( |
| ...shortcutDescriptors.map(shortcut => KeyboardShortcut.keyCodeAndModifiersFromKey(shortcut.key))); |
| } |
| if (!keybindSets) { |
| this._devToolsDefaultShortcutActions.add(actionId); |
| this._registerShortcut(new KeyboardShortcut(shortcutDescriptors, actionId, Type.DefaultShortcut)); |
| } else { |
| if (keybindSets.includes(DefaultShortcutSetting)) { |
| this._devToolsDefaultShortcutActions.add(actionId); |
| } |
| this._registerShortcut( |
| new KeyboardShortcut(shortcutDescriptors, actionId, Type.KeybindSetShortcut, new Set(keybindSets))); |
| } |
| } |
| } |
| } |
| |
| /** |
| * @param {string=} platformsString |
| * @return {boolean} |
| */ |
| function platformMatches(platformsString) { |
| if (!platformsString) { |
| return true; |
| } |
| const platforms = platformsString.split(','); |
| let isMatch = false; |
| const currentPlatform = Host.Platform.platform(); |
| for (let i = 0; !isMatch && i < platforms.length; ++i) { |
| isMatch = platforms[i] === currentPlatform; |
| } |
| return isMatch; |
| } |
| |
| /** |
| * @param {!Array<string>=} keybindSets |
| */ |
| function keybindSetsMatch(keybindSets) { |
| if (!keybindSets) { |
| return true; |
| } |
| return keybindSets.includes(keybindSet); |
| } |
| } |
| |
| /** |
| * @param {!Array<!{key: number, name: string}>} shortcutDescriptors |
| * @param {string} action |
| */ |
| _isDisabledDefault(shortcutDescriptors, action) { |
| const disabledDefaults = this._disabledDefaultShortcutsForAction.get(action); |
| for (const disabledDefault of disabledDefaults) { |
| if (disabledDefault.descriptorsMatch(shortcutDescriptors)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| |
| export class ShortcutTreeNode { |
| /** |
| * @param {number} key |
| * @param {number=} depth |
| */ |
| constructor(key, depth = 0) { |
| this._key = key; |
| /** @type {!Array.<string>} */ |
| this._actions = []; |
| this._chords = new Map(); |
| this._depth = depth; |
| } |
| |
| /** |
| * @param {string} action |
| */ |
| addAction(action) { |
| this._actions.push(action); |
| } |
| |
| /** |
| * @return {number} |
| */ |
| key() { |
| return this._key; |
| } |
| |
| /** |
| * @return {!Map.<number, !ShortcutTreeNode>} |
| */ |
| chords() { |
| return this._chords; |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| hasChords() { |
| return this._chords.size > 0; |
| } |
| |
| /** |
| * @param {!Array.<number>} keys |
| * @param {string} action |
| */ |
| addKeyMapping(keys, action) { |
| if (keys.length < this._depth) { |
| return; |
| } |
| |
| if (keys.length === this._depth) { |
| this.addAction(action); |
| } else { |
| const key = keys[this._depth]; |
| if (!this._chords.has(key)) { |
| this._chords.set(key, new ShortcutTreeNode(key, this._depth + 1)); |
| } |
| this._chords.get(key).addKeyMapping(keys, action); |
| } |
| } |
| |
| /** |
| * @param {number} key |
| * @return {?ShortcutTreeNode} |
| */ |
| getNode(key) { |
| return this._chords.get(key) || null; |
| } |
| |
| /** |
| * @return {!Array.<string>} |
| */ |
| actions() { |
| return this._actions; |
| } |
| |
| clear() { |
| this._actions = []; |
| this._chords = new Map(); |
| } |
| } |
| |
| |
| export class ForwardedShortcut {} |
| |
| ForwardedShortcut.instance = new ForwardedShortcut(); |
| |
| export const ForwardedActions = new Set([ |
| 'main.toggle-dock', 'debugger.toggle-breakpoints-active', 'debugger.toggle-pause', 'commandMenu.show', 'console.show' |
| ]); |
| export const KeyTimeout = 1000; |
| export const DefaultShortcutSetting = 'devToolsDefault'; |