diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js
index 36274ce29..61327d4bb 100644
--- a/client/app/scripts/actions/app-actions.js
+++ b/client/app/scripts/actions/app-actions.js
@@ -13,6 +13,22 @@ import AppStore from '../stores/app-store';
const log = debug('scope:app-actions');
+export function showHelp() {
+ AppDispatcher.dispatch({type: ActionTypes.SHOW_HELP});
+}
+
+export function hideHelp() {
+ AppDispatcher.dispatch({type: ActionTypes.HIDE_HELP});
+}
+
+export function toggleHelp() {
+ if (AppStore.getShowingHelp()) {
+ hideHelp();
+ } else {
+ showHelp();
+ }
+}
+
export function selectMetric(metricId) {
AppDispatcher.dispatch({
type: ActionTypes.SELECT_METRIC,
@@ -219,7 +235,9 @@ export function enterNode(nodeId) {
export function hitEsc() {
const controlPipe = AppStore.getControlPipe();
- if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') {
+ if (AppStore.getShowingHelp()) {
+ hideHelp();
+ } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_CLOSE_TERMINAL,
pipeId: controlPipe.get('id')
diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js
index da50a2acb..ab9babe27 100644
--- a/client/app/scripts/components/app.js
+++ b/client/app/scripts/components/app.js
@@ -7,12 +7,13 @@ import Logo from './logo';
import AppStore from '../stores/app-store';
import Footer from './footer.js';
import Sidebar from './sidebar.js';
+import HelpPanel from './help-panel';
import Status from './status.js';
import Topologies from './topologies.js';
import TopologyOptions from './topology-options.js';
import { getApiDetails, getTopologies } from '../utils/web-api-utils';
import { pinNextMetric, hitEsc, unpinMetric,
- selectMetric } from '../actions/app-actions';
+ selectMetric, toggleHelp } from '../actions/app-actions';
import Details from './details';
import Nodes from './nodes';
import MetricSelector from './metric-selector';
@@ -22,10 +23,6 @@ import { showingDebugToolbar, toggleDebugToolbar,
DebugToolbar } from './debug-toolbar.js';
const ESC_KEY_CODE = 27;
-const D_KEY_CODE = 68;
-const Q_KEY_CODE = 81;
-const RIGHT_ANGLE_KEY_IDENTIFIER = 'U+003C';
-const LEFT_ANGLE_KEY_IDENTIFIER = 'U+003E';
const keyPressLog = debug('scope:app-key-press');
/* make sure these can all be shallow-checked for equality for PureRenderMixin */
@@ -47,6 +44,7 @@ function getStateFromStores() {
availableCanvasMetrics: AppStore.getAvailableCanvasMetrics(),
nodeDetails: AppStore.getNodeDetails(),
nodes: AppStore.getNodes(),
+ showingHelp: AppStore.getShowingHelp(),
selectedNodeId: AppStore.getSelectedNodeId(),
selectedMetric: AppStore.getSelectedMetric(),
topologies: AppStore.getTopologies(),
@@ -65,12 +63,14 @@ export default class App extends React.Component {
super(props, context);
this.onChange = this.onChange.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
+ this.onKeyUp = this.onKeyUp.bind(this);
this.state = getStateFromStores();
}
componentDidMount() {
AppStore.addListener(this.onChange);
- window.addEventListener('keyup', this.onKeyPress);
+ window.addEventListener('keypress', this.onKeyPress);
+ window.addEventListener('keyup', this.onKeyUp);
getRouter().start({hashbang: true});
if (!AppStore.isRouteSet()) {
@@ -80,24 +80,43 @@ export default class App extends React.Component {
getApiDetails();
}
+ componentWillUnmount() {
+ window.removeEventListener('keypress', this.onKeyPress);
+ window.removeEventListener('keyup', this.onKeyUp);
+ }
+
onChange() {
this.setState(getStateFromStores());
}
- onKeyPress(ev) {
- keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
+ onKeyUp(ev) {
+ // don't get esc in onKeyPress
if (ev.keyCode === ESC_KEY_CODE) {
hitEsc();
- } else if (ev.keyIdentifier === RIGHT_ANGLE_KEY_IDENTIFIER) {
+ }
+ }
+
+ onKeyPress(ev) {
+ //
+ // keyup gives 'key'
+ // keypress gives 'char'
+ // Distinction is important for international keyboard layouts where there
+ // is often a different {key: char} mapping.
+ //
+ keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
+ const char = String.fromCharCode(ev.charCode);
+ if (char === '<') {
pinNextMetric(-1);
- } else if (ev.keyIdentifier === LEFT_ANGLE_KEY_IDENTIFIER) {
+ } else if (char === '>') {
pinNextMetric(1);
- } else if (ev.keyCode === Q_KEY_CODE) {
+ } else if (char === 'q') {
unpinMetric();
selectMetric(null);
- } else if (ev.keyCode === D_KEY_CODE) {
+ } else if (char === 'd') {
toggleDebugToolbar();
this.forceUpdate();
+ } else if (char === '?') {
+ toggleHelp();
}
}
@@ -112,6 +131,9 @@ export default class App extends React.Component {
return (
{showingDebugToolbar() &&
}
+
+ {this.state.showingHelp &&
}
+
{showingDetails &&
}
diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js
index 590c7db01..904b22018 100644
--- a/client/app/scripts/components/footer.js
+++ b/client/app/scripts/components/footer.js
@@ -4,7 +4,7 @@ import moment from 'moment';
import { getUpdateBufferSize } from '../utils/update-buffer-utils';
import { contrastModeUrl, isContrastMode } from '../utils/contrast-utils';
import { clickDownloadGraph, clickForceRelayout, clickPauseUpdate,
- clickResumeUpdate } from '../actions/app-actions';
+ clickResumeUpdate, toggleHelp } from '../actions/app-actions';
import { basePathSlash } from '../utils/web-api-utils';
export default function Footer(props) {
@@ -64,6 +64,9 @@ export default function Footer(props) {
+
+
+
diff --git a/client/app/scripts/components/help-panel.js b/client/app/scripts/components/help-panel.js
new file mode 100644
index 000000000..42564a249
--- /dev/null
+++ b/client/app/scripts/components/help-panel.js
@@ -0,0 +1,44 @@
+import React from 'react';
+
+const GENERAL_SHORTCUTS = [
+ {key: 'esc', label: 'Close active panel'},
+ {key: '?', label: 'Toggle shortcut menu'},
+];
+
+const CANVAS_METRIC_SHORTCUTS = [
+ {key: '<', label: 'Select and pin previous metric'},
+ {key: '>', label: 'Select and pin next metric'},
+ {key: 'q', label: 'Unpin current metric'},
+];
+
+function renderShortcuts(cuts) {
+ return (
+
+ {cuts.map(({key, label}) => (
+
+ ))}
+
+ );
+}
+
+export default class HelpPanel extends React.Component {
+ render() {
+ return (
+
+
+
Keyboard Shortcuts
+
+
+
General
+ {renderShortcuts(GENERAL_SHORTCUTS)}
+ Canvas Metrics
+ {renderShortcuts(CANVAS_METRIC_SHORTCUTS)}
+
+
+ );
+ }
+}
+
diff --git a/client/app/scripts/components/terminal.js b/client/app/scripts/components/terminal.js
index 591a5d59a..ff062659e 100644
--- a/client/app/scripts/components/terminal.js
+++ b/client/app/scripts/components/terminal.js
@@ -175,6 +175,7 @@ export default class Terminal extends React.Component {
if (this.term) {
log('destroy terminal');
+ this.term.blur();
this.term.destroy();
this.term = null;
}
diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js
index a6b3f5912..b6290f2fb 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -21,6 +21,7 @@ const ACTION_TYPES = [
'DO_CONTROL_SUCCESS',
'ENTER_EDGE',
'ENTER_NODE',
+ 'HIDE_HELP',
'LEAVE_EDGE',
'LEAVE_NODE',
'PIN_METRIC',
@@ -36,7 +37,8 @@ const ACTION_TYPES = [
'RECEIVE_API_DETAILS',
'RECEIVE_ERROR',
'ROUTE_TOPOLOGY',
- 'SELECT_METRIC'
+ 'SELECT_METRIC',
+ 'SHOW_HELP'
];
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index 9f11ac536..646e11f10 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -58,6 +58,7 @@ let routeSet = false;
let controlPipes = makeOrderedMap(); // pipeId -> controlPipe
let updatePausedAt = null; // Date
let websocketClosed = true;
+let showingHelp = false;
let selectedMetric = null;
let pinnedMetric = selectedMetric;
@@ -149,6 +150,10 @@ export class AppStore extends Store {
};
}
+ getShowingHelp() {
+ return showingHelp;
+ }
+
getActiveTopologyOptions() {
// options for current topology, sub-topologies share options with parent
if (currentTopology && currentTopology.get('parentId')) {
@@ -449,6 +454,16 @@ export class AppStore extends Store {
this.__emitChange();
break;
}
+ case ActionTypes.SHOW_HELP: {
+ showingHelp = true;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.HIDE_HELP: {
+ showingHelp = false;
+ this.__emitChange();
+ break;
+ }
case ActionTypes.DESELECT_NODE: {
closeNodeDetails();
this.__emitChange();
diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js
index 6878b5ac6..df39d11d8 100644
--- a/client/app/scripts/utils/router-utils.js
+++ b/client/app/scripts/utils/router-utils.js
@@ -19,7 +19,7 @@ export function updateRoute() {
const urlStateString = window.location.hash
.replace('#!/state/', '')
.replace('#!/', '') || '{}';
- const prevState = JSON.parse(urlStateString);
+ const prevState = JSON.parse(decodeURIComponent(urlStateString));
if (shouldReplaceState(prevState, state)) {
// Replace the top of the history rather than pushing on a new item.
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index fe9d46fe1..35b49aa91 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -1080,6 +1080,70 @@ h2 {
}
}
+//
+// Help panel!
+//
+
+@help-panel-width: 400px;
+@help-panel-height: 380px;
+.help-panel {
+ position: absolute;
+ -webkit-transform: translate3d(0, 0, 0);
+ top: 50%;
+ left: 50%;
+ width: @help-panel-width;
+ height: @help-panel-height;
+ margin-left: @help-panel-width / -2;
+ margin-top: @help-panel-height / -2;
+ z-index: 2048;
+ background-color: white;
+ .shadow-2;
+
+ &-header {
+ background-color: @weave-blue;
+ padding: 36px;
+ color: white;
+
+ h2 {
+ margin: 0;
+ }
+ }
+
+ &-main {
+ padding: 12px 36px 36px;
+ }
+
+ h3 {
+ text-transform: uppercase;
+ font-size: 90%;
+ color: #8383ac;
+ padding: 4px 0;
+ }
+
+ &-shortcut {
+ kbd {
+ display: inline-block;
+ padding: 3px 5px;
+ font-size: 11px;
+ line-height: 10px;
+ color: #555;
+ vertical-align: middle;
+ background-color: #fcfcfc;
+ border: solid 1px #ccc;
+ border-bottom-color: #bbb;
+ border-radius: 3px;
+ box-shadow: inset 0 -1px 0 #bbb;
+ }
+ div.key {
+ width: 100px;
+ display: inline-block;
+ }
+ div.label {
+ display: inline-block;
+ }
+ }
+}
+
//
// Debug panel!
//