(js) Initial support for keyboard shortcuts

pull/222/head
Francis Lachapelle 2016-09-23 17:17:25 -04:00
parent 93de7b9ab3
commit a2e3807a3a
5 changed files with 545 additions and 27 deletions

View File

@ -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.<HotKey>} 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);
})();

View File

@ -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() {

View File

@ -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) {

View File

@ -357,7 +357,9 @@
}
}
};
_visit(this.parts);
if (this.parts)
_visit(this.parts);
return parts;
};

View File

@ -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.
*/