diff --git a/lib/features/keyboard/DiagramKeyBindings.js b/lib/features/keyboard/DiagramKeyBindings.js new file mode 100644 index 000000000..49e00d20e --- /dev/null +++ b/lib/features/keyboard/DiagramKeyBindings.js @@ -0,0 +1,134 @@ +import { + isCmd, + isShift, + MoveCanvasFactory +} from './helpers'; + +var zKeys = [ 'Z', 'z' ]; + +var copyKeys = [ 'C', 'c' ]; +var pasteKeys = [ 'V', 'v' ]; + +var undoKeys = [ 'Z', 'z' ]; +var redoKeys = zKeys.concat('Y', 'y'); + +var removeSelectionKeys = [ 'Delete' ]; + +var zoomDefaultKeys = [ '0' ]; +var zoomInKeys = [ '+', 'Add', '=' ]; +var zoomOutKeys = [ '-', 'Subtract' ]; + +var moveCanvasLeftKeys = [ 'ArrowLeft', 'Left' ]; +var moveCanvasUpKeys = [ 'ArrowUp', 'Up' ]; +var moveCanvasRightKeys = [ 'ArrowRight', 'Right' ]; +var moveCanvasDownKeys = [ 'ArrowDown', 'Down' ]; + +var DEFAULT_PRIORITY = 1000; + +/** + * Adds default KeyboardEvent listeners + * + * @param {Keyboard} keyboard + * @param {EditorActions} editorActions + */ +export default function DiagramKeyBindings(keyboard, editorActions) { + + var moveCanvasLeft = MoveCanvasFactory('left', keyboard._config, editorActions); + var moveCanvasUp = MoveCanvasFactory('up', keyboard._config, editorActions); + var moveCanvasRight = MoveCanvasFactory('right', keyboard._config, editorActions); + var moveCanvasDown = MoveCanvasFactory('down', keyboard._config, editorActions); + + keyboard.addListener(copyKeys, DEFAULT_PRIORITY, copy); + keyboard.addListener(pasteKeys, DEFAULT_PRIORITY, paste); + + keyboard.addListener(undoKeys, DEFAULT_PRIORITY, undo); + keyboard.addListener(redoKeys, DEFAULT_PRIORITY, redo); + + keyboard.addListener(removeSelectionKeys, DEFAULT_PRIORITY, removeSelection); + + keyboard.addListener(zoomDefaultKeys, DEFAULT_PRIORITY, zoomDefault); + keyboard.addListener(zoomInKeys, DEFAULT_PRIORITY, zoomIn); + keyboard.addListener(zoomOutKeys, DEFAULT_PRIORITY, zoomOut); + + keyboard.addListener(moveCanvasLeftKeys, DEFAULT_PRIORITY, moveCanvasLeft); + keyboard.addListener(moveCanvasUpKeys, DEFAULT_PRIORITY, moveCanvasUp); + keyboard.addListener(moveCanvasRightKeys, DEFAULT_PRIORITY, moveCanvasRight); + keyboard.addListener(moveCanvasDownKeys, DEFAULT_PRIORITY, moveCanvasDown); + + function copy(context) { + if (isCmd(context.event)) { + editorActions.trigger('copy'); + + return true; + } + } + + function paste(context) { + + if (isCmd(context.event)) { + editorActions.trigger('paste'); + + return true; + } + } + + function redo(context) { + var key = context.key; + + if (isCmd(context.event) && (!zKeys.indexOf(key) > -1 || isShift(context.event))) { + editorActions.trigger('redo'); + + return true; + } + } + + function removeSelection(context) { + + if (isCmd(context.event)) { + editorActions.trigger('removeSelection'); + + return true; + } + } + + function undo(context) { + + if (isCmd(context.event) && !isShift(context.event)) { + editorActions.trigger('undo'); + + return true; + } + } + + function zoomDefault(context) { + + if (isCmd(context.event)) { + editorActions.trigger('zoom', { value: 1 }); + + return true; + } + } + + function zoomIn(context) { + + if (isCmd(context.event)) { + editorActions.trigger('stepZoom', { value: 1 }); + + return true; + } + } + + function zoomOut(context) { + + if (isCmd(context.event)) { + editorActions.trigger('stepZoom', { value: -1 }); + + return true; + } + } +} + +DiagramKeyBindings.$inject = [ + 'keyboard', + 'editorActions' +]; \ No newline at end of file diff --git a/lib/features/keyboard/Keyboard.js b/lib/features/keyboard/Keyboard.js index ac50bf4a8..a3f769ed9 100644 --- a/lib/features/keyboard/Keyboard.js +++ b/lib/features/keyboard/Keyboard.js @@ -1,14 +1,40 @@ +import { + assign, + forEach, + isArray, + isFunction, + map +} from 'min-dash'; import { event as domEvent, matches as domMatches } from 'min-dom'; +import { + hasModifier, + isCmd, + isShift +} from './helpers'; + +var DEFAULT_SPEED = 50; /** * A keyboard abstraction that may be activated and * deactivated by users at will, consuming key events * and triggering diagram actions. * + * For pressed keys, keyboard fires an event according to following scheme: + * `keyboard.keydown.[event.key || event.keyCode]` + * + * Event context contains: + * - `` key - `event.key` or `event.keyCode` if `event.key` can't be determined + * - `` event + * + * If no specific key(s) listeners handle the event, the global keyboard event listeners + * will be triggered with following arguments: + * - `` keyCode + * - `` event + * * The implementation fires the following key events that allow * other components to hook into key handling: * @@ -22,50 +48,39 @@ import { * A default binding for the keyboard may be specified via the * `keyboard.bindTo` configuration option. * - * @param {Config} config + * @param {Object} [config] + * @param {boolean} [config.invertY] + * @param {Number} [config.speed] + * @param {EventTarget} [config.bindTo] * @param {EventBus} eventBus * @param {EditorActions} editorActions */ export default function Keyboard(config, eventBus, editorActions) { var self = this; - this._config = config || {}; + this._config = assign({ + speed: DEFAULT_SPEED, + invertY: false + }, config || {}); this._eventBus = eventBus; this._editorActions = editorActions; this._listeners = []; - - // our key handler is a singleton that passes - // (keycode, modifiers) to each listener. - // - // listeners must indicate that they handled a key event - // by returning true. This stops the event propagation. - // - this._keyHandler = function(event) { - - var i, l, - target = event.target, - listeners = self._listeners, - code = event.keyCode || event.charCode || -1; - - if (target && (domMatches(target, 'input, textarea') || target.contentEditable === 'true')) { - return; - } - - for (i = 0; (l = listeners[i]); i++) { - if (l(code, event)) { - event.preventDefault(); - event.stopPropagation(); - } - } - }; + this._globalListeners = []; + this._keyHandler = this._keyHandler.bind(this); // properly clean dom registrations eventBus.on('diagram.destroy', function() { self._fire('destroy'); self.unbind(); - self._listeners = null; + + forEach(self._listeners, function(listener) { + eventBus.off(listener.events, listener.callback); + }); + + self._listeners = []; + self._globalListeners = []; }); eventBus.on('diagram.init', function() { @@ -81,8 +96,6 @@ export default function Keyboard(config, eventBus, editorActions) { eventBus.on('detach', function() { self.unbind(); }); - - this._init(); } Keyboard.$inject = [ @@ -91,6 +104,43 @@ Keyboard.$inject = [ 'editorActions' ]; +Keyboard.prototype._keyHandler = function(event) { + + var target = event.target, + key = getKeyValue(event), + eventBusResult, + globalListenersResult; + + if (target && (domMatches(target, 'input, textarea') || target.contentEditable === 'true')) { + return; + } + + var eventType = getKeydownEventType(key); + + var context = { + event: event, + key: key + }; + + eventBusResult = this._eventBus.fire(eventType, context); + + // run global listeners only if no listeners for specific keys reacted + if (!eventBusResult) { + forEach(this._globalListeners, function(listener) { + globalListenersResult = listener(event.keyCode, event); + + if (globalListenersResult) { + return false; + } + }); + } + + if (eventBusResult || globalListenersResult) { + event.preventDefault(); + } + +}; + Keyboard.prototype.bind = function(node) { // make sure that the keyboard is only bound once to the DOM @@ -125,200 +175,61 @@ Keyboard.prototype._fire = function(event) { this._eventBus.fire('keyboard.' + event, { node: this._node, listeners: this._listeners }); }; -Keyboard.prototype._init = function() { - - var listeners = this._listeners; - - var editorActions = this._editorActions, - config = this._config; - - // init default listeners - - // undo - // (CTRL|CMD) + Z - function undo(key, modifiers) { - - if (isCmd(modifiers) && !isShift(modifiers) && key === 90) { - editorActions.trigger('undo'); - - return true; - } - } - - // redo - // CTRL + Y - // CMD + SHIFT + Z - function redo(key, modifiers) { - - if (isCmd(modifiers) && (key === 89 || (key === 90 && isShift(modifiers)))) { - editorActions.trigger('redo'); - - return true; - } - } - - // copy - // CTRL/CMD + C - function copy(key, modifiers) { - - if (isCmd(modifiers) && (key === 67)) { - editorActions.trigger('copy'); - return true; - } - } - - // paste - // CTRL/CMD + V - function paste(key, modifiers) { - - if (isCmd(modifiers) && (key === 86)) { - editorActions.trigger('paste'); - - return true; - } - } - - /** - * zoom in one step - * CTRL + + - * - * 107 = numpad plus - * 187 = regular plus - * 171 = regular plus in Firefox (german keyboard layout) - * 61 = regular plus in Firefox (US keyboard layout) - */ - function zoomIn(key, modifiers) { - - if ((key === 107 || key === 187 || key === 171 || key === 61) && isCmd(modifiers)) { - editorActions.trigger('stepZoom', { value: 1 }); - - return true; - } - } - - /** - * zoom out one step - * CTRL + - - * - * 109 = numpad minus - * 189 = regular minus - * 173 = regular minus in Firefox (US and german keyboard layout) - */ - function zoomOut(key, modifiers) { - - if ((key === 109 || key === 189 || key === 173) && isCmd(modifiers)) { - editorActions.trigger('stepZoom', { value: -1 }); - - return true; - } - } - - /** - * zoom to the default level - * CTRL + 0 - * - * 96 = numpad zero - * 48 = regular zero - */ - function zoomDefault(key, modifiers) { - - if ((key === 96 || key === 48) && isCmd(modifiers)) { - editorActions.trigger('zoom', { value: 1 }); - - return true; - } - } - - // delete selected element - // DEL - function removeSelection(key, modifiers) { - - if (key === 46) { - editorActions.trigger('removeSelection'); +/** + * Add a listener function which is notified whenever provided keys are pressed. + * You can specify keys and priority as well as pass a bare callback listening for all keys pressed. + * Return `true` to invoke `event.preventDefault` + * + * @method + * @param {String|Number|Array} keys + * @param {Number} priority + * @param {Function} callback + */ +Keyboard.prototype.addListener = function(keys, priority, callback) { + if (isFunction(keys)) { + this._globalListeners.push(keys); - return true; - } + return; } - // move canvas left - // left arrow - // - // 37 = Left - // 38 = Up - // 39 = Right - // 40 = Down - function moveCanvas(key, modifiers) { - - if ([37, 38, 39, 40].indexOf(key) >= 0) { - - var opts = { - invertY: config.invertY, - speed: (config.speed || 50) - }; - - switch (key) { - case 37: // Left - opts.direction = 'left'; - break; - case 38: // Up - opts.direction = 'up'; - break; - case 39: // Right - opts.direction = 'right'; - break; - case 40: // Down - opts.direction = 'down'; - break; - } + keys = isArray(keys) ? keys : [ keys ]; - editorActions.trigger('moveCanvas', opts); + var events = map(keys, function(key) { + return getKeydownEventType(key); + }); - return true; - } + if (isFunction(priority)) { + callback = priority; + this._eventBus.on(events, callback); + } else { + this._eventBus.on(events, priority, callback); } - listeners.push(undo); - listeners.push(redo); - listeners.push(copy); - listeners.push(paste); - listeners.push(removeSelection); - listeners.push(zoomIn); - listeners.push(zoomOut); - listeners.push(zoomDefault); - listeners.push(moveCanvas); + this._listeners.push({ events: events, callback: callback }); }; -/** - * Add a listener function that is notified with (key, modifiers) whenever - * the keyboard is bound and the user presses a key. - * - * @param {Function} listenerFn - */ -Keyboard.prototype.addListener = function(listenerFn) { - this._listeners.push(listenerFn); -}; - Keyboard.prototype.hasModifier = hasModifier; -Keyboard.prototype.isCmd = isCmd; -Keyboard.prototype.isShift = isShift; +Keyboard.prototype.isCmd = isCmd; -function hasModifier(modifiers) { - return (modifiers.ctrlKey || modifiers.metaKey || modifiers.shiftKey || modifiers.altKey); -} +Keyboard.prototype.isShift = isShift; -function isCmd(modifiers) { - // ensure we don't react to AltGr - // (mapped to CTRL + ALT) - if (modifiers.altKey) { - return false; - } +// helpers ////////////// - return modifiers.ctrlKey || modifiers.metaKey; +/** + * Extracts key value and maps keyCode if needed + * @param {KeyboardEvent} event + */ +function getKeyValue(event) { + return event.key || event.keyCode; } -function isShift(modifiers) { - return modifiers.shiftKey; -} +/** + * Maps key to eventType. + * @param {String} key + */ +function getKeydownEventType(key) { + return 'keyboard.keydown.' + key; +} \ No newline at end of file diff --git a/lib/features/keyboard/helpers/MoveCanvasFactory.js b/lib/features/keyboard/helpers/MoveCanvasFactory.js new file mode 100644 index 000000000..12a97a6aa --- /dev/null +++ b/lib/features/keyboard/helpers/MoveCanvasFactory.js @@ -0,0 +1,19 @@ +/** + * Returns function which moves canvas in provided direction + * @param {string} direction - direction in which canvas should move + */ +export function MoveCanvasFactory(direction, config, editorActions) { + return function() { + + var opts = { + invertY: config.invertY, + direction: direction + }; + + opts.speed = config.speed; + + editorActions.trigger('moveCanvas', opts); + + return true; + }; +} \ No newline at end of file diff --git a/lib/features/keyboard/helpers/hasModifier.js b/lib/features/keyboard/helpers/hasModifier.js new file mode 100644 index 000000000..def4a49ba --- /dev/null +++ b/lib/features/keyboard/helpers/hasModifier.js @@ -0,0 +1,7 @@ +/** + * Returns true if event was triggered with any modifier + * @param {KeyboardEvent} event + */ +export function hasModifier(event) { + return (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey); +} \ No newline at end of file diff --git a/lib/features/keyboard/helpers/index.js b/lib/features/keyboard/helpers/index.js new file mode 100644 index 000000000..3683b5cc2 --- /dev/null +++ b/lib/features/keyboard/helpers/index.js @@ -0,0 +1,4 @@ +export { isCmd } from './isCmd'; +export { isShift } from './isShift'; +export { hasModifier } from './hasModifier'; +export { MoveCanvasFactory } from './MoveCanvasFactory'; \ No newline at end of file diff --git a/lib/features/keyboard/helpers/isCmd.js b/lib/features/keyboard/helpers/isCmd.js new file mode 100644 index 000000000..140b372df --- /dev/null +++ b/lib/features/keyboard/helpers/isCmd.js @@ -0,0 +1,12 @@ +/** + * @param {KeyboardEvent} event + */ +export function isCmd(event) { + // ensure we don't react to AltGr + // (mapped to CTRL + ALT) + if (event.altKey) { + return false; + } + + return event.ctrlKey || event.metaKey; +} \ No newline at end of file diff --git a/lib/features/keyboard/helpers/isShift.js b/lib/features/keyboard/helpers/isShift.js new file mode 100644 index 000000000..f25222f64 --- /dev/null +++ b/lib/features/keyboard/helpers/isShift.js @@ -0,0 +1,6 @@ +/** + * @param {KeyboardEvent} event + */ +export function isShift(event) { + return event.shiftKey; +} \ No newline at end of file diff --git a/lib/features/keyboard/index.js b/lib/features/keyboard/index.js index b75df2c73..46d5134e1 100644 --- a/lib/features/keyboard/index.js +++ b/lib/features/keyboard/index.js @@ -1,6 +1,8 @@ import Keyboard from './Keyboard'; +import DiagramKeyBindings from './DiagramKeyBindings'; export default { - __init__: [ 'keyboard' ], - keyboard: [ 'type', Keyboard ] + __init__: [ 'keyboard', 'diagramKeyBindings' ], + keyboard: [ 'type', Keyboard ], + diagramKeyBindings: [ 'type', DiagramKeyBindings ] }; diff --git a/test/spec/features/keyboard/CopySpec.js b/test/spec/features/keyboard/CopySpec.js new file mode 100644 index 000000000..363f24a58 --- /dev/null +++ b/test/spec/features/keyboard/CopySpec.js @@ -0,0 +1,70 @@ +/* global sinon */ + +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = [ 'c', 'C' ]; + +describe('features/keyboard - copy', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'should call copy', keys: KEYS, ctrlKey: true, called: true }, + { desc: 'should not call copy', keys: KEYS, ctrlKey: false, called: false }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it(testCase.desc, inject(function(keyboard, editorActions) { + + // given + var copySpy = sinon.spy(editorActions, 'trigger'); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(copySpy.calledWith('copy')).to.be.equal(testCase.called); + + })); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/spec/features/keyboard/KeyboardSpec.js b/test/spec/features/keyboard/KeyboardSpec.js index 3e6c4ed9e..8170f919f 100755 --- a/test/spec/features/keyboard/KeyboardSpec.js +++ b/test/spec/features/keyboard/KeyboardSpec.js @@ -1,21 +1,25 @@ -import { - bootstrapDiagram, - DomMocking, - inject -} from 'test/TestHelper'; +/* global sinon */ import TestContainer from 'mocha-test-container-support'; +import { + assign +} from 'min-dash'; import modelingModule from 'lib/features/modeling'; import editorActionsModule from 'lib/features/editor-actions'; import keyboardModule from 'lib/features/keyboard'; -import { createKeyEvent } from '../../../util/KeyEvents'; +import { + bootstrapDiagram, + DomMocking, + inject +} from 'test/TestHelper'; +import { createKeyEvent } from 'test/util/KeyEvents'; describe('features/keyboard', function() { - beforeEach(bootstrapDiagram({ + var defaultDiagramConfig = { modules: [ modelingModule, keyboardModule, @@ -23,13 +27,10 @@ describe('features/keyboard', function() { ], canvas: { deferUpdate: false - }, - keyboard: { - speed: 5, - invertY: false, - bindTo: document } - })); + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); it('should bootstrap diagram with keyboard', inject(function(keyboard) { @@ -39,6 +40,14 @@ describe('features/keyboard', function() { describe('keyboard binding', function() { + var keyboardConfig = { + keyboard: { + bindTo: document + } + }; + + beforeEach(bootstrapDiagram(assign(defaultDiagramConfig, keyboardConfig))); + it('should integrate with + events', inject( function(keyboard, eventBus) { @@ -58,7 +67,7 @@ describe('features/keyboard', function() { }); - describe('listener handling', function() { + describe('keydown event listener handling', function() { beforeEach(function() { DomMocking.install(); @@ -75,6 +84,10 @@ describe('features/keyboard', function() { testContainer.appendChild(testDiv); }); + afterEach(function() { + DomMocking.uninstall(); + }); + it('should bind keyboard events to node', inject(function(keyboard) { // Actually three listeners are set @@ -108,264 +121,107 @@ describe('features/keyboard', function() { expect(binding).to.equal(testDiv); })); + it('should not fire event if target is input field', inject(function(keyboard, eventBus) { - afterEach(function() { - DomMocking.uninstall(); - }); - - }); + // given + var testKey = 'TestKey'; + var eventBusSpy = sinon.spy(eventBus, 'fire'); + var inputField = document.createElement('input'); + testDiv.appendChild(inputField); - describe('default listeners', function() { - - var container; - - beforeEach(function() { - container = TestContainer.get(this); - }); - - describe('zoom in', function() { - - it('should handle numpad plus', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 107, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.above(1); - })); - - - it('should handle regular plus', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 187, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.above(1); - })); - - - it('should handle regular plus in Firefox (US layout)', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 61, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.above(1); - })); - - - it('should handle regular plus in Firefox (german layout)', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 171, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.above(1); - })); - - }); + var event = createKeyEvent(testKey, {}, inputField); + // when + keyboard._keyHandler(event); - describe('zoom out', function() { + // then + expect(eventBusSpy).to.not.be.called; - it('should handle numpad minus', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 109, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.below(1); - })); - - - it('should handle regular minus', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 189, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.below(1); - })); - - - it('should handle regular minus in Firefox', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 173, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.be.below(1); - })); - - }); - - - describe('default zoom level', function() { - - it('should handle numpad zero', inject(function(canvas, keyboard) { - - // given - canvas.zoom(2.345); - var e = createKeyEvent(container, 96, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.equal(1); - })); - - - it('should handle regular zero', inject(function(canvas, keyboard) { - - // given - canvas.zoom(2.345); - var e = createKeyEvent(container, 48, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.zoom()).to.equal(1); - })); - - }); - - - describe('arrow keys', function() { - - it('should handle left arrow', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 37, true); - - // when - keyboard._keyHandler(e); - - // then - expect(canvas.viewbox().x).to.eql(-5); - expect(canvas.viewbox().y).to.eql(0); - })); - - - it('should handle right arrow', inject(function(canvas, keyboard) { - - // given - var e = createKeyEvent(container, 39, true); - - // when - keyboard._keyHandler(e); + })); - // then - expect(canvas.viewbox().x).to.eql(5); - expect(canvas.viewbox().y).to.eql(0); - })); + }); + describe('add listener', function() { - it('should handle up arrow', inject(function(canvas, keyboard) { + it('should handle listeners by priority', inject(function(keyboard) { - // given - var e = createKeyEvent(container, 38, true); + // given + var testKey = 'TestKey'; + var lowerPrioritySpy = sinon.spy(); + var higherPrioritySpy = sinon.stub().returns(true); - // when - keyboard._keyHandler(e); + keyboard.addListener(testKey, 500, lowerPrioritySpy); + keyboard.addListener(testKey, 1000, higherPrioritySpy); - // then - expect(canvas.viewbox().x).to.eql(0); - expect(canvas.viewbox().y).to.eql(-5); - })); + var event = createKeyEvent(testKey); + // when + keyboard._keyHandler(event); - it('should handle down arrow', inject(function(canvas, keyboard) { + // then + expect(higherPrioritySpy).to.be.called; + expect(lowerPrioritySpy).to.not.be.called; - // given - var e = createKeyEvent(container, 40, true); + })); - // when - keyboard._keyHandler(e); + it('should invoke listener of lower priority if key was not handled', inject(function(keyboard) { - // then - expect(canvas.viewbox().x).to.eql(0); - expect(canvas.viewbox().y).to.eql(5); - })); + // given + var testKey = 'TestKey'; + var lowerPrioritySpy = sinon.spy(); + var higherPrioritySpy = sinon.spy(); + keyboard.addListener(testKey, 500, lowerPrioritySpy); + keyboard.addListener(testKey, 1000, higherPrioritySpy); - describe('configurability', function() { + var event = createKeyEvent(testKey); - it('should configure speed', - inject(function(canvas, keyboard, injector) { + // when + keyboard._keyHandler(event); - // given - var keyboardConfig = injector.get('config.keyboard'); + // then + expect(higherPrioritySpy).to.be.called; + expect(lowerPrioritySpy).to.be.called; - var keyDownEvent = createKeyEvent(container, 38, true); + })); - // when - keyboardConfig.speed = 23; // plenty of fuel needed - keyboard._keyHandler(keyDownEvent); + it('should allow to add event listener by passing bare function', inject(function(keyboard) { - // then - expect(canvas.viewbox().x).to.eql(0); - expect(canvas.viewbox().y).to.eql(-23); - }) - ); + // given + var testKey = 'TestKey'; + var keyboardEventSpy = sinon.spy(); + keyboard.addListener(keyboardEventSpy); - it('should configure natural scrolling', - inject(function(canvas, keyboard, injector) { + var event = createKeyEvent(testKey); - // given - var keyboardConfig = injector.get('config.keyboard'); + // when + keyboard._keyHandler(event); - var keyDownEvent = createKeyEvent(container, 38, true), - keyUpEvent = createKeyEvent(container, 40, true); + // then + expect(keyboardEventSpy).to.be.called; - // when - keyboardConfig.invertY = true; - keyboard._keyHandler(keyDownEvent); + })); - // then - expect(canvas.viewbox().x).to.eql(0); - expect(canvas.viewbox().y).to.eql(5); + it('should prevent default if listener returned true', inject(function(keyboard) { + // given + var testKey = 'TestKey'; + var keyboardEventStub = sinon.stub().returns(true); - // but does up work, too? + keyboard.addListener(keyboardEventStub); - // when - keyboard._keyHandler(keyUpEvent); + var event = createKeyEvent(testKey); - // then - expect(canvas.viewbox().x).to.eql(0); - expect(canvas.viewbox().y).to.eql(0); - }) - ); + // when + keyboard._keyHandler(event); - }); + // then + expect(keyboardEventStub).to.be.called; + expect(event.defaultPrevented).to.be.true; - }); + })); }); diff --git a/test/spec/features/keyboard/MoveCanvasSpec.js b/test/spec/features/keyboard/MoveCanvasSpec.js new file mode 100644 index 000000000..869596142 --- /dev/null +++ b/test/spec/features/keyboard/MoveCanvasSpec.js @@ -0,0 +1,141 @@ +import { + bootstrapDiagram, + getDiagramJS, + inject +} from 'test/TestHelper'; + +import { + assign, + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = { + LEFT: [ 'ArrowLeft', 'Left' ], + UP: [ 'ArrowUp', 'Up' ], + RIGHT: [ 'ArrowRight', 'Right' ], + DOWN: [ 'ArrowDown', 'Down' ], +}; + + +describe('features/keyboard - move canvas', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + + describe('with default config', function() { + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'left arrow', keys: KEYS.LEFT, shiftKey: false, x: -50, y: 0 }, + { desc: 'right arrow', keys: KEYS.RIGHT, shiftKey: false, x: 50, y: 0 }, + { desc: 'up arrow', keys: KEYS.UP, shiftKey: false, x: 0, y: -50 }, + { desc: 'down arrow', keys: KEYS.DOWN, shiftKey: false, x: 0, y: 50 } + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it('should handle ' + testCase.desc, inject(function(canvas, keyboard) { + + // given + var event = createKeyEvent(key, { shiftKey: testCase.shiftKey }); + + // when + keyboard._keyHandler(event); + + // then + expect(canvas.viewbox().x).to.eql(testCase.x); + expect(canvas.viewbox().y).to.eql(testCase.y); + })); + + }); + + }); + + }); + + + describe('with custom config', function() { + + it('should use custom speed', function() { + + // given + var keyboardConfig = { + keyboard: { + speed: 23 + } + }; + + bootstrapDiagram(assign(defaultDiagramConfig, keyboardConfig))(); + + var keyDownEvent = createKeyEvent(KEYS.DOWN[0]); + + getDiagramJS().invoke(function(canvas, keyboard) { + + // when + keyboard._keyHandler(keyDownEvent); + + // then + expect(canvas.viewbox().x).to.eql(0); + expect(canvas.viewbox().y).to.eql(23); + + }); + }); + + + it('should use natural scrolling if enabled', function() { + + // given + var keyboardConfig = { + keyboard: { + invertY: true + } + }; + + bootstrapDiagram(assign(defaultDiagramConfig, keyboardConfig))(); + + var keyDownEvent = createKeyEvent(KEYS.DOWN[0]), + keyUpEvent = createKeyEvent(KEYS.UP[0]); + + getDiagramJS().invoke(function(canvas, keyboard) { + + // when + keyboard._keyHandler(keyDownEvent); + + // then + expect(canvas.viewbox().x).to.eql(0); + expect(canvas.viewbox().y).to.eql(-50); + + // when + keyboard._keyHandler(keyUpEvent); + + // then + expect(canvas.viewbox().x).to.eql(0); + expect(canvas.viewbox().y).to.eql(0); + + }); + + }); + + }); + +}); diff --git a/test/spec/features/keyboard/PasteSpec.js b/test/spec/features/keyboard/PasteSpec.js new file mode 100644 index 000000000..8f0ee43e3 --- /dev/null +++ b/test/spec/features/keyboard/PasteSpec.js @@ -0,0 +1,70 @@ +/* global sinon */ + +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = [ 'v', 'V' ]; + +describe('features/keyboard - paste', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'should call paste', keys: KEYS, ctrlKey: true, called: true }, + { desc: 'should not call paste', keys: KEYS, ctrlKey: false, called: false }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it(testCase.desc, inject(function(keyboard, editorActions) { + + // given + var pasteSpy = sinon.spy(editorActions, 'trigger'); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(pasteSpy.calledWith('paste')).to.be.equal(testCase.called); + + })); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/spec/features/keyboard/RedoSpec.js b/test/spec/features/keyboard/RedoSpec.js new file mode 100644 index 000000000..83fa89e77 --- /dev/null +++ b/test/spec/features/keyboard/RedoSpec.js @@ -0,0 +1,79 @@ +/* global sinon */ + +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = { + Z: [ 'z', 'Z' ], + Y: [ 'y', 'y' ], +}; + +describe('features/keyboard - redo', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'should call redo', keys: KEYS.Z, ctrlKey: true, shiftKey: true, called: true }, + { desc: 'should call redo', keys: KEYS.Y, ctrlKey: true, shiftKey: false, called: true }, + { desc: 'should call redo', keys: KEYS.Y, ctrlKey: true, shiftKey: true, called: true }, + { desc: 'should not call redo', keys: KEYS.Z, ctrlKey: false, shiftKey: true, called: false }, + { desc: 'should not call redo', keys: KEYS.Z, ctrlKey: true, shiftKey: false, called: false }, + { desc: 'should not call redo', keys: KEYS.Y, ctrlKey: false, shiftKey: false, called: false }, + { desc: 'should not call redo', keys: KEYS.Z, ctrlKey: false, shiftKey: false, called: false }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it(testCase.desc, inject(function(keyboard, editorActions) { + + // given + var redoSpy = sinon.spy(editorActions, 'trigger'); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey, + shiftKey: testCase.shiftKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(redoSpy.calledWith('redo')).to.be.equal(testCase.called); + + })); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/spec/features/keyboard/RemoveSelectionSpec.js b/test/spec/features/keyboard/RemoveSelectionSpec.js new file mode 100644 index 000000000..1d952acd2 --- /dev/null +++ b/test/spec/features/keyboard/RemoveSelectionSpec.js @@ -0,0 +1,70 @@ +/* global sinon */ + +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = [ 'Delete' ]; + +describe('features/keyboard - remove selection', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'should call remove selection', keys: KEYS, ctrlKey: true, called: true }, + { desc: 'should not call remove selection', keys: KEYS, ctrlKey: false, called: false }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it(testCase.desc, inject(function(keyboard, editorActions) { + + // given + var removeSelectionSpy = sinon.spy(editorActions, 'trigger'); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(removeSelectionSpy.calledWith('removeSelection')).to.be.equal(testCase.called); + + })); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/spec/features/keyboard/UndoSpec.js b/test/spec/features/keyboard/UndoSpec.js new file mode 100644 index 000000000..7d249f356 --- /dev/null +++ b/test/spec/features/keyboard/UndoSpec.js @@ -0,0 +1,73 @@ +/* global sinon */ + +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = [ 'z', 'Z' ]; + +describe('features/keyboard - undo', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'should call undo', keys: KEYS, ctrlKey: true, shiftKey: false, called: true }, + { desc: 'should not call undo', keys: KEYS, ctrlKey: true, shiftKey: true, called: false }, + { desc: 'should not call undo', keys: KEYS, ctrlKey: false, shiftKey: true, called: false }, + { desc: 'should not call undo', keys: KEYS, ctrlKey: false, shiftKey: false, called: false }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it(testCase.desc, inject(function(keyboard, editorActions) { + + // given + var undoSpy = sinon.spy(editorActions, 'trigger'); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey, + shiftKey: testCase.shiftKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(undoSpy.calledWith('undo')).to.be.equal(testCase.called); + + })); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/spec/features/keyboard/ZoomSpec.js b/test/spec/features/keyboard/ZoomSpec.js new file mode 100644 index 000000000..2aba0ad48 --- /dev/null +++ b/test/spec/features/keyboard/ZoomSpec.js @@ -0,0 +1,74 @@ +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import { + forEach +} from 'min-dash'; + +import modelingModule from 'lib/features/modeling'; +import editorActionsModule from 'lib/features/editor-actions'; +import keyboardModule from 'lib/features/keyboard'; + +import { createKeyEvent } from 'test/util/KeyEvents'; + +var KEYS = { + ZOOM_IN: [ '+', 'Add', '=' ], + ZOOM_OUT: [ '-', 'Subtract' ], + ZOOM_DEFAULT: [ '0' ], +}; + +describe('features/keyboard - zoom', function() { + + var defaultDiagramConfig = { + modules: [ + modelingModule, + keyboardModule, + editorActionsModule + ], + canvas: { + deferUpdate: false + } + }; + + beforeEach(bootstrapDiagram(defaultDiagramConfig)); + + /* eslint-disable no-multi-spaces */ + var decisionTable = [ + { desc: 'zoom in', keys: KEYS.ZOOM_IN, ctrlKey: true, defaultZoom: 3, zoom: 4 }, + { desc: 'zoom out', keys: KEYS.ZOOM_OUT, ctrlKey: true, defaultZoom: 3, zoom: 2.456 }, + { desc: 'zoom default', keys: KEYS.ZOOM_DEFAULT, ctrlKey: true, defaultZoom: 3, zoom: 1 }, + ]; + /* eslint-enable */ + + forEach(decisionTable, function(testCase) { + + forEach(testCase.keys, function(key) { + + it('should handle ' + key + ' for ' + testCase.desc, inject(function(canvas, keyboard) { + + // given + canvas.zoom(testCase.defaultZoom); + + var event = createKeyEvent( + key, + { + ctrlKey: testCase.ctrlKey + } + ); + + // when + keyboard._keyHandler(event); + + // then + expect(canvas.zoom()).to.be.equal(testCase.zoom); + expect(event.defaultPrevented).to.be.true; + + })); + + }); + + }); + +}); diff --git a/test/util/KeyEvents.js b/test/util/KeyEvents.js index 507034a41..cd4375553 100644 --- a/test/util/KeyEvents.js +++ b/test/util/KeyEvents.js @@ -1,9 +1,28 @@ -export function createKeyEvent(element, code, ctrlKey) { - var e = document.createEvent('Events') || new document.defaultView.CustomEvent('keyEvent'); +import { + assign, + isString +} from 'min-dash'; - e.keyCode = code; - e.which = code; - e.ctrlKey = ctrlKey; +export function createKeyEvent(key, modifiers, target) { - return e; + var event = document.createEvent('Events') || new document.defaultView.CustomEvent('keyEvent'); + + var options = modifiers || {}; + + if (isString(key)) { + options.key = key; + } + + options.keyCode = key; + options.which = key; + options.target = target || document; + options.preventDefault = preventDefault; + + return assign({}, event, options); +} + +// helpers ////// + +function preventDefault() { + this.defaultPrevented = true; } \ No newline at end of file