commit 028970fd92176879f87bcf227339ae757bb08c02
Author: Pavel Fidranský <fidransky@outlook.com>
Date:   Sun Feb 10 20:28:08 2019 +0100

    add keyboard shortcuts (press F1 in app to see the list)

diff --git a/sources/imiger-core/src/main/webapp/css/components/viewport.css b/sources/imiger-core/src/main/webapp/css/components/viewport.css
index 34ad971..1537a71 100644
--- a/sources/imiger-core/src/main/webapp/css/components/viewport.css
+++ b/sources/imiger-core/src/main/webapp/css/components/viewport.css
@@ -1,5 +1,6 @@
 .viewport {
 	flex: 1;
+	user-select: none;
 }
 
 .viewport .edge {
@@ -37,7 +38,6 @@
 .viewport .vertex {
     font-family: 'Consolas', sans-serif;
     font-size: 15px;
-	user-select: none;
 	cursor: default;
 }
 
@@ -85,10 +85,6 @@
 	stroke-width: 0;
 }
 
-.viewport .group {
-	user-select: none;
-}
-
 .viewport .group .group-viewport {
 	stroke: #000;
 	stroke-width: 0;
diff --git a/sources/imiger-core/src/main/webapp/css/show-graph.css b/sources/imiger-core/src/main/webapp/css/show-graph.css
index 1e58e3a..3c8c0f4 100644
--- a/sources/imiger-core/src/main/webapp/css/show-graph.css
+++ b/sources/imiger-core/src/main/webapp/css/show-graph.css
@@ -2,6 +2,15 @@ body {
 	overflow: hidden;
 }
 
+kbd {
+	display: inline-block;
+	font-size: 1rem;
+	line-height: 1;
+	padding: 4px;
+	border-radius: 4px;
+	background-color: #eee;
+}
+
 .graph-content {
 	display: flex;
 	height: calc(100vh - 60px - 43px);
diff --git a/sources/imiger-core/src/main/webapp/js/components/HelpModalWindow.js b/sources/imiger-core/src/main/webapp/js/components/HelpModalWindow.js
new file mode 100644
index 0000000..5f89984
--- /dev/null
+++ b/sources/imiger-core/src/main/webapp/js/components/HelpModalWindow.js
@@ -0,0 +1,75 @@
+/**
+ * Class representing a modal window.
+ */
+class HelpModalWindow extends ModalWindow {
+	/**
+	 * @constructor
+	 */
+	constructor() {
+		super();
+
+		this.keyboardShortcuts = [{
+			shortcut: '<kbd>ctrl</kbd> and <kbd>+</kbd>',
+			action: 'Zoom in.',
+		}, {
+			shortcut: '<kbd>ctrl</kbd> and <kbd>-</kbd>',
+			action: 'Zoom out.',
+		}, {
+			shortcut: '<kbd>ctrl</kbd> + <kbd>0</kbd>',
+			action: 'Reset zoom.',
+		}, {
+			shortcut: '<kbd>ctrl</kbd> + <kbd>f</kbd>',
+			action: 'Focus search field.',
+		}, {
+			shortcut: '<kbd>ctrl</kbd> + <kbd>s</kbd>',
+			action: 'Open Save diagram dialog window.',
+		}, {
+			shortcut: '<kbd>ctrl</kbd> + <kbd>m</kbd>',
+			action: 'Toggle minimap.',
+		}, {
+			shortcut: '<kbd>alt</kbd> + click node',
+			action: 'Exclude node.',
+		}, {
+			shortcut: 'doubleclick graph',
+			action: 'Zoom in.',
+		}, {
+			shortcut: 'highlight node + <kbd>arrows</kbd>',
+			action: 'Move node.',
+		}, {
+			shortcut: '<kbd>F1</kbd>',
+			action: 'Display this help.',
+		}];
+	}
+
+	/**
+	 * @inheritdoc
+	 */
+	render() {
+		super.render();
+
+		this._bodyElement.appendChild(DOM.h('table', {}, [
+			DOM.h('thead', {}, [
+				DOM.h('tr', {}, [
+					DOM.h('th', {
+						innerText: 'Keyboard shortcut',
+					}),
+					DOM.h('th', {
+						innerText: 'Action',
+					}),
+				]),
+			]),
+			DOM.h('tbody', {}, this.keyboardShortcuts.map(keyboardShortcut => {
+				return DOM.h('tr', {}, [
+					DOM.h('td', {
+						innerHTML: keyboardShortcut.shortcut,
+					}),
+					DOM.h('td', {
+						innerText: keyboardShortcut.action,
+					}),
+				]);
+			})),
+		]));
+
+		return this._rootElement;
+	}
+}
diff --git a/sources/imiger-core/src/main/webapp/js/components/generic/modalWindow.js b/sources/imiger-core/src/main/webapp/js/components/generic/modalWindow.js
index 150fe05..bb75a94 100644
--- a/sources/imiger-core/src/main/webapp/js/components/generic/modalWindow.js
+++ b/sources/imiger-core/src/main/webapp/js/components/generic/modalWindow.js
@@ -55,6 +55,10 @@ class ModalWindow {
 	 */
 	open() {
 		this._rootElement.removeAttribute('hidden');
+
+		document.body.addEventListener('keydown', this._closeOnEscapeKeyPressed.bind(this), {
+			once: true,
+		});
 	}
 
 	/**
@@ -76,4 +80,15 @@ class ModalWindow {
 			this.close();
 		}
 	}
+
+	/**
+	 * Closes the modal window when Escape key is pressed.
+	 * @param {KeyboardEvent} e Keyboard event triggered by keydown event listener.
+	 */
+	_closeOnEscapeKeyPressed(e) {
+		if (e.key === 'Escape') {
+			e.preventDefault();
+			this.close();
+		}
+	}
 }
diff --git a/sources/imiger-core/src/main/webapp/js/components/minimap.js b/sources/imiger-core/src/main/webapp/js/components/minimap.js
index e1e5002..f7e6de6 100644
--- a/sources/imiger-core/src/main/webapp/js/components/minimap.js
+++ b/sources/imiger-core/src/main/webapp/js/components/minimap.js
@@ -25,11 +25,10 @@ class Minimap {
 			onMouseDown: this._onViewportMouseDown.bind(this),
 		});
 
-		this._rootElement = DOM.s('svg', {
+		this._minimapElement = DOM.s('svg', {
 			class: 'minimap',
-			id: 'minimapComponent',
 			viewBox: `-100 -50 ${this._width} ${this._height}`,
-			onMouseDown: this._onRootMouseDown.bind(this),
+			onMouseDown: this._onMinimapMouseDown.bind(this),
 		}, [
 			DOM.s('use', {
 				transform: `scale(${this._scale})`,
@@ -38,6 +37,12 @@ class Minimap {
 			this._viewportElement,
 		]);
 
+		this._rootElement = DOM.h('div', {
+			id: 'minimapComponent',
+		}, [
+			this._minimapElement,
+		]);
+
 		return this._rootElement;
 	}
 
@@ -72,13 +77,49 @@ class Minimap {
 		this._viewportElement.setAttribute('y', coords.y * -1 * this._scale);
 	}
 
+	/**
+	 * @returns {boolean} True if the minimap is revealed at the moment, otherwise false.
+	 * @public
+	 */
+	get isVisible() {
+		return this._rootElement.hasAttribute('hidden');
+	}
+
+	/**
+	 * Reveals the minimap.
+	 * @public
+	 */
+	display() {
+		this._rootElement.removeAttribute('hidden');
+	}
+
+	/**
+	 * Hides the minimap.
+	 * @public
+	 */
+	hide() {
+		this._rootElement.setAttribute('hidden', 'hidden');
+	}
+
+	/**
+	 * Toggles visibility of the minimap.
+	 * @public
+	 */
+	toggle() {
+		if (this.isVisible) {
+			this.display();
+		} else {
+			this.hide();
+		}
+	}
+
 	/**
 	 * @private
 	 */
-	_onRootMouseDown(e) {
+	_onMinimapMouseDown(e) {
 		let start = new Coordinates(e.clientX, e.clientY);
 
-		let viewBox = this._rootElement.getAttribute('viewBox').split(' ');
+		let viewBox = this._minimapElement.getAttribute('viewBox').split(' ');
 		let minimapRootPosition = new Coordinates(parseInt(viewBox[0]), parseInt(viewBox[1]));
 
 		let that = this;
@@ -91,7 +132,7 @@ class Minimap {
 
 			let offset = new Coordinates(start.x - e.clientX, start.y - e.clientY);
 
-			that._rootElement.setAttribute('viewBox', `${minimapRootPosition.x + offset.x} ${minimapRootPosition.y + offset.y} ${that._width} ${that._height}`);
+			that._minimapElement.setAttribute('viewBox', `${minimapRootPosition.x + offset.x} ${minimapRootPosition.y + offset.y} ${that._width} ${that._height}`);
 		}
 
 		function mouseUp() {
diff --git a/sources/imiger-core/src/main/webapp/js/components/navbar.js b/sources/imiger-core/src/main/webapp/js/components/navbar.js
index bc4d342..3c06746 100644
--- a/sources/imiger-core/src/main/webapp/js/components/navbar.js
+++ b/sources/imiger-core/src/main/webapp/js/components/navbar.js
@@ -65,7 +65,7 @@ class Navbar {
 			DOM.h('button', {
 				class: 'btn zoom',
 				id: 'zoomOut',
-				title: 'zoom -',
+				title: 'zoom out [ctrl and -]',
 				onClick: () => app.zoom.zoomOut(),
 			}, [
 				DOM.h('img', {
@@ -81,7 +81,7 @@ class Navbar {
 			DOM.h('button', {
 				class: 'btn zoom',
 				id: 'zoomIn',
-				title: 'zoom +',
+				title: 'zoom in [ctrl and +]',
 				onClick: () => app.zoom.zoomIn(),
 			}, [
 				DOM.h('img', {
@@ -98,7 +98,8 @@ class Navbar {
 			placeholder: 'Search components...',
 			class: 'search-text',
 			id: 'searchText',
-			onKeyUp: e => {
+			title: 'search nodes [ctrl + f]',
+			onKeyDown: e => {
 				switch (e.key) {
 					case 'Enter':
 						search(e.target.value);
@@ -216,6 +217,7 @@ class Navbar {
 			}, [
 				DOM.h('label', {
 					for: 'move',
+					title: 'move node [click]',
 				}, [
 					DOM.h('input', {
 						type: 'radio',
@@ -233,6 +235,7 @@ class Navbar {
 				]),
 				DOM.h('label', {
 					for: 'exclude',
+					title: 'exclude node [alt + click]',
 				}, [
 					DOM.h('input', {
 						type: 'radio',
@@ -396,7 +399,7 @@ class Navbar {
 		}, [
 			DOM.h('button', {
 				class: 'btn save-diagram',
-				title: 'Save diagram',
+				title: 'Save diagram [ctrl + s]',
 				onClick: () => app.saveDiagramModalWindowComponent.open(),
 			}, [
 				DOM.h('img', {
diff --git a/sources/imiger-core/src/main/webapp/js/components/node.js b/sources/imiger-core/src/main/webapp/js/components/node.js
index e81b9a9..1a9146f 100644
--- a/sources/imiger-core/src/main/webapp/js/components/node.js
+++ b/sources/imiger-core/src/main/webapp/js/components/node.js
@@ -416,16 +416,17 @@ class Node {
 				return;
 			}
 
-			switch (document.modeForm.mode.value) {
-				case 'move':
-					this.highlightWithNeighbours(!this.isHighlighted);
-					break;
-
-				case 'exclude':
-					this.exclude();
-
-					app.sidebarComponent.excludedNodeListComponent.addNode(this);
-					break;
+			if (document.modeForm.mode.value === 'exclude' || (document.modeForm.mode.value === 'move' && e.altKey === true)) {
+				this.exclude();
+				app.sidebarComponent.excludedNodeListComponent.addNode(this);
+			} else if (document.modeForm.mode.value === 'move') {
+				this.highlightWithNeighbours(!this.isHighlighted);
+
+				if (this.isHighlighted) {
+					app.activeNode = this;
+				} else {
+					app.activeNode = null;
+				}
 			}
 		}
 	}
diff --git a/sources/imiger-core/src/main/webapp/js/components/statusBar.js b/sources/imiger-core/src/main/webapp/js/components/statusBar.js
index 9f32a0f..1583fc9 100644
--- a/sources/imiger-core/src/main/webapp/js/components/statusBar.js
+++ b/sources/imiger-core/src/main/webapp/js/components/statusBar.js
@@ -19,6 +19,7 @@ class StatusBar {
 			DOM.h('span', {
 				class: 'link',
 				innerText: 'toggle minimap',
+				title: 'toggle minimap [ctrl + m]',
 				onClick: this._toggleMinimap.bind(this),
 			}),
 		]);
@@ -48,7 +49,6 @@ class StatusBar {
 	 */
 	_toggleMinimap(e) {
 		e.preventDefault();
-
-		document.getElementById('minimapComponent').classList.toggle('hidden');
+		app.sidebarComponent.minimapComponent.toggle();
 	}
 }
\ No newline at end of file
diff --git a/sources/imiger-core/src/main/webapp/js/components/viewport.js b/sources/imiger-core/src/main/webapp/js/components/viewport.js
index 59a79de..7b1131a 100644
--- a/sources/imiger-core/src/main/webapp/js/components/viewport.js
+++ b/sources/imiger-core/src/main/webapp/js/components/viewport.js
@@ -289,6 +289,8 @@ class Viewport {
 	}
 
 	_onRootDoubleClick(e) {
+		e.preventDefault();
+
 		app.closeFloatingComponents();
 
 		app.zoom.zoomIn(new Coordinates(e.offsetX, e.offsetY));
diff --git a/sources/imiger-core/src/main/webapp/js/services/zoom.js b/sources/imiger-core/src/main/webapp/js/services/zoom.js
index 6c7eea6..d3ca810 100644
--- a/sources/imiger-core/src/main/webapp/js/services/zoom.js
+++ b/sources/imiger-core/src/main/webapp/js/services/zoom.js
@@ -48,7 +48,15 @@ class Zoom {
 			document.querySelector('#zoomOut img').src = 'images/zoom_out_disabled.png';
 		}
 	}
-	
+
+	/**
+	 * Reset the zoom to 100%.
+	 */
+	reset() {
+		this._step = this._steps.indexOf(1);
+		this._onChange();
+	}
+
 	/**
 	 * Change current zoom scale according to currently selected step.
 	 */
diff --git a/sources/imiger-core/src/main/webapp/js/showGraphApp.js b/sources/imiger-core/src/main/webapp/js/showGraphApp.js
index ec32acf..ad91080 100644
--- a/sources/imiger-core/src/main/webapp/js/showGraphApp.js
+++ b/sources/imiger-core/src/main/webapp/js/showGraphApp.js
@@ -27,6 +27,8 @@ class ShowGraphApp extends App {
 		this.groupList = [];
 		/** @prop {array<NodeProxy>} proxyList */
 		this.proxyList = [];
+		/** @prop {Node} activeNode */
+		this.activeNode = null;
 
 		/** @prop {Diagram} diagram */
 		this.diagram = null;
@@ -97,6 +99,7 @@ class ShowGraphApp extends App {
 		this.saveDiagramModalWindowComponent = new SaveDiagramModalWindow;
 		this.filterModalWindowComponent = new FilterModalWindow;
 		this.spinLoaderComponent = new SpinLoader;
+		this.helpModalWindowComponent = new HelpModalWindow;
 
 		const appElement = document.getElementById('app');
 		appElement.appendChild(this.headerComponent.render());
@@ -111,6 +114,7 @@ class ShowGraphApp extends App {
 		appElement.appendChild(this.saveDiagramModalWindowComponent.render());
 		appElement.appendChild(this.filterModalWindowComponent.render());
 		appElement.appendChild(this.spinLoaderComponent.render());
+		appElement.appendChild(this.helpModalWindowComponent.render());
 
 		this.sidebarComponent.minimapComponent.viewportSize = this.viewportComponent.size;
 
@@ -141,6 +145,90 @@ class ShowGraphApp extends App {
 			this.redrawEdges();
 			this.sidebarComponent.minimapComponent.viewportSize = this.viewportComponent.size;
 		});
+
+		// keyboard shortcuts
+		document.body.addEventListener('keydown', e => {
+			// zoom in
+			if (e.ctrlKey === true && e.key === '+') {
+				e.preventDefault();
+				app.zoom.zoomIn();
+			}
+
+			// zoom out
+			if (e.ctrlKey === true && e.key === '-') {
+				e.preventDefault();
+				app.zoom.zoomOut();
+			}
+
+			// reset zoom
+			if (e.ctrlKey === true && e.key === '0') {
+				e.preventDefault();
+				app.zoom.reset();
+			}
+
+			// search
+			if (e.ctrlKey === true && e.key === 'f') {
+				e.preventDefault();
+				const searchInput = document.getElementById('searchText');
+				if (searchInput === document.activeElement) {
+					searchInput.blur();
+				} else {
+					searchInput.focus();
+				}
+			}
+
+			// save diagram
+			if (e.ctrlKey === true && e.key === 's') {
+				e.preventDefault();
+				if (Auth.isLoggedIn()) {
+					app.saveDiagramModalWindowComponent.open();
+				}
+			}
+
+			// toggle minimap
+			if (e.ctrlKey === true && e.key === 'm') {
+				e.preventDefault();
+				app.sidebarComponent.minimapComponent.toggle();
+			}
+
+			// move active node
+			if (e.key === 'ArrowUp' && app.activeNode !== null) {
+				e.preventDefault();
+				this._moveActiveNode(new Coordinates(0, -10));
+			}
+			if (e.key === 'ArrowDown' && app.activeNode !== null) {
+				e.preventDefault();
+				this._moveActiveNode(new Coordinates(0, 10));
+			}
+			if (e.key === 'ArrowLeft' && app.activeNode !== null) {
+				e.preventDefault();
+				this._moveActiveNode(new Coordinates(-10, 0));
+			}
+			if (e.key === 'ArrowRight' && app.activeNode !== null) {
+				e.preventDefault();
+				this._moveActiveNode(new Coordinates(10, 0));
+			}
+
+			// help modal
+			if (e.key === 'F1') {
+				e.preventDefault();
+				this.helpModalWindowComponent.open();
+			}
+		});
+	}
+
+	/**
+	 * Moves the currently active node by the coordinates from its current location.
+	 * @param {Coordinates} offsetCoords Offset coordinates.
+	 */
+	_moveActiveNode(offsetCoords) {
+		const coords = new Coordinates(
+			app.activeNode.position.x + offsetCoords.x,
+			app.activeNode.position.y + offsetCoords.y,
+		);
+
+		app.activeNode.position = coords;
+		app.activeNode.move(coords);
 	}
 
 	/**
diff --git a/sources/imiger-core/src/main/webapp/js/utils/Auth.js b/sources/imiger-core/src/main/webapp/js/utils/Auth.js
new file mode 100644
index 0000000..32e226e
--- /dev/null
+++ b/sources/imiger-core/src/main/webapp/js/utils/Auth.js
@@ -0,0 +1,5 @@
+class Auth {
+	static isLoggedIn() {
+		return document.body.classList.contains('loggedIn');
+	}
+}
\ No newline at end of file
diff --git a/sources/imiger-core/src/main/webapp/showGraph.jsp b/sources/imiger-core/src/main/webapp/showGraph.jsp
index 5636120..2dfe0c2 100644
--- a/sources/imiger-core/src/main/webapp/showGraph.jsp
+++ b/sources/imiger-core/src/main/webapp/showGraph.jsp
@@ -48,6 +48,7 @@
 		<script src="js/components/group.js"></script>
 		<script src="js/components/groupVertexList.js"></script>
 		<script src="js/components/header.js"></script>
+		<script src="js/components/HelpModalWindow.js"></script>
 		<script src="js/components/loginPopup.js"></script>
 		<script src="js/components/minimap.js"></script>
 		<script src="js/components/navbar.js"></script>
@@ -91,6 +92,7 @@
 		<script src="js/services/zoom.js"></script>
 
 		<script src="js/utils/ajax.js"></script>
+		<script src="js/utils/Auth.js"></script>
 		<script src="js/utils/cookies.js"></script>
 		<script src="js/utils/dom.js"></script>
 		<script src="js/utils/utils.js"></script>
