diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 713ff60a6..31fde7260 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -2,6 +2,7 @@ import debug from 'debug'; import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; +import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; @@ -57,6 +58,10 @@ export function clickCloseTerminal(pipeId, closePipe) { updateRoute(); } +export function clickDownloadGraph() { + saveGraph(); +} + export function clickForceRelayout() { AppDispatcher.dispatch({ type: ActionTypes.CLICK_FORCE_RELAYOUT diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 4f16b1879..32c9d4eff 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -225,7 +225,7 @@ export default class NodesChart extends React.Component {
{errorEmpty} {errorMaxNodesExceeded} - + {edgeElements} diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 9a796b5a1..80bb4f3c7 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -7,7 +7,7 @@ import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies, basePathSlash } from '../utils/web-api-utils'; -import { clickForceRelayout, hitEsc } from '../actions/app-actions'; +import { clickDownloadGraph, clickForceRelayout, hitEsc } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; import EmbeddedTerminal from './embedded-terminal'; @@ -124,6 +124,9 @@ export default class App extends React.Component { + + + diff --git a/client/app/scripts/utils/file-utils.js b/client/app/scripts/utils/file-utils.js new file mode 100644 index 000000000..2533e6f81 --- /dev/null +++ b/client/app/scripts/utils/file-utils.js @@ -0,0 +1,134 @@ +// adapted from https://github.com/NYTimes/svg-crowbar +import _ from 'lodash'; + +const doctype = ''; +const prefix = { + xmlns: 'http://www.w3.org/2000/xmlns/', + xlink: 'http://www.w3.org/1999/xlink', + svg: 'http://www.w3.org/2000/svg' +}; + +function setInlineStyles(svg, emptySvgDeclarationComputed) { + function explicitlySetStyle(element) { + const cSSStyleDeclarationComputed = getComputedStyle(element); + let value; + let computedStyleStr = ''; + _.each(cSSStyleDeclarationComputed, key => { + value = cSSStyleDeclarationComputed.getPropertyValue(key); + if (value !== emptySvgDeclarationComputed.getPropertyValue(key)) { + computedStyleStr += key + ':' + value + ';'; + } + }); + element.setAttribute('style', computedStyleStr); + } + + function traverse(obj) { + const tree = []; + + function visit(node) { + if (node && node.hasChildNodes()) { + let child = node.firstChild; + while (child) { + if (child.nodeType === 1 && child.nodeName !== 'SCRIPT') { + tree.push(child); + visit(child); + } + child = child.nextSibling; + } + } + } + + tree.push(obj); + visit(obj); + return tree; + } + + // hardcode computed css styles inside svg + const allElements = traverse(svg); + let i = allElements.length; + while (i--) { + explicitlySetStyle(allElements[i]); + } + // set font + svg.setAttribute('style', 'font-family: "Roboto", sans-serif;'); +} + +function download(source) { + let filename = 'untitled'; + + if (source.id) { + filename = source.id; + } else if (source.class) { + filename = source.class; + } else if (window.document.title) { + filename = window.document.title.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + } + + const url = window.URL.createObjectURL(new Blob(source.source, + {'type': 'text\/xml'} + )); + + const a = document.createElement('a'); + document.body.appendChild(a); + a.setAttribute('class', 'svg-crowbar'); + a.setAttribute('download', filename + '.svg'); + a.setAttribute('href', url); + a.style.display = 'none'; + a.click(); + + setTimeout(function() { + window.URL.revokeObjectURL(url); + }, 10); +} + +function getSVG(doc, emptySvgDeclarationComputed) { + const svg = document.getElementById('nodes-chart-canvas'); + + svg.setAttribute('version', '1.1'); + + // removing attributes so they aren't doubled up + svg.removeAttribute('xmlns'); + svg.removeAttribute('xlink'); + + // These are needed for the svg + if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns')) { + svg.setAttributeNS(prefix.xmlns, 'xmlns', prefix.svg); + } + + if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns:xlink')) { + svg.setAttributeNS(prefix.xmlns, 'xmlns:xlink', prefix.xlink); + } + + setInlineStyles(svg, emptySvgDeclarationComputed); + + const source = (new XMLSerializer()).serializeToString(svg); + + return { + class: svg.getAttribute('class'), + id: svg.getAttribute('id'), + childElementCount: svg.childElementCount, + source: [doctype + source] + }; +} + +function cleanup() { + const crowbarElements = document.querySelectorAll('.svg-crowbar'); + + [].forEach.call(crowbarElements, function(el) { + el.parentNode.removeChild(el); + }); +} + +export function saveGraph() { + window.URL = (window.URL || window.webkitURL); + + // add empty svg element + const emptySvg = window.document.createElementNS(prefix.svg, 'svg'); + window.document.body.appendChild(emptySvg); + const emptySvgDeclarationComputed = getComputedStyle(emptySvg); + + const svgSource = getSVG(document, emptySvgDeclarationComputed); + download(svgSource); + + cleanup(); +} diff --git a/client/package.json b/client/package.json index 824502f96..05a8b9756 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "license": "Apache-2.0", "private": true, "dependencies": { + "browser-filesaver": "^1.1.0", "classnames": "^2.2.1", "d3": "~3.5.5", "dagre": "0.7.4",