diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e88bbf7bd..d4b88f702 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -12,6 +12,7 @@ import { doControlRequest, getAllNodes, getNodesDelta, getNodeDetails, import { getActiveTopologyOptions, getCurrentTopologyUrl } from '../utils/topology-utils'; import { storageSet } from '../utils/storage-utils'; +import { loadTheme } from '../utils/contrast-utils'; const log = debug('scope:app-actions'); @@ -647,6 +648,18 @@ export function receiveNotFound(nodeId) { }; } +export function setContrastMode(enabled) { + return (dispatch, getState) => { + loadTheme(enabled ? 'contrast' : 'normal'); + dispatch({ + type: ActionTypes.TOGGLE_CONTRAST_MODE, + enabled, + }); + + updateRoute(getState); + }; +} + export function route(urlState) { return (dispatch, getState) => { dispatch({ @@ -668,6 +681,10 @@ export function route(urlState) { state.get('nodeDetails'), dispatch ); + + if (urlState.contrastMode) { + dispatch(setContrastMode(true)); + } }; } diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index 1deab2f04..f443a1953 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -3,7 +3,6 @@ import { connect } from 'react-redux'; import classNames from 'classnames'; import { enterEdge, leaveEdge } from '../actions/app-actions'; -import { isContrastMode } from '../utils/contrast-utils'; import { NODE_BASE_SIZE } from '../constants/styles'; class Edge extends React.Component { @@ -15,9 +14,9 @@ class Edge extends React.Component { } render() { - const { id, path, highlighted, blurred, focused, scale } = this.props; + const { id, path, highlighted, blurred, focused, scale, contrastMode } = this.props; const className = classNames('edge', { highlighted, blurred, focused }); - const thickness = scale * (isContrastMode() ? 0.02 : 0.01) * NODE_BASE_SIZE; + const thickness = scale * (contrastMode ? 0.02 : 0.01) * NODE_BASE_SIZE; // Draws the edge so that its thickness reflects the zoom scale. // Edge shadow is always made 10x thicker than the edge itself. @@ -41,7 +40,13 @@ class Edge extends React.Component { } } +function mapStateToProps(state) { + return { + contrastMode: state.get('contrastMode') + }; +} + export default connect( - null, + mapStateToProps, { enterEdge, leaveEdge } )(Edge); diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js index 9b36c57c8..4f5739620 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -3,12 +3,9 @@ import { omit } from 'lodash'; import { Motion, spring } from 'react-motion'; import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation'; -import { isContrastMode } from '../utils/contrast-utils'; import Node from './node'; -const nodeBlurOpacity = isContrastMode() ? 0.6 : 0.25; - const transformedNode = (otherProps, { x, y, k, opacity }) => ( // NOTE: Controlling blurring and transform from here seems to re-render // faster than adding a CSS class and controlling it from there. @@ -19,7 +16,8 @@ const transformedNode = (otherProps, { x, y, k, opacity }) => ( export default class NodeContainer extends React.PureComponent { render() { - const { dx, dy, isAnimated, scale, blurred } = this.props; + const { dx, dy, isAnimated, scale, blurred, contrastMode } = this.props; + const nodeBlurOpacity = contrastMode ? 0.6 : 0.25; const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'scale', 'blurred'); const opacity = blurred ? nodeBlurOpacity : 1; diff --git a/client/app/scripts/charts/node-networks-overlay.js b/client/app/scripts/charts/node-networks-overlay.js index ae1b45710..0522a83e0 100644 --- a/client/app/scripts/charts/node-networks-overlay.js +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -1,8 +1,9 @@ import React from 'react'; import { scaleBand } from 'd3-scale'; import { List as makeList } from 'immutable'; +import { connect } from 'react-redux'; + import { getNetworkColor } from '../utils/color-utils'; -import { isContrastMode } from '../utils/contrast-utils'; import { NODE_BASE_SIZE } from '../constants/styles'; // Min size is about a quarter of the width, feels about right. @@ -13,7 +14,7 @@ const borderRadius = 0.01; const offset = 0.67; const x = scaleBand(); -function NodeNetworksOverlay({ stack, networks = makeList() }) { +function NodeNetworksOverlay({ stack, networks = makeList(), contrastMode }) { const barWidth = Math.max(1, minBarWidth * networks.size); const yPosition = offset - (barHeight * 0.5); @@ -37,7 +38,7 @@ function NodeNetworksOverlay({ stack, networks = makeList() }) { /> )); - const translateY = stack && isContrastMode() ? 0.15 : 0; + const translateY = stack && contrastMode ? 0.15 : 0; return ( {bars.toJS()} @@ -45,4 +46,10 @@ function NodeNetworksOverlay({ stack, networks = makeList() }) { ); } -export default NodeNetworksOverlay; +function mapStateToProps(state) { + return { + contrastMode: state.get('contrastMode') + }; +} + +export default connect(mapStateToProps)(NodeNetworksOverlay); diff --git a/client/app/scripts/charts/node-shape-stack.js b/client/app/scripts/charts/node-shape-stack.js index 268ab1c04..f49e6cb40 100644 --- a/client/app/scripts/charts/node-shape-stack.js +++ b/client/app/scripts/charts/node-shape-stack.js @@ -1,10 +1,9 @@ import React from 'react'; import { NODE_BASE_SIZE } from '../constants/styles'; -import { isContrastMode } from '../utils/contrast-utils'; export default function NodeShapeStack(props) { - const shift = isContrastMode() ? 0.15 : 0.1; + const shift = props.contrastMode ? 0.15 : 0.1; const highlightScale = [1, 1 + shift]; const dy = NODE_BASE_SIZE * shift; diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 3252a7133..665c03a7f 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -127,7 +127,13 @@ class Node extends React.Component { this.renderStandardLabels(labelClassName, labelMinorClassName, labelOffsetY, mouseEvents)} - + {showingNetworks && } @@ -159,6 +165,7 @@ function mapStateToProps(state) { return { exportingGraph: state.get('exportingGraph'), showingNetworks: state.get('showingNetworks'), + contrastMode: state.get('contrastMode') }; } diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 26e619b5e..770048f0f 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -77,7 +77,7 @@ class NodesChartNodes extends React.Component { } render() { - const { layoutNodes, isAnimated } = this.props; + const { layoutNodes, isAnimated, contrastMode } = this.props; const nodesToRender = layoutNodes.toIndexedSeq() .map(this.nodeHighlightedDecorator) @@ -111,6 +111,7 @@ class NodesChartNodes extends React.Component { dy={node.get('y')} scale={node.get('scale')} isAnimated={isAnimated} + contrastMode={contrastMode} /> ))} @@ -130,6 +131,7 @@ function mapStateToProps(state) { selectedNetwork: state.get('selectedNetwork'), selectedNodeId: state.get('selectedNodeId'), searchQuery: state.get('searchQuery'), + contrastMode: state.get('contrastMode') }; } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 4a68db76b..f9af5671d 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -158,7 +158,6 @@ function mapStateToProps(state) { }; } - export default connect( mapStateToProps )(App); diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index 3633ece00..238c1597f 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -4,19 +4,22 @@ import moment from 'moment'; import Plugins from './plugins'; import { getUpdateBufferSize } from '../utils/update-buffer-utils'; -import { contrastModeUrl, isContrastMode } from '../utils/contrast-utils'; import { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, - clickResumeUpdate, toggleHelp, toggleTroubleshootingMenu } from '../actions/app-actions'; -import { basePathSlash } from '../utils/web-api-utils'; + clickResumeUpdate, toggleHelp, toggleTroubleshootingMenu, setContrastMode } from '../actions/app-actions'; class Footer extends React.Component { - render() { - const { hostname, updatePausedAt, version, versionUpdate } = this.props; - const contrastMode = isContrastMode(); + constructor(props, context) { + super(props, context); + + this.handleContrastClick = this.handleContrastClick.bind(this); + } + handleContrastClick(e) { + e.preventDefault(); + this.props.setContrastMode(!this.props.contrastMode); + } + render() { + const { hostname, updatePausedAt, version, versionUpdate, contrastMode } = this.props; - // link url to switch contrast with current UI state - const otherContrastModeUrl = contrastMode - ? basePathSlash(window.location.pathname) : contrastModeUrl; const otherContrastModeTitle = contrastMode ? 'Switch to normal contrast' : 'Switch to high contrast'; const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, ' @@ -76,7 +79,7 @@ class Footer extends React.Component { title={forceRelayoutTitle}> - + _.includes(c, name)); +} + +module.exports = class ContrastStyleCompiler { + apply(compiler) { + let themeJsChunk; + + compiler.plugin('compilation', (compilation) => { + compilation.plugin('html-webpack-plugin-before-html-processing', (htmlPluginData, callback) => { + themeJsChunk = findAsset(htmlPluginData.assets.js, 'contrast-theme'); + if (!themeJsChunk) { + return callback(null, htmlPluginData); + } + // Find the name of the contrast stylesheet and save it to a window variable. + const { css, publicPath } = htmlPluginData.assets; + const contrast = findAsset(css, 'contrast-theme'); + const normal = findAsset(css, 'style-app'); + // Convert to JSON string so they can be parsed into a window variable + const themes = JSON.stringify({ normal, contrast, publicPath }); + // Append a script to the end of to evaluate before the other scripts are loaded. + const script = ``; + const [head, end] = htmlPluginData.html.split(''); + htmlPluginData.html = head.concat(script).concat('\n ').concat(end); + // Remove the contrast assets so they don't get added to the HTML. + _.remove(htmlPluginData.assets.css, i => i === contrast); + _.remove(htmlPluginData.assets.js, i => i === themeJsChunk); + + return callback(null, htmlPluginData); + }); + }); + + compiler.plugin('emit', (compilation, callback) => { + // Remove the contrast-theme.js file, since it doesn't do anything + const filename = themeJsChunk && themeJsChunk.split('?')[0]; + if (filename) { + delete compilation.assets[filename]; + } + callback(); + }); + } +}; diff --git a/client/app/scripts/contrast-main.js b/client/app/scripts/contrast-main.js deleted file mode 100644 index 75a4e8b9d..000000000 --- a/client/app/scripts/contrast-main.js +++ /dev/null @@ -1,24 +0,0 @@ -import 'babel-polyfill'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Provider } from 'react-redux'; - -import '../styles/contrast.scss'; -import '../images/favicon.ico'; -import configureStore from './stores/configureStore'; - -const store = configureStore(); - -function renderApp() { - const App = require('./components/app').default; - ReactDOM.render(( - - - - ), document.getElementById('app')); -} - -renderApp(); -if (module.hot) { - module.hot.accept('./components/app', renderApp); -} diff --git a/client/app/scripts/contrast-theme.js b/client/app/scripts/contrast-theme.js new file mode 100644 index 000000000..cc1468866 --- /dev/null +++ b/client/app/scripts/contrast-theme.js @@ -0,0 +1 @@ +import '../styles/contrast.scss'; diff --git a/client/app/scripts/index.js b/client/app/scripts/index.js index 7f5362bfe..f4ae21fc8 100644 --- a/client/app/scripts/index.js +++ b/client/app/scripts/index.js @@ -1,3 +1,4 @@ exports.reducer = require('./reducers/root').default; exports.Scope = require('./components/app').default; exports.actions = require('./actions/app-actions'); +exports.ContrastStyleCompiler = require('./contrast-compiler'); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 34171df0e..804cff1ca 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */ import debug from 'debug'; import { size, each, includes } from 'lodash'; import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, @@ -29,6 +30,8 @@ const topologySorter = topology => topology.get('rank'); export const initialState = makeMap({ availableCanvasMetrics: makeList(), + availableNetworks: makeList(), + contrastMode: false, controlPipes: makeOrderedMap(), // pipeId -> controlPipe controlStatus: makeMap(), currentTopology: null, @@ -715,6 +718,10 @@ export function rootReducer(state = initialState, action) { return state; } + case ActionTypes.TOGGLE_CONTRAST_MODE: { + return state.set('contrastMode', action.enabled); + } + default: { return state; } diff --git a/client/app/scripts/utils/contrast-utils.js b/client/app/scripts/utils/contrast-utils.js index e7f4c7a46..2bddd6227 100644 --- a/client/app/scripts/utils/contrast-utils.js +++ b/client/app/scripts/utils/contrast-utils.js @@ -1,7 +1,38 @@ -export const contrastModeUrl = 'contrast.html'; +/* eslint-disable no-underscore-dangle */ +import last from 'lodash/last'; +/** + * Change the Scope UI theme from normal to high-contrast. + * This will inject a stylesheet into and override the styles. + * + * A window-level variable is written to the .html page during the build process that contains + * the filename (and content hash) needed to download the file. + */ -const contrastMode = window.location.pathname.indexOf(contrastModeUrl) > -1; - -export function isContrastMode() { - return contrastMode; +function getFilename(href) { + return last(href.split('/')); +} + +export function loadTheme(theme = 'normal') { + if (window.__WEAVE_SCOPE_THEMES) { + // Load the pre-built stylesheet. + const stylesheet = window.__WEAVE_SCOPE_THEMES[theme]; + const head = document.querySelector('head'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `${window.__WEAVE_SCOPE_THEMES.publicPath}${stylesheet}`; + link.onload = () => { + // Remove the old stylesheet to prevent weird overlapping styling issues + const oldTheme = theme === 'normal' ? 'contrast' : 'normal'; + const links = document.querySelectorAll('head link'); + for (let i = 0; i < links.length; i += 1) { + const l = links[i]; + if (getFilename(l.href) === getFilename(window.__WEAVE_SCOPE_THEMES[oldTheme])) { + head.removeChild(l); + break; + } + } + }; + + head.appendChild(link); + } } diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index ffc4d85f1..6bc7d330c 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -19,11 +19,18 @@ function encodeURL(url) { .replace(new RegExp(SLASH, 'g'), SLASH_REPLACEMENT); } -function decodeURL(url) { +export function decodeURL(url) { return decodeURIComponent(url.replace(new RegExp(SLASH_REPLACEMENT, 'g'), SLASH)) .replace(new RegExp(PERCENT_REPLACEMENT, 'g'), PERCENT); } +export function parseHashState(hash = window.location.hash) { + const urlStateString = hash + .replace('#!/state/', '') + .replace('#!/', '') || '{}'; + return JSON.parse(decodeURL(urlStateString)); +} + function shouldReplaceState(prevState, nextState) { // Opening a new terminal while an existing one is open. const terminalToTerminal = (prevState.controlPipe && nextState.controlPipe); @@ -50,7 +57,8 @@ export function getUrlState(state) { gridSortedBy: state.get('gridSortedBy'), gridSortedDesc: state.get('gridSortedDesc'), topologyId: state.get('currentTopologyId'), - topologyOptions: state.get('topologyOptions').toJS() // all options + topologyOptions: state.get('topologyOptions').toJS(), // all options, + contrastMode: state.get('contrastMode') }; if (state.get('showingNetworks')) { @@ -67,10 +75,7 @@ export function updateRoute(getState) { const state = getUrlState(getState()); const stateUrl = encodeURL(JSON.stringify(state)); const dispatch = false; - const urlStateString = window.location.hash - .replace('#!/state/', '') - .replace('#!/', '') || '{}'; - const prevState = JSON.parse(decodeURL(urlStateString)); + const prevState = parseHashState(); // back up state in storage as well storageSet(STORAGE_STATE_KEY, stateUrl); diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 1994c44aa..8e51cb0ea 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -1,8 +1,9 @@ const webpack = require('webpack'); const autoprefixer = require('autoprefixer'); const path = require('path'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); - +const ContrastStyleCompiler = require('./app/scripts/contrast-compiler'); /** * This is the Webpack configuration file for local development. * It contains local-specific configuration which includes: @@ -24,12 +25,12 @@ module.exports = { './app/scripts/main', 'webpack-hot-middleware/client' ], - 'dev-app': [ - './app/scripts/main.dev', + 'contrast-theme': [ + './app/scripts/contrast-theme', 'webpack-hot-middleware/client' ], - 'contrast-app': [ - './app/scripts/contrast-main', + 'dev-app': [ + './app/scripts/main.dev', 'webpack-hot-middleware/client' ], 'terminal-app': [ @@ -45,7 +46,7 @@ module.exports = { // Used by Webpack Dev Middleware output: { publicPath: '', - path: '/', + path: path.join(__dirname, 'build'), filename: '[name].js' }, @@ -56,26 +57,23 @@ module.exports = { new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]), - new HtmlWebpackPlugin({ - chunks: ['vendors', 'contrast-app'], - template: 'app/html/index.html', - filename: 'contrast.html' - }), + new ExtractTextPlugin('style-[name]-[chunkhash].css'), new HtmlWebpackPlugin({ chunks: ['vendors', 'terminal-app'], template: 'app/html/index.html', filename: 'terminal.html' }), new HtmlWebpackPlugin({ - chunks: ['vendors', 'dev-app'], + chunks: ['vendors', 'dev-app', 'contrast-theme'], template: 'app/html/index.html', filename: 'dev.html' }), new HtmlWebpackPlugin({ - chunks: ['vendors', 'app'], + chunks: ['vendors', 'app', 'contrast-theme'], template: 'app/html/index.html', filename: 'index.html' - }) + }), + new ContrastStyleCompiler() ], // Transform source code using Babel and React Hot Loader @@ -114,7 +112,7 @@ module.exports = { }, { test: /\.(scss|css)$/, - loader: 'style-loader!css-loader!postcss-loader!sass-loader' + loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss!sass-loader') } ] }, diff --git a/client/webpack.production.config.js b/client/webpack.production.config.js index 5dd6c2181..17c467f40 100644 --- a/client/webpack.production.config.js +++ b/client/webpack.production.config.js @@ -5,6 +5,7 @@ const path = require('path'); const CleanWebpackPlugin = require('clean-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const ContrastStyleCompiler = require('./app/scripts/contrast-compiler'); const GLOBALS = { 'process.env': {NODE_ENV: '"production"'} @@ -31,7 +32,7 @@ module.exports = { entry: { app: './app/scripts/main', - 'contrast-app': './app/scripts/contrast-main', + 'contrast-theme': ['./app/scripts/contrast-theme'], 'terminal-app': './app/scripts/terminal-main', // keep only some in here, to make vendors and app bundles roughly same size vendors: ['babel-polyfill', 'classnames', 'immutable', @@ -112,12 +113,6 @@ module.exports = { } }), new ExtractTextPlugin('style-[name]-[chunkhash].css'), - new HtmlWebpackPlugin({ - hash: true, - chunks: ['vendors', 'contrast-app'], - template: 'app/html/index.html', - filename: 'contrast.html' - }), new HtmlWebpackPlugin({ hash: true, chunks: ['vendors', 'terminal-app'], @@ -126,10 +121,11 @@ module.exports = { }), new HtmlWebpackPlugin({ hash: true, - chunks: ['vendors', 'app'], + chunks: ['vendors', 'app', 'contrast-theme'], template: 'app/html/index.html', filename: 'index.html' - }) + }), + new ContrastStyleCompiler() ], sassLoader: { includePaths: [