Files
coriolis/src/app/Coriolis.jsx
2017-04-21 16:07:41 +01:00

348 lines
11 KiB
JavaScript

import React from 'react';
import PropTypes from 'prop-types';
import Router from './Router';
import { EventEmitter } from 'fbemitter';
import { getLanguage } from './i18n/Language';
import Persist from './stores/Persist';
import Header from './components/Header';
import Tooltip from './components/Tooltip';
import ModalExport from './components/ModalExport';
import ModalHelp from './components/ModalHelp';
import ModalImport from './components/ModalImport';
import ModalPermalink from './components/ModalPermalink';
import * as CompanionApiUtils from './utils/CompanionApiUtils';
import AboutPage from './pages/AboutPage';
import NotFoundPage from './pages/NotFoundPage';
import OutfittingPage from './pages/OutfittingPage';
import ComparisonPage from './pages/ComparisonPage';
import ShipyardPage from './pages/ShipyardPage';
import ErrorDetails from './pages/ErrorDetails';
const zlib = require('zlib');
/**
* Coriolis App
*/
export default class Coriolis extends React.Component {
static childContextTypes = {
closeMenu: PropTypes.func.isRequired,
hideModal: PropTypes.func.isRequired,
language: PropTypes.object.isRequired,
noTouch: PropTypes.bool.isRequired,
onCommand: PropTypes.func.isRequired,
onWindowResize: PropTypes.func.isRequired,
openMenu: PropTypes.func.isRequired,
route: PropTypes.object.isRequired,
showModal: PropTypes.func.isRequired,
sizeRatio: PropTypes.number.isRequired,
termtip: PropTypes.func.isRequired,
tooltip: PropTypes.func.isRequired
};
/**
* Creates an instance of the Coriolis App
*/
constructor() {
super();
this._setPage = this._setPage.bind(this);
this._openMenu = this._openMenu.bind(this);
this._closeMenu = this._closeMenu.bind(this);
this._showModal = this._showModal.bind(this);
this._hideModal = this._hideModal.bind(this);
this._tooltip = this._tooltip.bind(this);
this._termtip = this._termtip.bind(this);
this._onWindowResize = this._onWindowResize.bind(this);
this._onCommand = this._onCommand.bind(this);
this._onLanguageChange = this._onLanguageChange.bind(this);
this._onSizeRatioChange = this._onSizeRatioChange.bind(this);
this._keyDown = this._keyDown.bind(this);
this._importBuild = this._importBuild.bind(this);
this.emitter = new EventEmitter();
this.state = {
noTouch: !('ontouchstart' in window || navigator.msMaxTouchPoints || navigator.maxTouchPoints),
page: null,
language: getLanguage(Persist.getLangCode()),
route: {},
sizeRatio: Persist.getSizeRatio()
};
Router('', (r) => this._setPage(ShipyardPage, r));
Router('/import?', (r) => this._importBuild(r));
Router('/import/:data', (r) => this._importBuild(r));
Router('/outfit/?', (r) => this._setPage(OutfittingPage, r));
Router('/outfit/:ship/?', (r) => this._setPage(OutfittingPage, r));
Router('/outfit/:ship/:code?', (r) => this._setPage(OutfittingPage, r));
Router('/compare/:name?', (r) => this._setPage(ComparisonPage, r));
Router('/comparison?', (r) => this._setPage(ComparisonPage, r));
Router('/comparison/:code', (r) => this._setPage(ComparisonPage, r));
Router('/about', (r) => this._setPage(AboutPage, r));
Router('*', (r) => this._setPage(null, r));
}
/**
* Import a build directly
* @param {Object} r The current route
*/
_importBuild(r) {
try {
// Need to decode and gunzip the data, then build the ship
const data = zlib.gunzipSync(new Buffer(r.params.data, 'base64'));
const json = JSON.parse(data);
const ship = CompanionApiUtils.shipFromJson(json);
r.params.ship = ship.id;
r.params.code = ship.toString();
this._setPage(OutfittingPage, r);
} catch (err) {
this._onError('Failed to import ship', r.path, 0, 0, err);
}
}
/**
* Updates / Sets the page and route context
* @param {[type]} page The page to be shown
* @param {Object} route The current route
*/
_setPage(page, route) {
this.setState({ page, route, currentMenu: null, modal: null, error: null });
}
/**
* Handle unexpected error. This is most likely an unhandled React Error which
* is also most likely unrecoverable. The best option is to catch as many details
* as possible so the user can report the error and provide a link to reload the page
* to reset the VM and clear any error state.
*
* @param {string} msg Message
* @param {string} scriptUrl URL
* @param {number} line Line number
* @param {number} col Column number
* @param {Object} errObj Error Object
*/
_onError(msg, scriptUrl, line, col, errObj) {
console && console.error && console.error(arguments); // eslint-disable-line no-console
this.setState({
error: <ErrorDetails error={{ message: msg, details: { scriptUrl, line, col, error: JSON.stringify(errObj) } }}/>,
page: null,
currentMenu: null,
modal: null
});
// TODO: Improve in the event of React Errors
// Potentially ReactDOM.render into dom here instead
// ReactDOM.render(this, document.getElementById('coriolis'));
}
/**
* Propagate language and format changes
* @param {string} lang Language code
*/
_onLanguageChange(lang) {
this.setState({ language: getLanguage(Persist.getLangCode()) });
}
/**
* Propagate the sizeRatio change
* @param {number} sizeRatio Size ratio / scale
*/
_onSizeRatioChange(sizeRatio) {
this.setState({ sizeRatio });
}
/**
* Handle Key Down
* @param {Event} e Keyboard Event
*/
_keyDown(e) {
// .keyCode will eventually be replaced with .key
switch (e.keyCode) {
case 27: // Escape Key
this._hideModal();
this._closeMenu();
break;
case 72: // 'h'
if (e.ctrlKey || e.metaKey) { // CTRL/CMD + h
e.preventDefault();
this._showModal(<ModalHelp />);
}
break;
case 73: // 'i'
if (e.ctrlKey || e.metaKey) { // CTRL/CMD + i
e.preventDefault();
this._showModal(<ModalImport />);
}
break;
case 79: // 'o'
if (e.ctrlKey || e.metaKey) { // CTRL/CMD + o
e.preventDefault();
this._showModal(<ModalPermalink url={window.location.href}/>);
}
break;
case 83: // 's'
if (e.ctrlKey || e.metaKey) { // CTRL/CMD + s
e.preventDefault();
this.emitter.emit('command', 'save');
}
}
}
/**
* Opens the modal display with the specified content
* @param {React.Component} content Modal Content
*/
_showModal(content) {
let modal = <div className='modal-bg' onClick={(e) => this._hideModal() }>{content}</div>;
this.setState({ modal });
}
/**
* Hides any open modal
*/
_hideModal() {
if (this.state.modal) {
this.setState({ modal: null });
}
}
/**
* Sets the open menu state
* @param {string|object} currentMenu The reference to the current menu
*/
_openMenu(currentMenu) {
if (this.state.currentMenu != currentMenu) {
this.setState({ currentMenu });
}
}
/**
* Closes the open menu
*/
_closeMenu() {
if (this.state.currentMenu) {
this.setState({ currentMenu: null });
}
}
/**
* Show/Hide the tooltip
* @param {React.Component} content Tooltip content
* @param {DOMRect} rect Target bounding rect
* @param {[type]} opts Options
*/
_tooltip(content, rect, opts) {
if (!content && this.state.tooltip) {
this.setState({ tooltip: null });
} else if (content && Persist.showTooltips()) {
this.setState({ tooltip: <Tooltip rect={rect} options={opts}>{content}</Tooltip> });
}
}
/**
* Show the term tip
* @param {string} term Term or Phrase
* @param {Object} opts Options - dontCap, orientation (n,e,s,w) (can also be the event if no options supplied)
* @param {SyntheticEvent} event Event
* @param {SyntheticEvent} e2 Alternative location for synthetic event from charts (where 'Event' is actually a chart index)
*/
_termtip(term, opts, event, e2) {
if (opts && opts.nativeEvent) { // Opts is the SyntheticEvent
event = opts;
opts = { cap: true };
}
if (e2 instanceof Object && e2.nativeEvent) { // E2 is the SyntheticEvent
event = e2;
}
this._tooltip(
<div className={'cen' + (opts.cap ? ' cap' : '')}>{this.state.language.translate(term)}</div>,
event.currentTarget.getBoundingClientRect(),
opts
);
}
/**
* Add a listener to on window resize
* @param {Function} listener Listener callback
* @return {Object} Subscription token
*/
_onWindowResize(listener) {
return this.emitter.addListener('windowResize', listener);
}
/**
* Add a listener to global commands such as save,
* @param {Function} listener Listener callback
* @return {Object} Subscription token
*/
_onCommand(listener) {
return this.emitter.addListener('command', listener);
}
/**
* Creates the context to be passed down to pages / components containing
* language, sizeRatio and route references
* @return {object} Context to be passed down
*/
getChildContext() {
return {
closeMenu: this._closeMenu,
hideModal: this._hideModal,
language: this.state.language,
noTouch: this.state.noTouch,
onCommand: this._onCommand,
onWindowResize: this._onWindowResize,
openMenu: this._openMenu,
route: this.state.route,
showModal: this._showModal,
sizeRatio: this.state.sizeRatio,
termtip: this._termtip,
tooltip: this._tooltip
};
}
/**
* Adds necessary listeners and starts Routing
*/
componentWillMount() {
// Listen for appcache updated event, present refresh to update view
if (window.applicationCache) {
window.applicationCache.addEventListener('updateready', () => {
if (window.applicationCache.status == window.applicationCache.UPDATEREADY) {
this.setState({ appCacheUpdate: true }); // Browser downloaded a new app cache.
}
});
}
window.onerror = this._onError.bind(this);
window.addEventListener('resize', () => this.emitter.emit('windowResize'));
document.getElementById('coriolis').addEventListener('scroll', () => this._tooltip());
document.addEventListener('keydown', this._keyDown);
Persist.addListener('language', this._onLanguageChange);
Persist.addListener('sizeRatio', this._onSizeRatioChange);
Router.start();
}
/**
* Renders the main app
* @return {React.Component} The main app
*/
render() {
let currentMenu = this.state.currentMenu;
return <div style={{ minHeight: '100%' }} onClick={this._closeMenu} className={ this.state.noTouch ? 'no-touch' : null }>
<Header appCacheUpdate={this.state.appCacheUpdate} currentMenu={currentMenu} />
{ this.state.error ? this.state.error : this.state.page ? React.createElement(this.state.page, { currentMenu }) : <NotFoundPage/> }
{ this.state.modal }
{ this.state.tooltip }
<footer>
<div className="right cap">
<a href="https://github.com/EDCD/coriolis" target="_blank" title="Coriolis Github Project">{window.CORIOLIS_VERSION} - {window.CORIOLIS_DATE}</a>
</div>
</footer>
</div>;
}
}