UNPKG

chrome-devtools-frontend

Version:
589 lines (536 loc) • 18.3 kB
// 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, getRegisteredActionExtensions, KeybindSet} from './ActionRegistration.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|undefined} */ let shortcutRegistryInstance; export class ShortcutRegistry { /** * @param {!ActionRegistry} actionRegistry */ constructor(actionRegistry) { this._actionRegistry = actionRegistry; /** @type {!Platform.MapUtilities.Multimap.<string, !KeyboardShortcut>} */ this._actionToShortcut = new Platform.MapUtilities.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.MapUtilities.Multimap.<string, !KeyboardShortcut>} */ this._disabledDefaultShortcutsForAction = new Platform.MapUtilities.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; } static removeInstance() { shortcutRegistryInstance = undefined; } /** * @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 = Boolean(handlers) || Boolean(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 = 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 Boolean(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(); 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); } } } for (const actionExtension of getRegisteredActionExtensions()) { const actionId = actionExtension.id(); const bindings = actionExtension.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) { 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(KeybindSet.DEVTOOLS_DEFAULT)) { this._devToolsDefaultShortcutActions.add(actionId); } this._registerShortcut( new KeyboardShortcut(shortcutDescriptors, actionId, Type.KeybindSetShortcut, new Set(keybindSets))); } } } } Host.InspectorFrontendHost.InspectorFrontendHostInstance.setWhitelistedShortcuts(JSON.stringify(forwardedKeys)); /** * @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';