src/command/KeyBindingManager.js
/*
* Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint regexp: true */
/*unittests: KeyBindingManager */
/**
* Manages the mapping of keyboard inputs to commands.
*/
define(function (require, exports, module) {
"use strict";
require("utils/Global");
var AppInit = require("utils/AppInit"),
Commands = require("command/Commands"),
CommandManager = require("command/CommandManager"),
DefaultDialogs = require("widgets/DefaultDialogs"),
EventDispatcher = require("utils/EventDispatcher"),
FileSystem = require("filesystem/FileSystem"),
FileSystemError = require("filesystem/FileSystemError"),
FileUtils = require("file/FileUtils"),
KeyEvent = require("utils/KeyEvent"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
UrlParams = require("utils/UrlParams").UrlParams,
_ = require("thirdparty/lodash");
var KeyboardPrefs = JSON.parse(require("text!base-config/keyboard.json"));
var KEYMAP_FILENAME = "keymap.json",
_userKeyMapFilePath = brackets.app.getApplicationSupportDirectory() + "/" + KEYMAP_FILENAME;
/**
* @private
* Maps normalized shortcut descriptor to key binding info.
* @type {!Object.<string, {commandID: string, key: string, displayKey: string}>}
*/
var _keyMap = {}, // For the actual key bindings including user specified ones
// For the default factory key bindings, cloned from _keyMap after all extensions are loaded.
_defaultKeyMap = {};
/**
* @typedef {{shortcut: !string,
* commandID: ?string}} UserKeyBinding
*/
/**
* @private
* Maps shortcut descriptor to a command id.
* @type {UserKeyBinding}
*/
var _customKeyMap = {},
_customKeyMapCache = {};
/**
* @private
* Maps commandID to the list of shortcuts that are bound to it.
* @type {!Object.<string, Array.<{key: string, displayKey: string}>>}
*/
var _commandMap = {};
/**
* @private
* An array of command ID for all the available commands including the commands
* of installed extensions.
* @type {Array.<string>}
*/
var _allCommands = [];
/**
* @private
* Maps key names to the corresponding unicode symols
* @type {{key: string, displayKey: string}}
*/
var _displayKeyMap = { "up": "\u2191",
"down": "\u2193",
"left": "\u2190",
"right": "\u2192",
"-": "\u2212" };
var _specialCommands = [Commands.EDIT_UNDO, Commands.EDIT_REDO, Commands.EDIT_SELECT_ALL,
Commands.EDIT_CUT, Commands.EDIT_COPY, Commands.EDIT_PASTE],
_reservedShortcuts = ["Ctrl-Z", "Ctrl-Y", "Ctrl-A", "Ctrl-X", "Ctrl-C", "Ctrl-V"],
_macReservedShortcuts = ["Cmd-,", "Cmd-H", "Cmd-Alt-H", "Cmd-M", "Cmd-Shift-Z", "Cmd-Q"],
_keyNames = ["Up", "Down", "Left", "Right", "Backspace", "Enter", "Space", "Tab",
"PageUp", "PageDown", "Home", "End", "Insert", "Delete"];
/**
* @private
* Flag to show key binding errors in the key map file. Default is true and
* it will be set to false when reloading without extensions. This flag is not
* used to suppress errors in loading or parsing the key map file. So if the key
* map file is corrupt, then the error dialog still shows up.
*
* @type {boolean}
*/
var _showErrors = true;
/**
* @private
* Allow clients to toggle key binding
* @type {boolean}
*/
var _enabled = true;
/**
* @private
* Stack of registered global keydown hooks.
* @type {Array.<function(Event): boolean>}
*/
var _globalKeydownHooks = [];
/**
* @private
* Forward declaration for JSLint.
* @type {Function}
*/
var _loadUserKeyMap;
/**
* @private
* States of Ctrl key down detection
* @enum {number}
*/
var CtrlDownStates = {
"NOT_YET_DETECTED" : 0,
"DETECTED" : 1,
"DETECTED_AND_IGNORED": 2 // For consecutive ctrl keydown events while a Ctrl key is being hold down
};
/**
* @private
* Flags used to determine whether right Alt key is pressed. When it is pressed,
* the following two keydown events are triggered in that specific order.
*
* 1. _ctrlDown - flag used to record { ctrlKey: true, keyIdentifier: "Control", ... } keydown event
* 2. _altGrDown - flag used to record { ctrlKey: true, altKey: true, keyIdentifier: "Alt", ... } keydown event
*
* @type {CtrlDownStates|boolean}
*/
var _ctrlDown = CtrlDownStates.NOT_YET_DETECTED,
_altGrDown = false;
/**
* @private
* Used to record the timeStamp property of the last keydown event.
* @type {number}
*/
var _lastTimeStamp;
/**
* @private
* Used to record the keyIdentifier property of the last keydown event.
* @type {string}
*/
var _lastKeyIdentifier;
/*
* @private
* Constant used for checking the interval between Control keydown event and Alt keydown event.
* If the right Alt key is down we get Control keydown followed by Alt keydown within 30 ms. if
* the user is pressing Control key and then Alt key, the interval will be larger than 30 ms.
* @type {number}
*/
var MAX_INTERVAL_FOR_CTRL_ALT_KEYS = 30;
/**
* @private
* Forward declaration for JSLint.
* @type {Function}
*/
var _onCtrlUp;
/**
* @private
* Resets all the flags and removes _onCtrlUp event listener.
*
*/
function _quitAltGrMode() {
_enabled = true;
_ctrlDown = CtrlDownStates.NOT_YET_DETECTED;
_altGrDown = false;
_lastTimeStamp = null;
_lastKeyIdentifier = null;
$(window).off("keyup", _onCtrlUp);
}
/**
* @private
* Detects the release of AltGr key by checking all keyup events
* until we receive one with ctrl key code. Once detected, reset
* all the flags and also remove this event listener.
*
* @param {!KeyboardEvent} e keyboard event object
*/
_onCtrlUp = function (e) {
var key = e.keyCode || e.which;
if (_altGrDown && key === KeyEvent.DOM_VK_CONTROL) {
_quitAltGrMode();
}
};
/**
* @private
* Detects whether AltGr key is pressed. When it is pressed, the first keydown event has
* ctrlKey === true with keyIdentifier === "Control". The next keydown event with
* altKey === true, ctrlKey === true and keyIdentifier === "Alt" is sent within 30 ms. Then
* the next keydown event with altKey === true, ctrlKey === true and keyIdentifier === "Control"
* is sent. If the user keep holding AltGr key down, then the second and third
* keydown events are repeatedly sent out alternately. If the user is also holding down Ctrl
* key, then either keyIdentifier === "Control" or keyIdentifier === "Alt" is repeatedly sent
* but not alternately.
*
* Once we detect the AltGr key down, then disable KeyBindingManager and set up a keyup
* event listener to detect the release of the altGr key so that we can re-enable KeyBindingManager.
* When we detect the addition of Ctrl key besides AltGr key, we also quit AltGr mode and re-enable
* KeyBindingManager.
*
* @param {!KeyboardEvent} e keyboard event object
*/
function _detectAltGrKeyDown(e) {
if (brackets.platform !== "win") {
return;
}
if (!_altGrDown) {
if (_ctrlDown !== CtrlDownStates.DETECTED_AND_IGNORED && e.ctrlKey && e.keyIdentifier === "Control") {
_ctrlDown = CtrlDownStates.DETECTED;
} else if (e.repeat && e.ctrlKey && e.keyIdentifier === "Control") {
// We get here if the user is holding down left/right Control key. Set it to false
// so that we don't misidentify the combination of Ctrl and Alt keys as AltGr key.
_ctrlDown = CtrlDownStates.DETECTED_AND_IGNORED;
} else if (_ctrlDown === CtrlDownStates.DETECTED && e.altKey && e.ctrlKey && e.keyIdentifier === "Alt" &&
(e.timeStamp - _lastTimeStamp) < MAX_INTERVAL_FOR_CTRL_ALT_KEYS) {
_altGrDown = true;
_lastKeyIdentifier = "Alt";
_enabled = false;
$(window).on("keyup", _onCtrlUp);
} else {
// Reset _ctrlDown so that we can start over in detecting the two key events
// required for AltGr key.
_ctrlDown = CtrlDownStates.NOT_YET_DETECTED;
}
_lastTimeStamp = e.timeStamp;
} else if (e.keyIdentifier === "Control" || e.keyIdentifier === "Alt") {
// If the user is NOT holding down AltGr key or is also pressing Ctrl key,
// then _lastKeyIdentifier will be the same as keyIdentifier in the current
// key event. So we need to quit AltGr mode to re-enable KBM.
if (e.altKey && e.ctrlKey && e.keyIdentifier === _lastKeyIdentifier) {
_quitAltGrMode();
} else {
_lastKeyIdentifier = e.keyIdentifier;
}
}
}
/**
* @private
*/
function _reset() {
_keyMap = {};
_defaultKeyMap = {};
_customKeyMap = {};
_customKeyMapCache = {};
_commandMap = {};
_globalKeydownHooks = [];
_userKeyMapFilePath = brackets.app.getApplicationSupportDirectory() + "/" + KEYMAP_FILENAME;
}
/**
* @private
* Initialize an empty keymap as the current keymap. It overwrites the current keymap if there is one.
* builds the keyDescriptor string from the given parts
* @param {boolean} hasCtrl Is Ctrl key enabled
* @param {boolean} hasAlt Is Alt key enabled
* @param {boolean} hasShift Is Shift key enabled
* @param {string} key The key that's pressed
* @return {string} The normalized key descriptor
*/
function _buildKeyDescriptor(hasMacCtrl, hasCtrl, hasAlt, hasShift, key) {
if (!key) {
console.log("KeyBindingManager _buildKeyDescriptor() - No key provided!");
return "";
}
var keyDescriptor = [];
if (hasMacCtrl) {
keyDescriptor.push("Ctrl");
}
if (hasAlt) {
keyDescriptor.push("Alt");
}
if (hasShift) {
keyDescriptor.push("Shift");
}
if (hasCtrl) {
// Windows display Ctrl first, Mac displays Command symbol last
if (brackets.platform === "mac") {
keyDescriptor.push("Cmd");
} else {
keyDescriptor.unshift("Ctrl");
}
}
keyDescriptor.push(key);
return keyDescriptor.join("-");
}
/**
* normalizes the incoming key descriptor so the modifier keys are always specified in the correct order
* @param {string} The string for a key descriptor, can be in any order, the result will be Ctrl-Alt-Shift-<Key>
* @return {string} The normalized key descriptor or null if the descriptor invalid
*/
function normalizeKeyDescriptorString(origDescriptor) {
var hasMacCtrl = false,
hasCtrl = false,
hasAlt = false,
hasShift = false,
key = "",
error = false;
function _compareModifierString(left, right) {
if (!left || !right) {
return false;
}
left = left.trim().toLowerCase();
right = right.trim().toLowerCase();
return (left.length > 0 && left === right);
}
origDescriptor.split("-").forEach(function parseDescriptor(ele, i, arr) {
if (_compareModifierString("ctrl", ele)) {
if (brackets.platform === "mac") {
hasMacCtrl = true;
} else {
hasCtrl = true;
}
} else if (_compareModifierString("cmd", ele)) {
if (brackets.platform === "mac") {
hasCtrl = true;
} else {
error = true;
}
} else if (_compareModifierString("alt", ele)) {
hasAlt = true;
} else if (_compareModifierString("opt", ele)) {
if (brackets.platform === "mac") {
hasAlt = true;
} else {
error = true;
}
} else if (_compareModifierString("shift", ele)) {
hasShift = true;
} else if (key.length > 0) {
console.log("KeyBindingManager normalizeKeyDescriptorString() - Multiple keys defined. Using key: " + key + " from: " + origDescriptor);
error = true;
} else {
key = ele;
}
});
if (error) {
return null;
}
// Check to see if the binding is for "-".
if (key === "" && origDescriptor.search(/^.+--$/) !== -1) {
key = "-";
}
// '+' char is valid if it's the only key. Keyboard shortcut strings should use
// unicode characters (unescaped). Keyboard shortcut display strings may use
// unicode escape sequences (e.g. \u20AC euro sign)
if ((key.indexOf("+")) >= 0 && (key.length > 1)) {
return null;
}
// Ensure that the first letter of the key name is in upper case and the rest are
// in lower case. i.e. 'a' => 'A' and 'up' => 'Up'
if (/^[a-z]/i.test(key)) {
key = _.capitalize(key.toLowerCase());
}
// Also make sure that the second word of PageUp/PageDown has the first letter in upper case.
if (/^Page/.test(key)) {
key = key.replace(/(up|down)$/, function (match, p1) {
return _.capitalize(p1);
});
}
// No restriction on single character key yet, but other key names are restricted to either
// Function keys or those listed in _keyNames array.
if (key.length > 1 && !/F\d+/.test(key) &&
_keyNames.indexOf(key) === -1) {
return null;
}
return _buildKeyDescriptor(hasMacCtrl, hasCtrl, hasAlt, hasShift, key);
}
/**
* @private
* Looks for keycodes that have os-inconsistent keys and fixes them.
* @param {number} The keycode from the keyboard event.
* @param {string} The current best guess at what the key is.
* @return {string} If the key is OS-inconsistent, the correct key; otherwise, the original key.
**/
function _mapKeycodeToKey(keycode, key) {
// If keycode represents one of the digit keys (0-9), then return the corresponding digit
// by subtracting KeyEvent.DOM_VK_0 from keycode. ie. [48-57] --> [0-9]
if (keycode >= KeyEvent.DOM_VK_0 && keycode <= KeyEvent.DOM_VK_9) {
return String(keycode - KeyEvent.DOM_VK_0);
// Do the same with the numpad numbers
// by subtracting KeyEvent.DOM_VK_NUMPAD0 from keycode. ie. [96-105] --> [0-9]
} else if (keycode >= KeyEvent.DOM_VK_NUMPAD0 && keycode <= KeyEvent.DOM_VK_NUMPAD9) {
return String(keycode - KeyEvent.DOM_VK_NUMPAD0);
}
switch (keycode) {
case KeyEvent.DOM_VK_SEMICOLON:
return ";";
case KeyEvent.DOM_VK_EQUALS:
return "=";
case KeyEvent.DOM_VK_COMMA:
return ",";
case KeyEvent.DOM_VK_SUBTRACT:
case KeyEvent.DOM_VK_DASH:
return "-";
case KeyEvent.DOM_VK_ADD:
return "+";
case KeyEvent.DOM_VK_DECIMAL:
case KeyEvent.DOM_VK_PERIOD:
return ".";
case KeyEvent.DOM_VK_DIVIDE:
case KeyEvent.DOM_VK_SLASH:
return "/";
case KeyEvent.DOM_VK_BACK_QUOTE:
return "`";
case KeyEvent.DOM_VK_OPEN_BRACKET:
return "[";
case KeyEvent.DOM_VK_BACK_SLASH:
return "\\";
case KeyEvent.DOM_VK_CLOSE_BRACKET:
return "]";
case KeyEvent.DOM_VK_QUOTE:
return "'";
default:
return key;
}
}
/**
* Takes a keyboard event and translates it into a key in a key map
*/
function _translateKeyboardEvent(event) {
var hasMacCtrl = (brackets.platform === "mac") ? (event.ctrlKey) : false,
hasCtrl = (brackets.platform !== "mac") ? (event.ctrlKey) : (event.metaKey),
hasAlt = (event.altKey),
hasShift = (event.shiftKey),
key = String.fromCharCode(event.keyCode);
//From the W3C, if we can get the KeyboardEvent.keyIdentifier then look here
//As that will let us use keys like then function keys "F5" for commands. The
//full set of values we can use is here
//http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset.html#KeySet-Set
var ident = event.keyIdentifier;
if (ident) {
if (ident.charAt(0) === "U" && ident.charAt(1) === "+") {
//This is a unicode code point like "U+002A", get the 002A and use that
key = String.fromCharCode(parseInt(ident.substring(2), 16));
} else {
//This is some non-character key, just use the raw identifier
key = ident;
}
}
// Translate some keys to their common names
if (key === "\t") {
key = "Tab";
} else if (key === " ") {
key = "Space";
} else if (key === "\b") {
key = "Backspace";
} else if (key === "Help") {
key = "Insert";
} else if (event.keyCode === KeyEvent.DOM_VK_DELETE) {
key = "Delete";
} else {
key = _mapKeycodeToKey(event.keyCode, key);
}
return _buildKeyDescriptor(hasMacCtrl, hasCtrl, hasAlt, hasShift, key);
}
/**
* Convert normalized key representation to display appropriate for platform.
* @param {!string} descriptor Normalized key descriptor.
* @return {!string} Display/Operating system appropriate string
*/
function formatKeyDescriptor(descriptor) {
var displayStr;
if (brackets.platform === "mac") {
displayStr = descriptor.replace(/-(?!$)/g, ""); // remove dashes
displayStr = displayStr.replace("Ctrl", "\u2303"); // Ctrl > control symbol
displayStr = displayStr.replace("Cmd", "\u2318"); // Cmd > command symbol
displayStr = displayStr.replace("Shift", "\u21E7"); // Shift > shift symbol
displayStr = displayStr.replace("Alt", "\u2325"); // Alt > option symbol
} else {
displayStr = descriptor.replace("Ctrl", Strings.KEYBOARD_CTRL);
displayStr = displayStr.replace("Shift", Strings.KEYBOARD_SHIFT);
displayStr = displayStr.replace(/-(?!$)/g, "+");
}
displayStr = displayStr.replace("Space", Strings.KEYBOARD_SPACE);
displayStr = displayStr.replace("PageUp", Strings.KEYBOARD_PAGE_UP);
displayStr = displayStr.replace("PageDown", Strings.KEYBOARD_PAGE_DOWN);
displayStr = displayStr.replace("Home", Strings.KEYBOARD_HOME);
displayStr = displayStr.replace("End", Strings.KEYBOARD_END);
displayStr = displayStr.replace("Ins", Strings.KEYBOARD_INSERT);
displayStr = displayStr.replace("Del", Strings.KEYBOARD_DELETE);
return displayStr;
}
/**
* @private
* @param {string} A normalized key-description string.
* @return {boolean} true if the key is already assigned, false otherwise.
*/
function _isKeyAssigned(key) {
return (_keyMap[key] !== undefined);
}
/**
* Remove a key binding from _keymap
*
* @param {!string} key - a key-description string that may or may not be normalized.
* @param {?string} platform - OS from which to remove the binding (all platforms if unspecified)
*/
function removeBinding(key, platform) {
if (!key || ((platform !== null) && (platform !== undefined) && (platform !== brackets.platform))) {
return;
}
var normalizedKey = normalizeKeyDescriptorString(key);
if (!normalizedKey) {
console.log("Failed to normalize " + key);
} else if (_isKeyAssigned(normalizedKey)) {
var binding = _keyMap[normalizedKey],
command = CommandManager.get(binding.commandID),
bindings = _commandMap[binding.commandID];
// delete key binding record
delete _keyMap[normalizedKey];
if (bindings) {
// delete mapping from command to key binding
_commandMap[binding.commandID] = bindings.filter(function (b) {
return (b.key !== normalizedKey);
});
if (command) {
command.trigger("keyBindingRemoved", {key: normalizedKey, displayKey: binding.displayKey});
}
}
}
}
/**
* @private
*
* Updates _allCommands array and _defaultKeyMap with the new key binding
* if it is not yet in the _allCommands array. _allCommands array is initialized
* only in extensionsLoaded event. So any new commands or key bindings added after
* that will be updated here.
*
* @param {{commandID: string, key: string, displayKey:string, explicitPlatform: string}} newBinding
*/
function _updateCommandAndKeyMaps(newBinding) {
if (_allCommands.length === 0) {
return;
}
if (newBinding && newBinding.commandID && _allCommands.indexOf(newBinding.commandID) === -1) {
_defaultKeyMap[newBinding.commandID] = _.cloneDeep(newBinding);
// Process user key map again to catch any reassignment to all new key bindings added from extensions.
_loadUserKeyMap();
}
}
/**
* @private
*
* @param {string} commandID
* @param {string|{{key: string, displayKey: string}}} keyBinding - a single shortcut.
* @param {?string} platform
* - "all" indicates all platforms, not overridable
* - undefined indicates all platforms, overridden by platform-specific binding
* @param {boolean=} userBindings true if adding a user key binding or undefined otherwise.
* @return {?{key: string, displayKey:String}} Returns a record for valid key bindings.
* Returns null when key binding platform does not match, binding does not normalize,
* or is already assigned.
*/
function _addBinding(commandID, keyBinding, platform, userBindings) {
var key,
result = null,
normalized,
normalizedDisplay,
explicitPlatform = keyBinding.platform || platform,
targetPlatform,
command,
bindingsToDelete = [],
existing;
// For platform: "all", use explicit current platform
if (explicitPlatform && explicitPlatform !== "all") {
targetPlatform = explicitPlatform;
} else {
targetPlatform = brackets.platform;
}
// Skip if the key binding is not for this platform.
if (explicitPlatform === "mac" && brackets.platform !== "mac") {
return null;
}
// if the request does not specify an explicit platform, and we're
// currently on a mac, then replace Ctrl with Cmd.
key = (keyBinding.key) || keyBinding;
if (brackets.platform === "mac" && (explicitPlatform === undefined || explicitPlatform === "all")) {
key = key.replace("Ctrl", "Cmd");
if (keyBinding.displayKey !== undefined) {
keyBinding.displayKey = keyBinding.displayKey.replace("Ctrl", "Cmd");
}
}
normalized = normalizeKeyDescriptorString(key);
// skip if the key binding is invalid
if (!normalized) {
console.error("Unable to parse key binding " + key + ". Permitted modifiers: Ctrl, Cmd, Alt, Opt, Shift; separated by '-' (not '+').");
return null;
}
// check for duplicate key bindings
existing = _keyMap[normalized];
// for cross-platform compatibility
if (exports.useWindowsCompatibleBindings) {
// windows-only key bindings are used as the default binding
// only if a default binding wasn't already defined
if (explicitPlatform === "win") {
// search for a generic or platform-specific binding if it
// already exists
if (existing && (!existing.explicitPlatform ||
existing.explicitPlatform === brackets.platform ||
existing.explicitPlatform === "all")) {
// do not clobber existing binding with windows-only binding
return null;
}
// target this windows binding for the current platform
targetPlatform = brackets.platform;
}
}
// skip if this binding doesn't match the current platform
if (targetPlatform !== brackets.platform) {
return null;
}
// skip if the key is already assigned
if (existing) {
if (!existing.explicitPlatform && explicitPlatform) {
// remove the the generic binding to replace with this new platform-specific binding
removeBinding(normalized);
existing = false;
}
}
// delete existing bindings when
// (1) replacing a windows-compatible binding with a generic or
// platform-specific binding
// (2) replacing a generic binding with a platform-specific binding
var existingBindings = _commandMap[commandID] || [],
isWindowsCompatible,
isReplaceGeneric,
ignoreGeneric;
existingBindings.forEach(function (binding) {
// remove windows-only bindings in _commandMap
isWindowsCompatible = exports.useWindowsCompatibleBindings &&
binding.explicitPlatform === "win";
// remove existing generic binding
isReplaceGeneric = !binding.explicitPlatform &&
explicitPlatform;
if (isWindowsCompatible || isReplaceGeneric) {
bindingsToDelete.push(binding);
} else {
// existing binding is platform-specific and the requested binding is generic
ignoreGeneric = binding.explicitPlatform && !explicitPlatform;
}
});
if (ignoreGeneric) {
// explicit command binding overrides this one
return null;
}
if (existing) {
// do not re-assign a key binding
console.error("Cannot assign " + normalized + " to " + commandID + ". It is already assigned to " + _keyMap[normalized].commandID);
return null;
}
// remove generic or windows-compatible bindings
bindingsToDelete.forEach(function (binding) {
removeBinding(binding.key);
});
// optional display-friendly string (e.g. CMD-+ instead of CMD-=)
normalizedDisplay = (keyBinding.displayKey) ? normalizeKeyDescriptorString(keyBinding.displayKey) : normalized;
// 1-to-many commandID mapping to key binding
if (!_commandMap[commandID]) {
_commandMap[commandID] = [];
}
result = {
key : normalized,
displayKey : normalizedDisplay,
explicitPlatform : explicitPlatform
};
_commandMap[commandID].push(result);
// 1-to-1 key binding to commandID
_keyMap[normalized] = {
commandID : commandID,
key : normalized,
displayKey : normalizedDisplay,
explicitPlatform : explicitPlatform
};
if (!userBindings) {
_updateCommandAndKeyMaps(_keyMap[normalized]);
}
// notify listeners
command = CommandManager.get(commandID);
if (command) {
command.trigger("keyBindingAdded", result);
}
return result;
}
/**
* Returns a copy of the current key map. If the optional 'defaults' parameter is true,
* then a copy of the default key map is returned.
* @param {boolean=} defaults true if the caller wants a copy of the default key map.
* Otherwise, the current active key map is returned.
* @return {!Object.<string, {commandID: string, key: string, displayKey: string}>}
*/
function getKeymap(defaults) {
return $.extend({}, defaults ? _defaultKeyMap : _keyMap);
}
/**
* Process the keybinding for the current key.
*
* @param {string} A key-description string.
* @return {boolean} true if the key was processed, false otherwise
*/
function _handleKey(key) {
if (_enabled && _keyMap[key]) {
// The execute() function returns a promise because some commands are async.
// Generally, commands decide whether they can run or not synchronously,
// and reject immediately, so we can test for that synchronously.
var promise = CommandManager.execute(_keyMap[key].commandID);
return (promise.state() !== "rejected");
}
return false;
}
/**
* @private
*
* Sort objects by platform property. Objects with a platform property come
* before objects without a platform property.
*/
function _sortByPlatform(a, b) {
var a1 = (a.platform) ? 1 : 0,
b1 = (b.platform) ? 1 : 0;
return b1 - a1;
}
/**
* Add one or more key bindings to a particular Command.
*
* @param {!string | Command} command - A command ID or command object
* @param {?({key: string, displayKey: string}|Array.<{key: string, displayKey: string, platform: string}>)} keyBindings
* A single key binding or an array of keybindings. Example:
* "Shift-Cmd-F". Mac and Win key equivalents are automatically
* mapped to each other. Use displayKey property to display a different
* string (e.g. "CMD+" instead of "CMD=").
* @param {?string} platform The target OS of the keyBindings either
* "mac", "win" or "linux". If undefined, all platforms not explicitly
* defined will use the key binding.
* NOTE: If platform is not specified, Ctrl will be replaced by Cmd for "mac" platform
* @return {{key: string, displayKey:String}|Array.<{key: string, displayKey:String}>}
* Returns record(s) for valid key binding(s)
*/
function addBinding(command, keyBindings, platform) {
var commandID = "",
results;
if (!command) {
console.error("addBinding(): missing required parameter: command");
return;
}
if (!keyBindings) { return; }
if (typeof (command) === "string") {
commandID = command;
} else {
commandID = command.getID();
}
if (Array.isArray(keyBindings)) {
var keyBinding;
results = [];
// process platform-specific bindings first
keyBindings.sort(_sortByPlatform);
keyBindings.forEach(function addSingleBinding(keyBindingRequest) {
// attempt to add keybinding
keyBinding = _addBinding(commandID, keyBindingRequest, keyBindingRequest.platform);
if (keyBinding) {
results.push(keyBinding);
}
});
} else {
results = _addBinding(commandID, keyBindings, platform);
}
return results;
}
/**
* Retrieve key bindings currently associated with a command
*
* @param {!string | Command} command - A command ID or command object
* @return {!Array.<{{key: string, displayKey: string}}>} An array of associated key bindings.
*/
function getKeyBindings(command) {
var bindings = [],
commandID = "";
if (!command) {
console.error("getKeyBindings(): missing required parameter: command");
return [];
}
if (typeof (command) === "string") {
commandID = command;
} else {
commandID = command.getID();
}
bindings = _commandMap[commandID];
return bindings || [];
}
/**
* Adds default key bindings when commands are registered to CommandManager
* @param {$.Event} event jQuery event
* @param {Command} command Newly registered command
*/
function _handleCommandRegistered(event, command) {
var commandId = command.getID(),
defaults = KeyboardPrefs[commandId];
if (defaults) {
addBinding(commandId, defaults);
}
}
/**
* Adds a global keydown hook that gets first crack at keydown events
* before standard keybindings do. This is intended for use by modal or
* semi-modal UI elements like dialogs or the code hint list that should
* execute before normal command bindings are run.
*
* The hook is passed one parameter, the original keyboard event. If the
* hook handles the event (or wants to block other global hooks from
* handling the event), it should return true. Note that this will *only*
* stop other global hooks and KeyBindingManager from handling the
* event; to prevent further event propagation, you will need to call
* stopPropagation(), stopImmediatePropagation(), and/or preventDefault()
* as usual.
*
* Multiple keydown hooks can be registered, and are executed in order,
* most-recently-added first.
*
* (We have to have a special API for this because (1) handlers are normally
* called in least-recently-added order, and we want most-recently-added;
* (2) native DOM events don't have a way for us to find out if
* stopImmediatePropagation()/stopPropagation() has been called on the
* event, so we have to have some other way for one of the hooks to
* indicate that it wants to block the other hooks from running.)
*
* @param {function(Event): boolean} hook The global hook to add.
*/
function addGlobalKeydownHook(hook) {
_globalKeydownHooks.push(hook);
}
/**
* Removes a global keydown hook added by `addGlobalKeydownHook`.
* Does not need to be the most recently added hook.
*
* @param {function(Event): boolean} hook The global hook to remove.
*/
function removeGlobalKeydownHook(hook) {
var index = _globalKeydownHooks.indexOf(hook);
if (index !== -1) {
_globalKeydownHooks.splice(index, 1);
}
}
/**
* Handles a given keydown event, checking global hooks first before
* deciding to handle it ourselves.
* @param {Event} The keydown event to handle.
*/
function _handleKeyEvent(event) {
var i, handled = false;
for (i = _globalKeydownHooks.length - 1; i >= 0; i--) {
if (_globalKeydownHooks[i](event)) {
handled = true;
break;
}
}
_detectAltGrKeyDown(event);
if (!handled && _handleKey(_translateKeyboardEvent(event))) {
event.stopPropagation();
event.preventDefault();
}
}
AppInit.htmlReady(function () {
// Install keydown event listener.
window.document.body.addEventListener(
"keydown",
_handleKeyEvent,
true
);
exports.useWindowsCompatibleBindings = (brackets.platform !== "mac") &&
(brackets.platform !== "win");
});
/**
* @private
* Displays an error dialog and also opens the user key map file for editing only if
* the error is not the loading file error.
*
* @param {?string} err Error type returned from JSON parser or open file operation
* @param {string=} message Error message to be displayed in the dialog
*/
function _showErrorsAndOpenKeyMap(err, message) {
// Asynchronously loading Dialogs module to avoid the circular dependency
require(["widgets/Dialogs"], function (Dialogs) {
var errorMessage = Strings.ERROR_KEYMAP_CORRUPT;
if (err === FileSystemError.UNSUPPORTED_ENCODING) {
errorMessage = Strings.ERROR_LOADING_KEYMAP;
} else if (message) {
errorMessage = message;
}
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.ERROR_KEYMAP_TITLE,
errorMessage
)
.done(function () {
if (err !== FileSystemError.UNSUPPORTED_ENCODING) {
CommandManager.execute(Commands.FILE_OPEN_KEYMAP);
}
});
});
}
/**
* @private
*
* Checks whether the given command ID is a special command that the user can't bind
* to another shortcut.
* @param {!string} commandID A string referring to a specific command
* @return {boolean} true if normalizedKey is a special command, false otherwise.
*/
function _isSpecialCommand(commandID) {
if (brackets.platform === "mac" && commandID === "file.quit") {
return true;
}
return (_specialCommands.indexOf(commandID) > -1);
}
/**
* @private
*
* Checks whether the given key combination is a shortcut of a special command
* or a Mac system command that the user can't reassign to another command.
* @param {!string} normalizedKey A key combination string used for a keyboard shortcut
* @return {boolean} true if normalizedKey is a restricted shortcut, false otherwise.
*/
function _isReservedShortcuts(normalizedKey) {
if (!normalizedKey) {
return false;
}
if (_reservedShortcuts.indexOf(normalizedKey) > -1 ||
_reservedShortcuts.indexOf(normalizedKey.replace("Cmd", "Ctrl")) > -1) {
return true;
}
if (brackets.platform === "mac" && _macReservedShortcuts.indexOf(normalizedKey) > -1) {
return true;
}
return false;
}
/**
* @private
*
* Creates a bullet list item for any item in the given list.
* @param {Array.<string>} list An array of strings to be converted into a
* message string with a bullet list.
* @return {string} the html text version of the list
*/
function _getBulletList(list) {
var message = "<ul class='dialog-list'>";
list.forEach(function (info) {
message += "<li>" + info + "</li>";
});
message += "</ul>";
return message;
}
/**
* @private
*
* Gets the corresponding unicode symbol of an arrow key for display in the menu.
* @param {string} key The non-modifier key used in the shortcut. It does not need to be normalized.
* @return {string} An empty string if key is not one of those we want to show with the unicode symbol.
* Otherwise, the corresponding unicode symbol is returned.
*/
function _getDisplayKey(key) {
var displayKey = "",
match = key ? key.match(/(Up|Down|Left|Right|\-)$/i) : null;
if (match && !/Page(Up|Down)/.test(key)) {
displayKey = key.substr(0, match.index) + _displayKeyMap[match[0].toLowerCase()];
}
return displayKey;
}
/**
* @private
*
* Applies each user key binding to all the affected commands and updates _keyMap.
* Shows errors in a dialog and then opens the user key map file if any of the following
* is detected while applying the user key bindings.
* - A key binding is attempting to modify a special command.
* - A key binding is attempting to assign a shortcut of a special command to another one.
* - Multiple key bindings are specified for the same command ID.
* - The same key combination is listed for multiple key bindings.
* - A key binding has any invalid key syntax.
* - A key binding is referring to a non-existent command ID.
*/
function _applyUserKeyBindings() {
var remappedCommands = [],
remappedKeys = [],
restrictedCommands = [],
restrictedKeys = [],
invalidKeys = [],
invalidCommands = [],
multipleKeys = [],
duplicateBindings = [],
errorMessage = "";
_.forEach(_customKeyMap, function (commandID, key) {
var normalizedKey = normalizeKeyDescriptorString(key),
existingBindings = _commandMap[commandID] || [];
// Skip this since we don't allow user to update key binding of a special
// command like cut, copy, paste, undo, redo and select all.
if (_isSpecialCommand(commandID)) {
restrictedCommands.push(commandID);
return;
}
// Skip this since we don't allow user to update a shortcut used in
// a special command or any Mac system command.
if (_isReservedShortcuts(normalizedKey)) {
restrictedKeys.push(key);
return;
}
// Skip this if the key is invalid.
if (!normalizedKey) {
invalidKeys.push(key);
return;
}
if (_isKeyAssigned(normalizedKey)) {
if (remappedKeys.indexOf(normalizedKey) !== -1) {
// JSON parser already removed all the duplicates that have the exact
// same case or order in their keys. So we're only detecting duplicate
// bindings that have different orders or different cases used in the key.
duplicateBindings.push(key);
return;
}
// The same key binding already exists, so skip this.
if (_keyMap[normalizedKey].commandID === commandID) {
// Still need to add it to the remappedCommands so that
// we can detect any duplicate later on.
remappedCommands.push(commandID);
return;
}
removeBinding(normalizedKey);
}
if (remappedKeys.indexOf(normalizedKey) === -1) {
remappedKeys.push(normalizedKey);
}
// Remove another key binding if the new key binding is for a command
// that has a different key binding. e.g. "Ctrl-W": "edit.selectLine"
// requires us to remove "Ctrl-W" from "file.close" command, but we
// also need to remove "Ctrl-L" from "edit.selectLine".
if (existingBindings.length) {
existingBindings.forEach(function (binding) {
removeBinding(binding.key);
});
}
if (commandID) {
if (_allCommands.indexOf(commandID) !== -1) {
if (remappedCommands.indexOf(commandID) === -1) {
var keybinding = { key: normalizedKey };
keybinding.displayKey = _getDisplayKey(normalizedKey);
_addBinding(commandID, keybinding.displayKey ? keybinding : normalizedKey, brackets.platform, true);
remappedCommands.push(commandID);
} else {
multipleKeys.push(commandID);
}
} else {
invalidCommands.push(commandID);
}
}
});
if (restrictedCommands.length) {
errorMessage = StringUtils.format(Strings.ERROR_RESTRICTED_COMMANDS, _getBulletList(restrictedCommands));
}
if (restrictedKeys.length) {
errorMessage += StringUtils.format(Strings.ERROR_RESTRICTED_SHORTCUTS, _getBulletList(restrictedKeys));
}
if (multipleKeys.length) {
errorMessage += StringUtils.format(Strings.ERROR_MULTIPLE_SHORTCUTS, _getBulletList(multipleKeys));
}
if (duplicateBindings.length) {
errorMessage += StringUtils.format(Strings.ERROR_DUPLICATE_SHORTCUTS, _getBulletList(duplicateBindings));
}
if (invalidKeys.length) {
errorMessage += StringUtils.format(Strings.ERROR_INVALID_SHORTCUTS, _getBulletList(invalidKeys));
}
if (invalidCommands.length) {
errorMessage += StringUtils.format(Strings.ERROR_NONEXISTENT_COMMANDS, _getBulletList(invalidCommands));
}
if (_showErrors && errorMessage) {
_showErrorsAndOpenKeyMap("", errorMessage);
}
}
/**
* @private
*
* Restores the default key bindings for all the commands that are modified by each key binding
* specified in _customKeyMapCache (old version) but no longer specified in _customKeyMap (new version).
*/
function _undoPriorUserKeyBindings() {
_.forEach(_customKeyMapCache, function (commandID, key) {
var normalizedKey = normalizeKeyDescriptorString(key),
defaults = _.find(_.toArray(_defaultKeyMap), { "commandID": commandID }),
defaultCommand = _defaultKeyMap[normalizedKey];
// We didn't modified this before, so skip it.
if (_isSpecialCommand(commandID) ||
_isReservedShortcuts(normalizedKey)) {
return;
}
if (_isKeyAssigned(normalizedKey) &&
_customKeyMap[key] !== commandID && _customKeyMap[normalizedKey] !== commandID) {
// Unassign the key from any command. e.g. "Cmd-W": "file.open" in _customKeyMapCache
// will require us to remove Cmd-W shortcut from file.open command.
removeBinding(normalizedKey);
}
// Reassign the default key binding. e.g. "Cmd-W": "file.open" in _customKeyMapCache
// will require us to reassign Cmd-O shortcut to file.open command.
if (defaults) {
addBinding(commandID, defaults, brackets.platform);
}
// Reassign the default key binding of the previously modified command.
// e.g. "Cmd-W": "file.open" in _customKeyMapCache will require us to reassign Cmd-W
// shortcut to file.close command.
if (defaultCommand && defaultCommand.key) {
addBinding(defaultCommand.commandID, defaultCommand.key, brackets.platform);
}
});
}
/**
* @private
*
* Gets the full file path to the user key map file. In testing environment
* a different file path is returned so that running integration tests won't
* pop up the error dialog showing the errors from the actual user key map file.
*
* @return {string} full file path to the user key map file.
*/
function _getUserKeyMapFilePath() {
if (window.isBracketsTestWindow) {
return brackets.app.getApplicationSupportDirectory() + "/_test_/" + KEYMAP_FILENAME;
}
return _userKeyMapFilePath;
}
/**
* @private
*
* Reads in the user key map file and parses its content into JSON.
* Returns the user key bindings if JSON has "overrides".
* Otherwise, returns an empty object or an error if the file
* cannot be parsed or loaded.
*
* @return {$.Promise} a jQuery promise that will be resolved with the JSON
* object if the user key map file has "overrides" property or an empty JSON.
* If the key map file cannot be read or cannot be parsed by the JSON parser,
* then the promise is rejected with an error.
*/
function _readUserKeyMap() {
var file = FileSystem.getFileForPath(_getUserKeyMapFilePath()),
result = new $.Deferred();
file.exists(function (err, doesExist) {
if (doesExist) {
FileUtils.readAsText(file)
.done(function (text) {
var keyMap = {};
try {
if (text) {
var json = JSON.parse(text);
// If no overrides, return an empty key map.
result.resolve((json && json.overrides) || keyMap);
} else {
// The file is empty, so return an empty key map.
result.resolve(keyMap);
}
} catch (err) {
// Cannot parse the text read from the key map file.
result.reject(err);
}
})
.fail(function (err) {
// Key map file cannot be loaded.
result.reject(err);
});
} else {
// Just resolve if no user key map file
result.resolve();
}
});
return result.promise();
}
/**
* @private
*
* Reads in the user key bindings and updates the key map with each user key
* binding by removing the existing one assigned to each key and adding
* new one for the specified command id. Shows errors and opens the user
* key map file if it cannot be parsed.
*
* This function is wrapped with debounce so that its execution is always delayed
* by 200 ms. The delay is required because when this function is called some
* extensions may still be adding some commands and their key bindings asychronously.
*/
_loadUserKeyMap = _.debounce(function () {
_readUserKeyMap()
.then(function (keyMap) {
// Some extensions may add a new command without any key binding. So
// we always have to get all commands again to ensure that we also have
// those from any extensions installed during the current session.
_allCommands = CommandManager.getAll();
_customKeyMapCache = _.cloneDeep(_customKeyMap);
_customKeyMap = keyMap;
_undoPriorUserKeyBindings();
_applyUserKeyBindings();
}, function (err) {
_showErrorsAndOpenKeyMap(err);
});
}, 200);
/**
* @private
*
* Opens the existing key map file or creates a new one with default content
* if it does not exist.
*/
function _openUserKeyMap() {
var userKeyMapPath = _getUserKeyMapFilePath(),
file = FileSystem.getFileForPath(userKeyMapPath);
file.exists(function (err, doesExist) {
if (doesExist) {
CommandManager.execute(Commands.FILE_OPEN, { fullPath: userKeyMapPath });
} else {
var defaultContent = "{\n \"documentation\": \"https://github.com/adobe/brackets/wiki/User-Key-Bindings\"," +
"\n \"overrides\": {" +
"\n \n }\n}\n";
FileUtils.writeText(file, defaultContent, true)
.done(function () {
CommandManager.execute(Commands.FILE_OPEN, { fullPath: userKeyMapPath });
});
}
});
}
// Due to circular dependencies, not safe to call on() directly
EventDispatcher.on_duringInit(CommandManager, "commandRegistered", _handleCommandRegistered);
CommandManager.register(Strings.CMD_OPEN_KEYMAP, Commands.FILE_OPEN_KEYMAP, _openUserKeyMap);
// Asynchronously loading DocumentManager to avoid the circular dependency
require(["document/DocumentManager"], function (DocumentManager) {
DocumentManager.on("documentSaved", function checkKeyMapUpdates(e, doc) {
if (doc && doc.file.fullPath === _userKeyMapFilePath) {
_loadUserKeyMap();
}
});
});
/**
* @private
*
* Initializes _allCommands array and _defaultKeyMap so that we can use them for
* detecting non-existent commands and restoring the original key binding.
*/
function _initCommandAndKeyMaps() {
_allCommands = CommandManager.getAll();
// Keep a copy of the default key bindings before loading user key bindings.
_defaultKeyMap = _.cloneDeep(_keyMap);
}
/**
* @private
*
* Sets the full file path to the user key map file. Only used by unit tests
* to load a test file instead of the actual user key map file.
*
* @param {string} fullPath file path to the user key map file.
*/
function _setUserKeyMapFilePath(fullPath) {
_userKeyMapFilePath = fullPath;
}
AppInit.extensionsLoaded(function () {
var params = new UrlParams();
params.parse();
if (params.get("reloadWithoutUserExts") === "true") {
_showErrors = false;
}
_initCommandAndKeyMaps();
_loadUserKeyMap();
});
// unit test only
exports._reset = _reset;
exports._setUserKeyMapFilePath = _setUserKeyMapFilePath;
exports._getDisplayKey = _getDisplayKey;
exports._loadUserKeyMap = _loadUserKeyMap;
exports._initCommandAndKeyMaps = _initCommandAndKeyMaps;
exports._onCtrlUp = _onCtrlUp;
// Define public API
exports.getKeymap = getKeymap;
exports.addBinding = addBinding;
exports.removeBinding = removeBinding;
exports.formatKeyDescriptor = formatKeyDescriptor;
exports.getKeyBindings = getKeyBindings;
exports.addGlobalKeydownHook = addGlobalKeydownHook;
exports.removeGlobalKeydownHook = removeGlobalKeydownHook;
/**
* Use windows-specific bindings if no other are found (e.g. Linux).
* Core Brackets modules that use key bindings should always define at
* least a generic keybinding that is applied for all platforms. This
* setting effectively creates a compatibility mode for third party
* extensions that define explicit key bindings for Windows and Mac, but
* not Linux.
*/
exports.useWindowsCompatibleBindings = false;
// For unit testing only
exports._handleKey = _handleKey;
exports._handleKeyEvent = _handleKeyEvent;
});