From a2e3807a3a0d15662a17dbc50bfd155aa820934f Mon Sep 17 00:00:00 2001 From: Francis Lachapelle Date: Fri, 23 Sep 2016 17:17:25 -0400 Subject: [PATCH] (js) Initial support for keyboard shortcuts --- .../js/Common/sgHotkeys.service.js | 376 ++++++++++++++++++ .../js/Mailer/MailboxController.js | 156 +++++++- .../js/Mailer/MailboxesController.js | 4 +- .../js/Mailer/Message.service.js | 4 +- .../js/Mailer/MessageController.js | 32 +- 5 files changed, 545 insertions(+), 27 deletions(-) create mode 100644 UI/WebServerResources/js/Common/sgHotkeys.service.js diff --git a/UI/WebServerResources/js/Common/sgHotkeys.service.js b/UI/WebServerResources/js/Common/sgHotkeys.service.js new file mode 100644 index 000000000..0ebfeb6e1 --- /dev/null +++ b/UI/WebServerResources/js/Common/sgHotkeys.service.js @@ -0,0 +1,376 @@ +/* -*- Mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ + +(function() { + /* jshint validthis: true */ + 'use strict'; + + /** + * $sgHotkeys - A service to associate keyboard shortcuts to actions. + * @memberof SOGo.Common + * + * @description + * This service is a modified version of angular-hotkeys-light by Eugene Brodsky: + * https://github.com/fupslot/angular-hotkeys-light + */ + function $sgHotkeys() { + + // Key-code values for various meta-keys. + // Source : http://www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes + // http://unixpapa.com/js/key.html + // Date: Oct 02, 2015. + //var KEY_CODES = this.KEY_CODES = { + var KEY_CODES = { + 8: 'backspace', + 9: 'tab', + 13: 'enter', + 16: 'shift', + 17: 'ctrl', + 18: 'alt', + 19: 'pause', + 20: 'caps', + 27: 'escape', + 32: 'space', + 33: 'pageup', + 34: 'pagedown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 45: 'insert', + 46: 'delete', + // Numpad + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9', + 106: '*', + 107: '+', + 109: '-', + 110: '.', + 111: '/', + // Function keys + 112: 'f1', + 113: 'f2', + 114: 'f3', + 115: 'f4', + 116: 'f5', + 117: 'f6', + 118: 'f7', + 119: 'f8', + 120: 'f9', + 121: 'f10', + 122: 'f11', + 123: 'f12' + }; + + this.$get = getService; + + getService.$inject = ['$rootScope', '$window']; + function getService($rootScope, $window) { + + var wrapWithApply = function (fn) { + return function(event, args) { + $rootScope.$apply(function() { + fn.call(this, event, args); + }.bind(this)); + }; + }; + + var HotKey = function(params) { + this.id = params.id || guid(); + this.key = params.key; + this.context = params.context || null; + this.callback = params.callback; + this.preventInClass = params.preventInClass; + this.args = params.args; + this.onKeyUp = false; + }; + + HotKey.prototype.clone = function() { + return new HotKey(this); + }; + + var Hotkeys = function() { + /** + * Sometimes a UI wants keybindings which are global, so called hotkeys. + * Keys are keystrings (identify key combinations) and values are objects + * with keys callback, context. + */ + this._hotkeys = {}; + + /** + * Sometimes a UI wants keybindings for keyup behaviour. + */ + this._hotkeysUp = {}; + + /** + * Keybindings are ignored by default when coming from a form input field. + */ + this._preventIn = ['INPUT', 'SELECT', 'MD-SELECT', 'TEXTAREA']; + + this._onKeydown = this._onKeydown.bind(this); + this._onKeyup = this._onKeyup.bind(this); + + this.initialize(); + }; + + /** + * Binds Keydown, Keyup with the window object + */ + Hotkeys.prototype.initialize = function() { + $window.addEventListener('keydown', this._onKeydown, true); + $window.addEventListener('keyup', this._onKeyup, true); + }; + + /** + * Invokes callback functions assosiated with the given hotkey + * @param {Event} event + * @param {String} keyString hotkey + * @param {Array.} hotkeys List of registered callbacks for + * the given hotkey + * @private + */ + Hotkeys.prototype._invokeHotkeyHandlers = function(event, keyString, hotkeys) { + for (var i = 0, l = hotkeys.length; i < l; i++) { + var hotkey = hotkeys[i], + target = event.target || event.srcElement, + nodeName = target.nodeName.toUpperCase(); + if (!_.includes(this._preventIn, nodeName) && + _.intersection(target.classList, hotkey.preventInClass).length === 0) { + try { + hotkey.callback.call(hotkey.context, event, hotkey.args); + } catch(e) { + console.error('HotKeys: ', hotkey.key, e.message); + } + } + } + }; + + /** + * Keydown Event Handler + * @private + */ + Hotkeys.prototype._onKeydown = function(event) { + var keyString = this.keyStringFromEvent(event); + if (this._hotkeys[keyString]) { + this._invokeHotkeyHandlers(event, keyString, this._hotkeys[keyString]); + } + }; + + /** + * Keyup Event Handler + * @private + */ + Hotkeys.prototype._onKeyup = function(event) { + var keyString = this.keyStringFromEvent(event); + if (this._hotkeysUp[keyString]) { + this._invokeHotkeyHandlers(this._hotkeysUp[keyString], keyString); + } + }; + + /** + * Cross-browser method which can extract a key string from an event. + * Key strings are of the form + * + * ctrl+alt+shift+meta+character + * + * where each of the 4 modifiers may or may not appear, but always appear + * in that order if they do appear. + * + * TODO : this is not yet implemented fully. The trouble is, the keyCode, + * charCode, and which properties of the DOM standard KeyboardEvent are + * discouraged in favour of the use of key and char, but key and char are + * not yet implemented in Gecko nor in Blink/Webkit. We need to leverage + * keyCode/charCode so that current browser versions are supported, but also + * look to key and char because they're apparently more useful and are the + * future. + */ + Hotkeys.prototype.keyStringFromEvent = function(event) { + var result = []; + var key = event.which; + + if (KEY_CODES[key]) { + key = KEY_CODES[key]; + } else { + key = String.fromCharCode(key).toLowerCase(); + } + + if (event.ctrlKey) { result.push('ctrl'); } + if (event.altKey) { result.push('alt'); } + if (event.shiftKey) { result.push('shift'); } + if (event.metaKey) { result.push('meta'); } + result.push(key); + return _.uniq(result).join('+'); + }; + + /** + * Unregister a hotkey (shortcut) helper for (keyUp/keyDown). + * + * @param {String} params.key - valid key string. + */ + Hotkeys.prototype._deregisterHotkey = function(hotkey) { + var ret; + var table = this._hotkeys; + + if (hotkey.onKeyUp) { + table = this._hotkeysUp; + } + + if (table[hotkey.key]) { + var callbackArray = table[hotkey.key]; + for (var i = 0; i < callbackArray.length; ++i) { + var callbackData = callbackArray[i]; + if ((hotkey.callback === callbackData.callback && + callbackData.context === hotkey.context) || + (hotkey.id === callbackData.id)) { + ret = callbackArray.splice(i, 1); + } + } + } + return ret; + }; + + /** + * Unregister hotkeys + * @param {Hotkey} hotkey A hotkey object + * @return {Array} + */ + Hotkeys.prototype.deregisterHotkey = function(hotkey) { + var result = []; + + this._validateHotkey(hotkey); + + if (angular.isArray(hotkey.key)) { + for (var i = hotkey.key.length - 1; i >= 0; i--) { + var clone = hotkey.clone(); + clone.key = hotkey.key[i]; + var ret = this._deregisterHotkey(clone); + if (ret !== void 0) { + result.push(ret[0]); + } + } + } else { + result.push(this._deregisterHotkey(hotkey)); + } + return result; + }; + + /** + * Validate HotKey type + */ + Hotkeys.prototype._validateHotkey = function(hotkey) { + if (!(hotkey instanceof HotKey)) { + throw new TypeError('Hotkeys: Expected a hotkey object be instance of HotKey'); + } + }; + + /** + * Register a hotkey (shortcut) helper for (keyUp/keyDown). + * @param {Object} params Parameters object + * @param {String} params.key - valid key string. + * @param {Function} params.callback - routine to run when key is pressed. + * @param {Object} params.context - @this value in the callback. + * @param [Boolean] params.onKeyUp - if this is intended for a keyup. + * @param [String] params.id - the identifier for this registration. + */ + Hotkeys.prototype._registerKey = function(hotkey) { + var table = this._hotkeys; + + if (hotkey.onKeyUp) { + table = this._hotkeysUp; + } + + table[hotkey.key] = table[hotkey.key] || []; + table[hotkey.key].push(hotkey); + return hotkey; + }; + + Hotkeys.prototype._registerKeys = function(hotkey) { + var result = []; + + if (angular.isArray(hotkey.key)) { + for (var i = hotkey.key.length - 1; i >= 0; i--) { + var clone = hotkey.clone(); + clone.id = guid(); + clone.key = hotkey.key[i]; + result.push(this._registerKey(clone)); + } + } else { + result.push(this._registerKey(hotkey)); + } + return result; + }; + + /** + * Register a hotkey (shortcut). see _registerHotKey + */ + Hotkeys.prototype.registerHotkey = function(hotkey) { + this._validateHotkey(hotkey); + return this._registerKeys(hotkey); + }; + + /** + * Register a hotkey (shortcut) keyup behavior. + * see _registerHotKey + */ + Hotkeys.prototype.registerHotkeyUp = function(hotkey) { + this._validateHotkey(hotkey); + hotkey.onKeyUp = true; + this._registerKeys(hotkey); + }; + + /** + * Creates new hotkey object. + * @param {Object} args + * @return {HotKey} + */ + Hotkeys.prototype.createHotkey = function(args) { + if (args.key === null || args.key === void 0) { + throw new TypeError('HotKeys: Argument "key" is required'); + } + + if (args.callback === null || args.callback === void 0) { + throw new TypeError('HotKeys: Argument "callback" is required'); + } + + args.callback = wrapWithApply(args.callback); + return new HotKey(args); + }; + + /** + * Checks if given shortcut match the event + * @param {Event} event An event + * @param {String|Array} key A shortcut + * @return {Boolean} + */ + Hotkeys.prototype.match = function(event, key) { + if (!angular.isArray(key)) { + key = [key]; + } + + var eventHotkey = this.keyStringFromEvent(event); + return Boolean(~key.indexOf(eventHotkey)); + }; + + return Hotkeys; + } + } + + sgHotkeys.$inject = ['$sgHotkeys']; + function sgHotkeys($sgHotkeys) { + return new $sgHotkeys(); + } + + angular + .module('SOGo.Common') + .service('sgHotkeys', sgHotkeys) + .provider('$sgHotkeys', $sgHotkeys); +})(); diff --git a/UI/WebServerResources/js/Mailer/MailboxController.js b/UI/WebServerResources/js/Mailer/MailboxController.js index 3d2efbbef..5588d0fe9 100644 --- a/UI/WebServerResources/js/Mailer/MailboxController.js +++ b/UI/WebServerResources/js/Mailer/MailboxController.js @@ -6,10 +6,11 @@ /** * @ngInject */ - MailboxController.$inject = ['$window', '$scope', '$timeout', '$q', '$state', '$mdDialog', '$mdToast', 'stateAccounts', 'stateAccount', 'stateMailbox', 'encodeUriFilter', 'sgFocus', 'Dialog', 'Account', 'Mailbox']; - function MailboxController($window, $scope, $timeout, $q, $state, $mdDialog, $mdToast, stateAccounts, stateAccount, stateMailbox, encodeUriFilter, focus, Dialog, Account, Mailbox) { + MailboxController.$inject = ['$window', '$scope', '$timeout', '$q', '$state', '$mdDialog', '$mdToast', 'stateAccounts', 'stateAccount', 'stateMailbox', 'sgHotkeys', 'encodeUriFilter', 'sgFocus', 'Dialog', 'Account', 'Mailbox']; + function MailboxController($window, $scope, $timeout, $q, $state, $mdDialog, $mdToast, stateAccounts, stateAccount, stateMailbox, sgHotkeys, encodeUriFilter, focus, Dialog, Account, Mailbox) { var vm = this, messageDialog = null, - defaultWindowTitle = angular.element($window.document).find('title').attr('sg-default') || "SOGo"; + defaultWindowTitle = angular.element($window.document).find('title').attr('sg-default') || "SOGo", + hotkeys = []; // Expose controller for eventual popup windows $window.$mailboxController = vm; @@ -41,6 +42,10 @@ angular.element($window).on('beforeunload', _compactBeforeUnload); $scope.$on('$destroy', function() { angular.element($window).off('beforeunload', _compactBeforeUnload); + // Deregister hotkeys + _.forEach(hotkeys, function(key) { + sgHotkeys.deregisterHotkey(key); + }); }); // Update window's title with unseen messages count of selected mailbox @@ -52,6 +57,49 @@ $window.document.title = title; }); + _registerHotkeys(hotkeys); + + + function _registerHotkeys(keys) { + keys.push(sgHotkeys.createHotkey({ + key: 'c', + callback: newMessage + })); + keys.push(sgHotkeys.createHotkey({ + key: 'space', + callback: toggleMessageSelection + })); + keys.push(sgHotkeys.createHotkey({ + key: 'up', + callback: _nextMessage, + preventInClass: ['sg-mail-part'] + })); + keys.push(sgHotkeys.createHotkey({ + key: 'down', + callback: _previousMessage, + preventInClass: ['sg-mail-part'] + })); + keys.push(sgHotkeys.createHotkey({ + key: 'shift+up', + callback: _addNextMessageToSelection, + preventInClass: ['sg-mail-part'] + })); + keys.push(sgHotkeys.createHotkey({ + key: 'shift+down', + callback: _addPreviousMessageToSelection, + preventInClass: ['sg-mail-part'] + })); + keys.push(sgHotkeys.createHotkey({ + key: 'backspace', + callback: confirmDeleteSelectedMessages + })); + + // Register the hotkeys + _.forEach(hotkeys, function(key) { + sgHotkeys.registerHotkey(key); + }); + } + function _compactBeforeUnload(event) { return vm.selectedFolder.$compact(); } @@ -106,6 +154,68 @@ } } + /** + * User has pressed up arrow key + */ + function _nextMessage($event) { + var index = vm.selectedFolder.$selectedMessageIndex(); + + if (angular.isDefined(index)) + index--; + else + // No message is selected, show oldest message + index = vm.selectedFolder.getLength() - 1; + + if (index > -1) + selectMessage(vm.selectedFolder.$messages[index]); + + $event.preventDefault(); + + return index; + } + + /** + * User has pressed the down arrow key + */ + function _previousMessage($event) { + var index = vm.selectedFolder.$selectedMessageIndex(); + + if (angular.isDefined(index)) + index++; + else + // No message is selected, show newest + index = 0; + + if (index < vm.selectedFolder.getLength()) + selectMessage(vm.selectedFolder.$messages[index]); + else + index = -1; + + $event.preventDefault(); + + return index; + } + + function _addNextMessageToSelection($event) { + var index; + + if (vm.selectedFolder.hasSelectedMessage()) { + index = _nextMessage($event); + if (index >= 0) + toggleMessageSelection($event, vm.selectedFolder.$messages[index]); + } + } + + function _addPreviousMessageToSelection($event) { + var index; + + if (vm.selectedFolder.hasSelectedMessage()) { + index = _previousMessage($event); + if (index >= 0) + toggleMessageSelection($event, vm.selectedFolder.$messages[index]); + } + } + function selectMessage(message) { if (Mailbox.$virtualMode) $state.go('mail.account.virtualMailbox.message', {mailboxId: encodeUriFilter(message.$mailbox.path), messageId: message.uid}); @@ -114,6 +224,7 @@ } function toggleMessageSelection($event, message) { + if (!message) message = vm.selectedFolder.$selectedMessage(); message.selected = !message.selected; vm.mode.multiple += message.selected? 1 : -1; $event.preventDefault(); @@ -170,27 +281,30 @@ } } - function confirmDeleteSelectedMessages() { - Dialog.confirm(l('Warning'), - l('Are you sure you want to delete the selected messages?'), - { ok: l('Delete') }) + function confirmDeleteSelectedMessages($event) { + var selectedMessages = vm.selectedFolder.$selectedMessages(); + + if (_.size(selectedMessages) > 0) + Dialog.confirm(l('Warning'), + l('Are you sure you want to delete the selected messages?'), + { ok: l('Delete') }) .then(function() { var deleteSelectedMessage = vm.selectedFolder.hasSelectedMessage(); - var selectedMessages = vm.selectedFolder.$selectedMessages(); - if (_.size(selectedMessages) > 0) - vm.selectedFolder.$deleteMessages(selectedMessages).then(function(index) { - if (Mailbox.$virtualMode) { - // When performing an advanced search, we refresh the view if the selected message - // was deleted, but only once all promises have completed. - if (deleteSelectedMessage) - $state.go('mail.account.virtualMailbox'); - } - else { - // In normal mode, we immediately unselect the selected message. - _unselectMessage(deleteSelectedMessage, index); - } - }); + vm.selectedFolder.$deleteMessages(selectedMessages).then(function(index) { + if (Mailbox.$virtualMode) { + // When performing an advanced search, we refresh the view if the selected message + // was deleted, but only once all promises have completed. + if (deleteSelectedMessage) + $state.go('mail.account.virtualMailbox'); + } + else { + // In normal mode, we immediately unselect the selected message. + _unselectMessage(deleteSelectedMessage, index); + } + }); }); + + $event.preventDefault(); } function markOrUnMarkMessagesAsJunk() { diff --git a/UI/WebServerResources/js/Mailer/MailboxesController.js b/UI/WebServerResources/js/Mailer/MailboxesController.js index 077f5bd76..f8f685bfa 100644 --- a/UI/WebServerResources/js/Mailer/MailboxesController.js +++ b/UI/WebServerResources/js/Mailer/MailboxesController.js @@ -1,4 +1,4 @@ -/* -*- Mode: javascript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* -*- Mode: js; indent-tabs-mode: nil; js-indent-level: 2; -*- */ (function() { 'use strict'; @@ -366,7 +366,7 @@ } function refreshUnseenCount() { - var unseenCountFolders = window.unseenCountFolders; + var unseenCountFolders = $window.unseenCountFolders; _.forEach(vm.accounts, function(account) { diff --git a/UI/WebServerResources/js/Mailer/Message.service.js b/UI/WebServerResources/js/Mailer/Message.service.js index 907b8ba9f..314e1a45d 100644 --- a/UI/WebServerResources/js/Mailer/Message.service.js +++ b/UI/WebServerResources/js/Mailer/Message.service.js @@ -357,7 +357,9 @@ } } }; - _visit(this.parts); + + if (this.parts) + _visit(this.parts); return parts; }; diff --git a/UI/WebServerResources/js/Mailer/MessageController.js b/UI/WebServerResources/js/Mailer/MessageController.js index 2904cf394..f5602d7de 100644 --- a/UI/WebServerResources/js/Mailer/MessageController.js +++ b/UI/WebServerResources/js/Mailer/MessageController.js @@ -6,9 +6,9 @@ /** * @ngInject */ - MessageController.$inject = ['$window', '$scope', '$state', '$mdMedia', '$mdDialog', 'sgConstant', 'stateAccounts', 'stateAccount', 'stateMailbox', 'stateMessage', 'encodeUriFilter', 'sgSettings', 'sgFocus', 'Dialog', 'Calendar', 'Component', 'Account', 'Mailbox', 'Message']; - function MessageController($window, $scope, $state, $mdMedia, $mdDialog, sgConstant, stateAccounts, stateAccount, stateMailbox, stateMessage, encodeUriFilter, sgSettings, focus, Dialog, Calendar, Component, Account, Mailbox, Message) { - var vm = this, messageDialog = null, popupWindow = null; + MessageController.$inject = ['$window', '$scope', '$state', '$mdMedia', '$mdDialog', 'sgConstant', 'stateAccounts', 'stateAccount', 'stateMailbox', 'stateMessage', 'sgHotkeys', 'encodeUriFilter', 'sgSettings', 'sgFocus', 'Dialog', 'Calendar', 'Component', 'Account', 'Mailbox', 'Message']; + function MessageController($window, $scope, $state, $mdMedia, $mdDialog, sgConstant, stateAccounts, stateAccount, stateMailbox, stateMessage, sgHotkeys, encodeUriFilter, sgSettings, focus, Dialog, Calendar, Component, Account, Mailbox, Message) { + var vm = this, messageDialog = null, popupWindow = null, hotkeys = []; // Expose controller $window.$messageController = vm; @@ -93,6 +93,32 @@ }); } + $scope.$on('$destroy', function() { + // Deregister hotkeys + _.forEach(hotkeys, function(key) { + sgHotkeys.deregisterHotkey(key); + }); + }); + + _registerHotkeys(hotkeys); + + + function _registerHotkeys(keys) { + keys.push(sgHotkeys.createHotkey({ + key: 'backspace', + callback: function($event) { + if (vm.mailbox.$selectedCount() === 0) + deleteMessage(); + $event.preventDefault(); + } + })); + + // Register the hotkeys + _.forEach(hotkeys, function(key) { + sgHotkeys.registerHotkey(key); + }); + } + /** * If this is a popup window, retrieve the matching controllers (mailbox and message) of the parent window. */