import _ from 'lodash'; import {gettext} from 'core/utils'; export const KEYS = Object.freeze({ pageup: 33, pagedown: 34, left: 37, up: 38, right: 39, down: 40, enter: 13, escape: 27, space: 32, backspace: 8, }); const superdeskKeyboardKeyNamesConvention = { // native key name, superdesk key name 'Enter': 'enter', 'ArrowUp': 'up', 'ArrowRight': 'right', 'ArrowLeft': 'left', 'ArrowDown': 'down', 'Escape': 'escape', }; function getKeyAccordingToSuperdeskConvention(key: string) { return superdeskKeyboardKeyNamesConvention[key] ?? key.toLowerCase(); } export function getNativeKey(superdeskKey) { return Object.keys(superdeskKeyboardKeyNamesConvention) .find((key) => superdeskKeyboardKeyNamesConvention[key] === superdeskKey) ?? superdeskKey; } function shouldInvoke(combination: string, event: KeyboardEvent) { let key = null; let ctrlKey = false; let altKey = false; let shiftKey = false; combination.split('+').forEach((_key) => { if (_key === 'ctrl') { ctrlKey = true; } else if (_key === 'alt') { altKey = true; } else if (_key === 'shift') { shiftKey = true; } else { key = _key; } }); return ( event.ctrlKey === ctrlKey && event.altKey === altKey && event.shiftKey === shiftKey && getKeyAccordingToSuperdeskConvention(event.key) === key ); } export default angular.module('superdesk.core.keyboard', []) .constant('Keys', KEYS) .constant('shiftNums', { '`': '~', 1: '!', 2: '@', 3: '#', 4: '$', 5: '%', 6: '^', 7: '&', 8: '*', 9: '(', 0: ')', '-': '_', '=': '+', ';': ':', '\'': '"', ',': '<', '.': '>', '/': '?', '\\': '|', }) // unbind all keyboard shortcuts when switching route .run(['$rootScope', 'keyboardManager', function($rootScope, kb) { $rootScope.$on('$routeChangeStart', () => { angular.forEach(kb.keyboardEvent, (e, key) => { if (!e.opt.global) { kb.unbind(key); } }); }); }]) /** * Broadcast key:char events cought on body */ .run(['$rootScope', '$document', 'Keys', 'shiftNums', function KeyEventBroadcast($rootScope, $document, Keys, shiftNums) { var ignoreNodes = { INPUT: true, TEXTAREA: true, BUTTON: true, }; $document.on('keydown', (e) => { var ctrlKey = e.ctrlKey || e.metaKey, altKey = e.altKey, shiftKey = e.shiftKey, isGlobal = ctrlKey && shiftKey; if (isGlobal || !ignoreNodes[e.target.nodeName]) { // $document.body is empty when testing var character = e.key.toLowerCase(), modifier = ''; modifier += ctrlKey ? 'ctrl:' : ''; modifier += altKey ? 'alt:' : ''; modifier += shiftKey ? 'shift:' : ''; // also handle arrows, enter/escape, etc. angular.forEach(Object.keys(Keys), (key) => { if (e.which === Keys[key]) { character = key; } }); // Emit the corresponding shift character the same way keyboardManager service emits if (shiftKey && shiftNums[character]) { character = shiftNums[character]; } $rootScope.$broadcast('key:' + modifier + character, e); } }); }]) .service('keyboardManager', ['$window', '$timeout', 'shiftNums', function($window, $timeout, shiftNums) { var stack = [], defaultOpt = { type: 'keydown', propagate: true, inputDisabled: false, target: $window.document, keyCode: false, global: false, }, specialKeys = { // Special Keys - and their codes esc: 27, escape: 27, tab: 9, space: 32, return: 13, enter: 13, backspace: 8, scrolllock: 145, scroll_lock: 145, scroll: 145, capslock: 20, caps_lock: 20, caps: 20, numlock: 144, num_lock: 144, num: 144, pause: 19, break: 19, insert: 45, home: 36, delete: 46, end: 35, pageup: 33, page_up: 33, pu: 33, pagedown: 34, page_down: 34, pd: 34, left: 37, up: 38, right: 39, down: 40, f1: 112, f2: 113, f3: 114, f4: 115, f5: 116, f6: 117, f7: 118, f8: 119, f9: 120, f10: 121, f11: 122, f12: 123, }; // Store all keyboard combination shortcuts this.keyboardEvent = {}; this.registry = {}; this.register = function register(group, label, description) { this.registry[group] = this.registry[group] || {}; this.registry[group][label] = description; }; // Add a new keyboard combination shortcut this.bind = function bind(label, callback, opt) { var fct, elt; // Initialize options object let options = angular.extend({}, defaultOpt, opt); let lbl = label.toLowerCase(); elt = options.target; if (typeof options.target === 'string') { elt = document.getElementById(options.target); } const inputDisabled = function(e) { if (options.inputDisabled) { var elem; if (e.target) { elem = e.target; } else if (e.srcElement) { elem = e.srcElement; } if (elem.nodeType === 3) { elem = elem.parentNode; } return elem.tagName === 'INPUT' || elem.tagName === 'TEXTAREA' || elem.className.indexOf('editor-type-html') !== -1; } }; fct = function keyboardHandler(e = $window.event) { // Disable event handler when focus input and textarea if (inputDisabled(e)) { return; } if (shouldInvoke(label, e)) { $timeout(() => { callback(e); }, 1); if (!options.propagate) { // Stop the event // e.cancelBubble is supported by IE - this will kill the bubbling process. e.cancelBubble = true; e.returnValue = false; // e.stopPropagation works in Firefox. if (e.stopPropagation) { e.stopPropagation(); e.preventDefault(); } return false; } } }; // Store shortcut this.keyboardEvent[lbl] = { callback: fct, target: elt, event: options.type, _callback: callback, opt: options, label: lbl, }; // Attach the function with the event if (elt.addEventListener) { elt.addEventListener(options.type, fct, false); } else if (elt.attachEvent) { elt.attachEvent('on' + options.type, fct); } else { elt['on' + options.type] = fct; } }; this.push = function push(label, callback, options) { var e = this.keyboardEvent[label.toLowerCase()]; if (e) { stack.push(e); this.unbind(label); } this.bind(label, callback, options); }; this.pop = function pop(label) { this.unbind(label); var index = _.findLastIndex(stack, {label: label.toLowerCase()}); if (index !== -1) { this.bind(label, stack[index]._callback, stack[index].opt); stack.splice(index, 0); } }; // Remove the shortcut - just specify the shortcut and I will remove the binding this.unbind = function unbind(label) { let lbl = label.toLowerCase(); var binding = this.keyboardEvent[lbl]; delete this.keyboardEvent[lbl]; if (!binding) { return; } var type = binding.event, elt = binding.target, callback = binding.callback; if (elt.detachEvent) { elt.detachEvent('on' + type, callback); } else if (elt.removeEventListener) { elt.removeEventListener(type, callback, false); } else { elt['on' + type] = false; } }; }]) .directive('sdHotkey', ['keyboardManager', '$timeout', function(keyboardManager, $timeout) { return { link: function(scope, elem, attrs, ctrl) { var hotkey = attrs.sdHotkey, callback = scope.$eval(attrs.sdHotkeyCallback), options = scope.$eval(attrs.sdHotkeyOptions); keyboardManager.bind(hotkey, (e) => { e.preventDefault(); if (callback) { callback(); } else { elem.click(); } }, options); /* * On scope $destroy unbind binded shortcuts */ scope.$on('$destroy', () => { keyboardManager.unbind(hotkey); }); $timeout(() => { if (elem.attr('title')) { elem.attr('title', elem.attr('title') + ' (' + hotkey + ')'); } else if (elem.attr('tooltip')) { elem.attr('tooltip', elem.attr('tooltip') + ' (' + hotkey + ')'); } else { elem.attr('title', hotkey); } }, 0, false); }, }; }]) .directive('sdKeyboardModal', ['keyboardManager', function(keyboardManager) { return { scope: true, templateUrl: 'scripts/core/keyboard/views/keyboard-modal.html', link: function(scope) { scope.enabled = false; scope.data = {}; keyboardManager.bind('alt+k', () => { scope.enabled = true; scope.data = keyboardManager.registry; }, { global: true, group: gettext('General'), description: gettext('Displays active keyboard shortcuts'), }); keyboardManager.bind('alt+k', () => { scope.enabled = false; }, { global: true, type: 'keyup', group: gettext('General'), description: gettext('Displays active keyboard shortcuts'), }); scope.close = function() { scope.enabled = false; }; }, }; }]);