UNPKG

@oat-sa/tao-core-sdk

Version:
801 lines (716 loc) 23.5 kB
/** * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; under version 2 * of the License (non-upgradable). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2016-2019 (original work) Open Assessment Technologies SA ; */ /** * Helper allowing to register shortcuts on the whole page. * * You may register keyboard and mouse shortcuts, like: * * ``` * Ctrl+C * Shift+leftMouseClick * ``` * * **Known limitations:** * Due to browser implementation, some shortcuts may not work. * For instance on a french keyboard layout, the shortcut "Shift+;" wont work as the browser * will return the result of the uppercase key that is "Shift+." in this case. * For alphanumeric keys the issue is prevented (this is the more needed feature). * * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com> */ import $ from 'jquery'; import _ from 'lodash'; import namespaceHelper from 'util/namespace'; /** * All shortcuts have a namespace, this one is the default */ const defaultNs = '*'; /** * Translation map from name of modifiers to event property * @type {Object} */ const modifiers = { ctrl: 'ctrlKey', alt: 'altKey', option: 'altKey', shift: 'shiftKey', meta: 'metaKey', cmd: 'metaKey', win: 'metaKey' }; /** * Translation map from normalized name of keys * @type {Object} */ const translateKeys = { escape: 'esc', arrowdown: 'down', arrowleft: 'left', arrowright: 'right', arrowup: 'up' }; /** * List of special keys with their codes * @type {Object} */ const specialKeys = { 8: 'backspace', 9: 'tab', 13: 'enter', 19: 'pause', 20: 'capslock', 27: 'esc', 32: 'space', 33: 'pageup', 34: 'pagedown', 35: 'end', 36: 'home', 37: 'left', 38: 'up', 39: 'right', 40: 'down', 45: 'insert', 46: 'delete', 91: 'meta', 112: 'f1', 113: 'f2', 114: 'f3', 115: 'f4', 116: 'f5', 117: 'f6', 118: 'f7', 119: 'f8', 120: 'f9', 121: 'f10', 122: 'f11', 123: 'f12', 145: 'scrolllock', 144: 'numlock' }; /** * Registers an event handler on a particular element * @param {Element|Window} target * @param {String} eventName * @param {Function} listener */ function registerEvent(target, eventName, listener) { if (target.addEventListener) { target.addEventListener(eventName, listener, false); } else if (target.attachEvent) { target.attachEvent(`on${eventName}`, listener); } else { target[`on${eventName}`] = listener; } } /** * Removes an event handler from a particular element * @param {Element|Window} target * @param {String} eventName * @param {Function} listener */ function unregisterEvent(target, eventName, listener) { if (target.removeEventListener) { target.removeEventListener(eventName, listener, false); } else if (target.detachEvent) { target.detachEvent(`on${eventName}`, listener); } else { target[`on${eventName}`] = null; } } /** * Gets the actual input key * @param {KeyboardEvent} event * @returns {String} */ function getActualKey(event) { // Get the code of the key, used to identify special keys on browser that does not support the full KeyboardEvent API const code = event.which || event.keyCode; const character = code >= 32 ? String.fromCharCode(code).toLowerCase() : ''; // Get the name of the key on browser that have a good support of the KeyboardEvent API let key = event.key && event.key.toLowerCase(); // If the browser supports the KeyboardEvent API it may provide the result of the shortcut instead of the actual key. // For instance on Mac if you input "Alt+V" the key property will contain "◊" const keyName = event.code && event.code.toLowerCase(); if (keyName) { if (keyName.indexOf('key') === 0) { // fix the result key only if the actual key name is not alpha (diff due to local layout) if (key < 'a' || key > 'z') { if (character >= 'a' && character <= 'z') { key = character; } } } else if (keyName.indexOf('digit') === 0) { key = keyName.substr(5); } } //return special key map first, if not fallback to one of the other key identification methods return specialKeys[code] || key || character; } /** * Gets the pressed buttons * @param {MouseEvent} event * @returns {Object} */ function getActualButton(event) { const buttons = { clickLeft: false, clickRight: false, clickMiddle: false, clickBack: false, clickForward: false }; if (event.buttons) { buttons.clickLeft = !!(event.buttons & 1); buttons.clickRight = !!(event.buttons & 2); buttons.clickMiddle = !!(event.buttons & 4); buttons.clickBack = !!(event.buttons & 8); buttons.clickForward = !!(event.buttons & 16); } else { switch (event.button) { case 0: buttons.clickLeft = true; break; case 1: buttons.clickMiddle = true; break; case 2: buttons.clickRight = true; break; case 3: buttons.clickBack = true; break; case 4: buttons.clickForward = true; break; } } return buttons; } /** * Gets the scroll direction * @param {WheelEvent} event * @returns {Object} */ function getActualScroll(event) { return { scrollUp: event.deltaY < 0, scrollDown: event.deltaY > 0 }; } /** * Gets a normalized shortcut command from a shortcut descriptor * @param {Object} descriptor * @returns {String} */ function normalizeCommand(descriptor) { const key = translateKeys[descriptor.key] || descriptor.key; const parts = []; if (descriptor.ctrlKey) { parts.push('control'); } if (descriptor.altKey) { parts.push('alt'); } if (descriptor.shiftKey) { parts.push('shift'); } if (descriptor.metaKey) { parts.push('meta'); } if (descriptor.scrollDown) { parts.push('scrollDown'); } if (descriptor.scrollUp) { parts.push('scrollUp'); } if (descriptor.clickLeft) { parts.push('clickLeft'); } if (descriptor.clickRight) { parts.push('clickRight'); } if (descriptor.clickMiddle) { parts.push('clickMiddle'); } if (descriptor.clickBack) { parts.push('clickBack'); } if (descriptor.clickForward) { parts.push('clickForward'); } if (key && parts.indexOf(key) < 0) { parts.push(key); } return parts.join('+'); } /** * Parses a shortcut command and return a descriptor * @param {String} shortcut * @returns {Object} */ function parseCommand(shortcut) { const parts = namespaceHelper.getName(shortcut).split('+'); const descriptor = { keyboardInvolved: false, mouseClickInvolved: false, mouseWheelInvolved: false, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, key: null, scrollUp: null, scrollDown: null, clickLeft: null, clickRight: null, clickMiddle: null, clickBack: null, clickForward: null }; _.forEach(parts, function (part) { if (modifiers[part]) { descriptor[modifiers[part]] = true; } else if (part.indexOf('mouse') >= 0) { if (descriptor.keyboardInvolved) { throw new Error('A shortcut cannot involve both mouse and regular keys!'); } if (part.indexOf('scroll') >= 0) { descriptor.mouseWheelInvolved = true; descriptor.scrollUp = part.indexOf('up') >= 0; descriptor.scrollDown = part.indexOf('down') >= 0; } if (part.indexOf('click') >= 0) { descriptor.mouseClickInvolved = true; descriptor.clickLeft = part.indexOf('left') >= 0; descriptor.clickRight = part.indexOf('right') >= 0; descriptor.clickMiddle = part.indexOf('middle') >= 0; descriptor.clickBack = part.indexOf('back') >= 0; descriptor.clickForward = part.indexOf('forward') >= 0; } } else { if (descriptor.mouseClickInvolved || descriptor.mouseWheelInvolved) { throw new Error('A shortcut cannot involve both mouse and regular keys!'); } descriptor.keyboardInvolved = true; descriptor.key = part; } }); return descriptor; } /** * Builds shortcuts registry that manages shortcuts attached to a DOM element * * @param {Element|Window} root - The root element from which listen to events * @param {Object} [defaultOptions] - Default options applied to each shortcut * @param {Boolean} [defaultOptions.propagate] - Allow the event to be propagated after caught * @param {Boolean} [defaultOptions.prevent] - Prevent the default behavior of the shortcut * @param {Boolean} [defaultOptions.avoidInput] - Prevent the shortcut to be caught inside an input field * @param {Boolean} [defaultOptions.allowIn] - Always allows the shortcut if the event source is in the scope of * the provided CSS class, even if the shortcut is triggered from an input field. * @returns {shortcut} */ export default function shortcutFactory(root, defaultOptions) { let keyboardIsRegistered = false; let mouseClickIsRegistered = false; let mouseWheelIsRegistered = false; let keyboardCount = 0; let mouseClickCount = 0; let mouseWheelCount = 0; let shortcuts = {}; let handlers = {}; const states = {}; /** * Gets the handlers for a shortcut * @param {String} command - the shortcut command * @param {String} namespace - the shortcut namespace * @returns {Function[]} the handlers */ function getHandlers(command, namespace) { handlers[namespace] = handlers[namespace] || {}; handlers[namespace][command] = handlers[namespace][command] || []; return handlers[namespace][command]; } /** * Gets all the handlers related to a particular command, not regarding the namespace * @param {String} command - the shortcut command * @returns {Function[]} the handlers */ function getCommandHandlers(command) { return _.reduce( handlers, function (acc, nsHandlers) { if (nsHandlers[command]) { acc = acc.concat(nsHandlers[command]); } return acc; }, [] ); } /** * Clears the handles attached to a shortcut * @param {String} command - the shortcut command * @param {String} namespace - the shortcut namespace */ function clearHandlers(command, namespace) { if (namespace && !command) { handlers[namespace] = {}; } else { _.forEach(handlers, function (nsHandlers, ns) { if (nsHandlers[command] && (namespace === defaultNs || namespace === ns)) { nsHandlers[command] = []; } }); } } /** * Assign options to a shortcut * @param {Object} descriptor * @param {Object} options */ function setOptions(descriptor, options) { descriptor.options = _.defaults(_.merge(descriptor.options || {}, options), defaultOptions); } /** * Registers a listener for the keyboard shortcuts */ function registerKeyboard() { if (!keyboardIsRegistered) { registerEvent(root, 'keydown', onKeyboard); keyboardIsRegistered = true; } keyboardCount++; } /** * Removes the listener of the keyboard shortcuts */ function unregisterKeyboard() { keyboardCount--; if (keyboardCount <= 0) { keyboardCount = 0; if (keyboardIsRegistered) { unregisterEvent(root, 'keydown', onKeyboard); keyboardIsRegistered = false; } } } /** * Registers a listener for the mouse click shortcuts */ function registerMouseClick() { if (!mouseClickIsRegistered) { registerEvent(root, 'click', onMouseClick); mouseClickIsRegistered = true; } mouseClickCount++; } /** * Removes the listener of the mouse click shortcuts */ function unregisterMouseClick() { mouseClickCount--; if (mouseClickCount <= 0) { mouseClickCount = 0; if (mouseClickIsRegistered) { unregisterEvent(root, 'click', onMouseClick); mouseClickIsRegistered = false; } } } /** * Registers a listener for the mouse wheel shortcuts */ function registerMouseWheel() { if (!mouseWheelIsRegistered) { registerEvent(root, 'wheel', onMouseWheel); mouseWheelIsRegistered = true; } mouseWheelCount++; } /** * Removes the listener of the mouse wheel shortcuts */ function unregisterMouseWheel() { mouseWheelCount--; if (mouseWheelCount <= 0) { mouseWheelCount = 0; if (mouseWheelIsRegistered) { unregisterEvent(root, 'wheel', onMouseWheel); mouseWheelIsRegistered = false; } } } /** * Registers a command shortcut and activates the right event listener * @param {String} command * @param {Object} descriptor */ function registerCommand(command, descriptor) { shortcuts[command] = descriptor; if (descriptor.keyboardInvolved) { registerKeyboard(); } if (descriptor.mouseClickInvolved) { registerMouseClick(); } if (descriptor.mouseWheelInvolved) { registerMouseWheel(); } } /** * Unregisters a command shortcut and removes the related event listener if not used anymore * @param {String} command */ function unregisterCommand(command) { const descriptor = shortcuts[command]; shortcuts[command] = null; if (descriptor) { if (descriptor.keyboardInvolved) { unregisterKeyboard(); } if (descriptor.mouseClickInvolved) { unregisterMouseClick(); } if (descriptor.mouseWheelInvolved) { unregisterMouseWheel(); } } } /** * Reacts to a keyboard event * @param {KeyboardEvent} event */ function onKeyboard(event) { processShortcut(event, { keyboardInvolved: true, ctrlKey: event.ctrlKey, altKey: event.altKey, shiftKey: event.shiftKey, metaKey: event.metaKey, key: getActualKey(event) }); } /** * Reacts to a mouse click event * @param {MouseEvent} event */ function onMouseClick(event) { processShortcut( event, _.merge( { mouseClickInvolved: true, ctrlKey: event.ctrlKey, altKey: event.altKey, shiftKey: event.shiftKey, metaKey: event.metaKey }, getActualButton(event) ) ); } /** * Reacts to a mouse wheel event * @param {WheelEvent} event */ function onMouseWheel(event) { processShortcut( event, _.merge( { mouseClickInvolved: true, ctrlKey: event.ctrlKey, altKey: event.altKey, shiftKey: event.shiftKey, metaKey: event.metaKey }, getActualScroll(event) ) ); } /** * Process a shortcut based on its descriptor * @param {Event} event * @param {Object} descriptor */ function processShortcut(event, descriptor) { const command = normalizeCommand(descriptor); const shortcut = shortcuts[command]; if (shortcut && !states.disabled) { if (shortcut.options.avoidInput === true) { const $target = $(event.target); if ($target.closest('[type="text"],textarea').length) { if (!shortcut.options.allowIn || !$target.closest(shortcut.options.allowIn).length) { return; } } } if (shortcut.options.propagate === false) { event.stopPropagation(); } if (shortcut.options.prevent === true) { event.preventDefault(); } const shortcutHandlers = getCommandHandlers(command); if (shortcutHandlers) { _.forEach(shortcutHandlers, function (handler) { handler(event, command); }); } } } if (root.jquery) { root = root.get(0); } /** * Defines the registry that manages the shortcuts attached to the provided DOM root * @typedef {shortcut} */ return { /** * Sets options for a particular shortcut. * If the shortcut does not already exists, create it * @param {String} shortcut * @param {Object} [options] * @param {Boolean} [options.propagate] - Allow the event to be propagated after caught * @param {Boolean} [options.prevent] - Prevent the default behavior of the shortcut * @param {Boolean} [options.avoidInput] - Prevent the shortcut to be caught inside an input field * @param {Boolean} [options.allowIn] - Always allows the shortcut if the event source is in the scope of * the provided CSS class, even if the shortcut is triggered from an input field. * @returns {shortcut} this */ set(shortcut, options) { _.forEach(namespaceHelper.split(shortcut, true), function (normalized) { const descriptor = parseCommand(normalized); const command = normalizeCommand(descriptor); setOptions(descriptor, options); registerCommand(command, descriptor); }); return this; }, /** * Registers a new shortcut * @param {String} shortcut * @param {Function} handler * @param {Object} [options] * @param {Boolean} [options.propagate] - Allow the event to be propagated after caught * @param {Boolean} [options.prevent] - Prevent the default behavior of the shortcut * @param {Boolean} [options.avoidInput] - Prevent the shortcut to be caught inside an input field * @param {Boolean} [options.allowIn] - Always allows the shortcut if the event source is in the scope of * the provided CSS class, even if the shortcut is triggered from an input field. * @returns {shortcut} this */ add(shortcut, handler, options) { if (_.isFunction(handler)) { _.forEach(namespaceHelper.split(shortcut, true), function (normalized) { const namespace = namespaceHelper.getNamespace(normalized, defaultNs); const descriptor = parseCommand(normalized); const command = normalizeCommand(descriptor); setOptions(descriptor, options); registerCommand(command, descriptor); getHandlers(command, namespace).push(handler); }); } return this; }, /** * Removes a shortcut * @param {String} shortcut * @returns {shortcut} this */ remove(shortcut) { _.forEach(namespaceHelper.split(shortcut, true), function (normalized) { const namespace = namespaceHelper.getNamespace(normalized, defaultNs); const descriptor = parseCommand(normalized); const command = normalizeCommand(descriptor); clearHandlers(command, namespace); if (!getCommandHandlers(command).length) { unregisterCommand(command); } }); return this; }, /** * Checks if a particular shortcut is already registered * @param {String} shortcut * @returns {Boolean} */ exists(shortcut) { const normalized = String(shortcut).trim().toLowerCase(); const namespace = namespaceHelper.getNamespace(normalized, defaultNs); const descriptor = parseCommand(normalized); const command = normalizeCommand(descriptor); let shortcutExists = false; if (shortcuts[command]) { shortcutExists = namespace === defaultNs || !!getHandlers(command, namespace).length; } else if (!command) { shortcutExists = !_.isEmpty(handlers[namespace]); } return shortcutExists; }, /** * Removes all registered shortcuts * @returns {shortcut} this */ clear() { shortcuts = {}; handlers = {}; keyboardCount = 0; mouseClickCount = 0; mouseWheelCount = 0; unregisterKeyboard(); unregisterMouseClick(); unregisterMouseWheel(); return this; }, /** * Checks a particular state * @param {String} name * @returns {Boolean} */ getState(name) { return !!states[name]; }, /** * Sets a particular state * @param {String} name * @param {Boolean} state * @returns {shortcut} */ setState(name, state) { states[name] = !!state; return this; }, /** * Enables the shortcuts to be listened * @returns {shortcut} */ enable() { this.setState('disabled', false); return this; }, /** * Prevents the shortcuts to be listened * @returns {shortcut} */ disable() { this.setState('disabled', true); return this; } }; }