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}) => ( +
+
{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! //