UNPKG

chrome-devtools-frontend

Version:
525 lines (469 loc) • 18.7 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 '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as Platform from '../../core/platform/platform.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import {type Action, getRegisteredActionExtensions, KeybindSet} from './ActionRegistration.js'; import type {ActionRegistry} from './ActionRegistry.js'; import {Context} from './Context.js'; import {Dialog} from './Dialog.js'; import {type Key, KeyboardShortcut, type Modifier, Modifiers, Type} from './KeyboardShortcut.js'; import {isEditing} from './UIUtils.js'; let shortcutRegistryInstance: ShortcutRegistry|undefined; export class ShortcutRegistry { private readonly actionRegistry: ActionRegistry; private readonly actionToShortcut: Platform.MapUtilities.Multimap<string, KeyboardShortcut>; private readonly keyMap: ShortcutTreeNode; private activePrefixKey: ShortcutTreeNode|null; private activePrefixTimeout: number|null; private consumePrefix: (() => Promise<void>)|null; private readonly devToolsDefaultShortcutActions: Set<string>; private readonly disabledDefaultShortcutsForAction: Platform.MapUtilities.Multimap<string, KeyboardShortcut>; private readonly keybindSetSetting: Common.Settings.Setting<string>; private readonly userShortcutsSetting: Common.Settings.Setting<KeyboardShortcut[]>; constructor(actionRegistry: ActionRegistry) { this.actionRegistry = actionRegistry; this.actionToShortcut = new Platform.MapUtilities.Multimap(); this.keyMap = new ShortcutTreeNode(0, 0); this.activePrefixKey = null; this.activePrefixTimeout = null; this.consumePrefix = null; this.devToolsDefaultShortcutActions = new Set(); this.disabledDefaultShortcutsForAction = new Platform.MapUtilities.Multimap(); this.keybindSetSetting = Common.Settings.Settings.instance().moduleSetting('active-keybind-set'); this.keybindSetSetting.addChangeListener(event => { Host.userMetrics.keybindSetSettingChanged(event.data); this.registerBindings(); }); this.userShortcutsSetting = Common.Settings.Settings.instance().moduleSetting('user-shortcuts'); this.userShortcutsSetting.addChangeListener(this.registerBindings, this); this.registerBindings(); } static instance(opts: { forceNew: boolean|null, actionRegistry: ActionRegistry|null, } = {forceNew: null, actionRegistry: null}): ShortcutRegistry { const {forceNew, actionRegistry} = opts; if (!shortcutRegistryInstance || forceNew) { if (!actionRegistry) { throw new Error('Missing actionRegistry for shortcutRegistry'); } shortcutRegistryInstance = new ShortcutRegistry(actionRegistry); } return shortcutRegistryInstance; } static removeInstance(): void { shortcutRegistryInstance = undefined; } private applicableActions(key: number, handlers: Record<string, () => Promise<boolean>>|undefined = {}): Action[] { let actions: string[] = []; 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) { if (this.actionRegistry.hasAction(actionId)) { const action = this.actionRegistry.getAction(actionId); applicableActions.push(action); } } } } return applicableActions; } shortcutsForAction(action: string): KeyboardShortcut[] { return [...this.actionToShortcut.get(action)]; } actionsForDescriptors(descriptors: Array<{ key: number, name: string, }>): string[] { let keyMapNode: (ShortcutTreeNode|null)|ShortcutTreeNode = this.keyMap; for (const {key} of descriptors) { if (!keyMapNode) { return []; } keyMapNode = keyMapNode.getNode(key); } return keyMapNode ? keyMapNode.actions() : []; } globalShortcutKeys(): number[] { 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; } keysForAction(actionId: string): number[] { const keys = [...this.actionToShortcut.get(actionId)].flatMap( shortcut => shortcut.descriptors.map(descriptor => descriptor.key)); return keys; } shortcutTitleForAction(actionId: string): string|undefined { for (const shortcut of this.actionToShortcut.get(actionId)) { return shortcut.title(); } return undefined; } keyAndModifiersForAction(actionId: string): {key: Key, modifier: Modifier}|undefined { for (const keys of this.keysForAction(actionId)) { const {keyCode, modifiers} = KeyboardShortcut.keyCodeAndModifiersFromKey(keys); const key = KeyboardShortcut.keyCodeToKey(keyCode); if (key) { return {key, modifier: KeyboardShortcut.modifierValueToModifier(modifiers) || Modifiers.None}; } } return undefined; } // DevTools and Chrome modifier values do not match, see latter here: crsrc.org/c/ui/events/event_constants.h;l=24 devToolsToChromeModifier(devToolsModifier: Modifier): number { return devToolsModifier.value * 2; } handleShortcut(event: KeyboardEvent, handlers?: Record<string, () => Promise<boolean>>): void { void this.handleKey(KeyboardShortcut.makeKeyFromEvent(event), event.key, event, handlers); } actionHasDefaultShortcut(actionId: string): boolean { return this.devToolsDefaultShortcutActions.has(actionId); } getShortcutListener(handlers: Record<string, () => Promise<boolean>>): (event: KeyboardEvent) => void { const shortcuts = Object.keys(handlers).flatMap(action => [...this.actionToShortcut.get(action)]); // 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); shortcuts.forEach(shortcut => { allowlistKeyMap.addKeyMapping(shortcut.descriptors.map(descriptor => descriptor.key), shortcut.action); }); return (event: KeyboardEvent) => { const key = KeyboardShortcut.makeKeyFromEvent(event); const keyMap = this.activePrefixKey ? allowlistKeyMap.getNode(this.activePrefixKey.key()) : allowlistKeyMap; if (!keyMap) { return; } if (keyMap.getNode(key)) { this.handleShortcut(event, handlers); } }; } addShortcutListener(element: Element, handlers: Record<string, () => Promise<boolean>>): (arg0: Event) => void { const listener = this.getShortcutListener(handlers) as (event: Event) => void; element.addEventListener('keydown', listener); return listener; } async handleKey( key: number, domKey: string, event?: KeyboardEvent, handlers?: Record<string, () => Promise<boolean>>, ): Promise<void> { const keyModifiers = key >> 8; const hasHandlersOrPrefixKey = Boolean(handlers) || Boolean(this.activePrefixKey); const keyMapNode = this.keyMap.getNode(key); const actions = this.applicableActions(key, handlers); const maybeHasActions = actions.length > 0 || (keyMapNode?.hasChords()); if ((!hasHandlersOrPrefixKey && isPossiblyInputKey()) || !maybeHasActions || KeyboardShortcut.isModifier(KeyboardShortcut.keyCodeAndModifiersFromKey(key).keyCode)) { return; } if (event) { event.consume(true); } // We allow the use of Ctrl/Meta+Plus/Minus/0 even when a modal dialog is open, // so that users are able to zoom in and out even while for example the Settings // dialog is shown. const DIALOG_ALLOWED_ACTION_IDS = ['main.zoom-in', 'main.zoom-out', 'main.zoom-reset']; if (!hasHandlersOrPrefixKey && Dialog.hasInstance() && (actions.length !== 1 || !DIALOG_ALLOWED_ACTION_IDS.includes(actions[0].id()))) { return; } if (this.activePrefixTimeout) { clearTimeout(this.activePrefixTimeout); const handled = await maybeExecuteActionForKey.call(this, event); this.activePrefixKey = null; this.activePrefixTimeout = null; if (handled) { return; } if (this.consumePrefix) { await this.consumePrefix(); } } if (keyMapNode?.hasChords()) { this.activePrefixKey = keyMapNode; this.consumePrefix = async () => { this.activePrefixKey = null; this.activePrefixTimeout = null; await maybeExecuteActionForKey.call(this, event); }; this.activePrefixTimeout = window.setTimeout(this.consumePrefix, KeyTimeout); } else { await maybeExecuteActionForKey.call(this, event); } function isPossiblyInputKey(): boolean { 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.value) === key) { return true; } if (KeyboardShortcut.makeKey('z', modifiers.Meta.value | modifiers.Shift.value) === key) { return true; } } else { if (KeyboardShortcut.makeKey('z', modifiers.Ctrl.value) === key) { return true; } if (KeyboardShortcut.makeKey('y', modifiers.Ctrl.value) === key) { return true; } if (!Host.Platform.isWin() && KeyboardShortcut.makeKey('z', modifiers.Ctrl.value | modifiers.Shift.value) === key) { return true; } } if ((keyModifiers & (modifiers.Ctrl.value | modifiers.Alt.value)) === (modifiers.Ctrl.value | modifiers.Alt.value)) { return Host.Platform.isWin(); } return !hasModifier(modifiers.Ctrl.value) && !hasModifier(modifiers.Alt.value) && !hasModifier(modifiers.Meta.value); } function hasModifier(mod: number): boolean { return Boolean(keyModifiers & mod); } /** ; */ async function maybeExecuteActionForKey(this: ShortcutRegistry, event?: KeyboardEvent): Promise<boolean> { const actions = this.applicableActions(key, handlers); if (!actions.length) { return false; } for (const action of actions) { let handled; if (event) { void VisualLogging.logKeyDown(null, event, action.id()); } if (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; } } registerUserShortcut(shortcut: KeyboardShortcut): void { 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)) { // don't allow duplicate shortcuts return; } } this.addShortcutToSetting(shortcut); } removeShortcut(shortcut: KeyboardShortcut): void { if (shortcut.type === Type.DEFAULT_SHORTCUT || shortcut.type === Type.KEYBIND_SET_SHORTCUT) { this.addShortcutToSetting(shortcut.changeType(Type.DISABLED_DEFAULT)); } else { this.removeShortcutFromSetting(shortcut); } } disabledDefaultsForAction(actionId: string): Set<KeyboardShortcut> { return this.disabledDefaultShortcutsForAction.get(actionId); } private addShortcutToSetting(shortcut: KeyboardShortcut): void { const userShortcuts = this.userShortcutsSetting.get(); userShortcuts.push(shortcut); this.userShortcutsSetting.set(userShortcuts); } private removeShortcutFromSetting(shortcut: KeyboardShortcut): void { const userShortcuts = this.userShortcutsSetting.get(); const index = userShortcuts.findIndex(shortcut.equals, shortcut); if (index !== -1) { userShortcuts.splice(index, 1); this.userShortcutsSetting.set(userShortcuts); } } private registerShortcut(shortcut: KeyboardShortcut): void { this.actionToShortcut.set(shortcut.action, shortcut); this.keyMap.addKeyMapping(shortcut.descriptors.map(descriptor => descriptor.key), shortcut.action); } private registerBindings(): void { this.actionToShortcut.clear(); this.keyMap.clear(); const keybindSet = this.keybindSetSetting.get(); this.disabledDefaultShortcutsForAction.clear(); this.devToolsDefaultShortcutActions.clear(); const forwardedKeys: Array<{ keyCode: number, modifiers: number, }> = []; const userShortcuts = this.userShortcutsSetting.get(); for (const userShortcut of userShortcuts) { const shortcut = KeyboardShortcut.createShortcutFromSettingObject(userShortcut); if (shortcut.type === Type.DISABLED_DEFAULT) { 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.DEFAULT_SHORTCUT)); } else { if (keybindSets.includes(KeybindSet.DEVTOOLS_DEFAULT)) { this.devToolsDefaultShortcutActions.add(actionId); } this.registerShortcut( new KeyboardShortcut(shortcutDescriptors, actionId, Type.KEYBIND_SET_SHORTCUT, new Set(keybindSets))); } } } } Host.InspectorFrontendHost.InspectorFrontendHostInstance.setWhitelistedShortcuts(JSON.stringify(forwardedKeys)); function platformMatches(platformsString?: string): boolean { 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; } function keybindSetsMatch(keybindSets?: string[]): boolean { if (!keybindSets) { return true; } return keybindSets.includes(keybindSet); } } private isDisabledDefault( shortcutDescriptors: Array<{ key: number, name: string, }>, action: string): boolean { const disabledDefaults = this.disabledDefaultShortcutsForAction.get(action); for (const disabledDefault of disabledDefaults) { if (disabledDefault.descriptorsMatch(shortcutDescriptors)) { return true; } } return false; } } export class ShortcutTreeNode { private readonly keyInternal: number; private actionsInternal: string[]; private chordsInternal: Map<number, ShortcutTreeNode>; private readonly depth: number; constructor(key: number, depth = 0) { this.keyInternal = key; this.actionsInternal = []; this.chordsInternal = new Map(); this.depth = depth; } addAction(action: string): void { this.actionsInternal.push(action); } key(): number { return this.keyInternal; } chords(): Map<number, ShortcutTreeNode> { return this.chordsInternal; } hasChords(): boolean { return this.chordsInternal.size > 0; } addKeyMapping(keys: number[], action: string): void { if (keys.length < this.depth) { return; } if (keys.length === this.depth) { this.addAction(action); } else { const key = keys[this.depth]; if (!this.chordsInternal.has(key)) { this.chordsInternal.set(key, new ShortcutTreeNode(key, this.depth + 1)); } (this.chordsInternal.get(key) as ShortcutTreeNode).addKeyMapping(keys, action); } } getNode(key: number): ShortcutTreeNode|null { return this.chordsInternal.get(key) || null; } actions(): string[] { return this.actionsInternal; } clear(): void { this.actionsInternal = []; this.chordsInternal = new Map(); } } export class ForwardedShortcut { static instance = new ForwardedShortcut(); } export const ForwardedActions = new Set<string>([ 'main.toggle-dock', 'debugger.toggle-breakpoints-active', 'debugger.toggle-pause', 'quick-open.show-command-menu', 'console.toggle', ]); export const KeyTimeout = 1000; export const DefaultShortcutSetting = 'devToolsDefault';