diff --git a/package.json b/package.json index 0ddd68e..d2216cc 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "plugins": [ "transform-runtime", "transform-class-properties", + "transform-object-rest-spread", "syntax-dynamic-import" ] }, @@ -120,6 +121,7 @@ "babel-loader": "^7.1.2", "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-env": "^1.6.1", "babel-preset-react": "^6.24.1", @@ -142,6 +144,7 @@ "i18next": "^10.3.0", "i18next-browser-languagedetector": "^2.1.0", "identity-obj-proxy": "^3.0.0", + "immutable": "^3.8.2", "jest": "^22.2.2", "npm-run-all": "^4.1.2", "postcss-cssnext": "^3.1.0", diff --git a/src/components/App/__snapshots__/test.js.snap b/src/components/App/__snapshots__/test.js.snap index cc58273..c2aa245 100644 --- a/src/components/App/__snapshots__/test.js.snap +++ b/src/components/App/__snapshots__/test.js.snap @@ -44,5 +44,10 @@ exports[`App rendering 1`] = ` Sample warning message

+
+ Testing image +
`; diff --git a/src/components/App/index.js b/src/components/App/index.js index 9fda3e8..2d76b43 100644 --- a/src/components/App/index.js +++ b/src/components/App/index.js @@ -1,32 +1,46 @@ import React from 'react'; +import style from './style.css'; + import Form from 'components/Form'; import Message from 'components/Message'; +import renderImage from 'components/SVG'; +import { syntaxes, demoImage } from 'devel'; -const syntaxes = { - js: 'JavaScript', - pcre: 'PCRE' -}; +class App extends React.PureComponent { + state = { + syntaxes, + image: demoImage, + downloadUrls: [ + { url: '#svg', filename: 'image.svg', type: 'image/svg+xml', label: 'Download SVG' }, + { url: '#png', filename: 'image.png', type: 'image/png', label: 'Download PNG' } + ] + } -const downloadUrls = [ - { url: '#svg', filename: 'image.svg', type: 'image/svg+xml', label: 'Download SVG' }, - { url: '#png', filename: 'image.png', type: 'image/png', label: 'Download PNG' } -]; + handleSubmit = ({expr, syntax}) => { + console.log(syntax, expr); // eslint-disable-line no-console + } -const handleSubmit = ({ expr, syntax}) => console.log(syntax, expr); // eslint-disable-line no-console + render() { + const { downloadUrls, syntaxes, image } = this.state; -const App = () => -
- -

Sample error message

-
- -

Sample warning message

-
-; + return + + +

Sample error message

+
+ +

Sample warning message

+
+
+ { renderImage(image) } +
+
; + } +} export default App; diff --git a/src/components/App/style.css b/src/components/App/style.css new file mode 100644 index 0000000..cecae30 --- /dev/null +++ b/src/components/App/style.css @@ -0,0 +1,17 @@ +@import url('../../globals.css'); + +.render { + display: flex; + justify-content: center; + width: 100%; + background: var(--color-white); + box-sizing: border-box; + overflow: auto; + margin: var(--spacing-margin) 0; + + & svg { + display: block; + transform: scaleZ(1); /* Move to separate render layer in Chrome */ + flex-shrink: 0; + } +} diff --git a/src/components/App/test.js b/src/components/App/test.js index 02dbd93..0ed05a8 100644 --- a/src/components/App/test.js +++ b/src/components/App/test.js @@ -1,10 +1,14 @@ +jest.mock('components/SVG'); + import React from 'react'; import { shallow } from 'enzyme'; import App from 'components/App'; +import renderImage from 'components/SVG'; describe('App', () => { test('rendering', () => { + renderImage.mockReturnValue('Testing image'); const component = shallow( ); diff --git a/src/components/SVG/Base.js b/src/components/SVG/Base.js new file mode 100644 index 0000000..b7c1f91 --- /dev/null +++ b/src/components/SVG/Base.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { Map } from 'immutable'; + +class Base extends React.PureComponent { + _currentBBox() { + return this.tempBBox ? this.tempBBox : (this.state || {}).bbox; + } + + setBBox(box, recalculate = {}) { + let bbox = this._currentBBox() || Map({ width: 0, height: 0}); + + bbox = bbox.merge(box); + + if (!bbox.has('axisY') || recalculate.axisY) { + bbox = bbox.set('axisY', bbox.get('height') / 2); + } + + if (!bbox.has('axisX1') || recalculate.axisX1) { + bbox = bbox.set('axisX1', 0); + } + + if (!bbox.has('axisX2') || recalculate.axisX2) { + bbox = bbox.set('axisX2', bbox.get('width')); + } + + this.tempBBox = bbox; // Want to get the updated bbox while setState is pending + this.setState({ bbox }, () => { + this.tempBBox = null; + }); + } + + getBBox() { + const bbox = this._currentBBox() || Map(); + return { + width: 0, + height: 0, + axisY: 0, + axisX1: 0, + axisX2: 0, + ...bbox.toJS() + }; + } + + async doPreReflow() { + const components = this.preReflow(); + + // No child components + if (components === undefined) { + return true; + } + + // List of child components + if (components.map) { + const componentsReflowed = await Promise.all(components.map(c => c.doReflow())); + + return componentsReflowed.reduce((memo, value) => memo || value, false); + } + + // One child component + return components.doReflow(); + } + + async doReflow() { + const oldBBox = this._currentBBox(); + const shouldReflow = await this.doPreReflow(); + + if (shouldReflow) { + this.reflow(); + } + + return this._currentBBox() !== oldBBox; + } + + preReflow() { + // Implemented in subclass, return array of components to reflow before this + } + + reflow() { + // Implemented in subclasses + } +} + +export default Base; diff --git a/src/components/SVG/Box.js b/src/components/SVG/Box.js new file mode 100644 index 0000000..a3bd49f --- /dev/null +++ b/src/components/SVG/Box.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Base from './Base'; +import style from './style'; + +/** @extends React.PureComponent */ +class Box extends Base { + static defaultProps = { + padding: 5 + } + + preReflow() { + return this.contained; + } + + reflow() { + return new Promise(resolve => { + const { padding, useAnchors } = this.props; + const box = this.contained.getBBox(); + const labelBox = this.label ? this.label.getBBox() : { width: 0, height: 0}; + + this.setBBox({ + width: Math.max(box.width + 2 * padding, labelBox.width), + height: box.height + 2 * padding + labelBox.height, + axisY: (useAnchors ? box.axisY : box.height / 2) + padding + labelBox.height, + axisX1: useAnchors ? box.axisX1 + padding : 0, + axisX2: useAnchors ? box.axisX2 + padding : box.width + 2 * padding + }); + + this.setState({ + width: this.getBBox().width, + height: box.height + 2 * padding, + contentTransform: `translate(${ padding } ${ padding + labelBox.height })`, + rectTransform: `translate(0 ${ labelBox.height })`, + labelTransform: `translate(0 ${ labelBox.height })` + }, resolve); + }); + } + + containedRef = contained => this.contained = contained + + labelRef = label => this.label = label + + render() { + const { style: propStyle, radius, label, children } = this.props; + const { width, height, labelTransform, rectTransform, contentTransform } = this.state || {}; + + const rectProps = { + style: propStyle, + width, + height, + rx: radius, + ry: radius, + transform: rectTransform + }; + const textProps = { + transform: labelTransform, + style: style.infoText, + ref: this.labelRef + }; + + return + + { label && { label } } + + { React.cloneElement(React.Children.only(children), { + ref: this.containedRef + }) } + + ; + } +} + +Box.propTypes = { + padding: PropTypes.number, + useAnchors: PropTypes.bool, + style: PropTypes.object, + radius: PropTypes.number, + label: PropTypes.string, + children: PropTypes.node +}; + +export default Box; diff --git a/src/components/SVG/HorizontalLayout.js b/src/components/SVG/HorizontalLayout.js new file mode 100644 index 0000000..1d89bc2 --- /dev/null +++ b/src/components/SVG/HorizontalLayout.js @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { List } from 'immutable'; + +import Base from './Base'; +import style from './style'; +import Path from './path'; + +/** @extends React.PureComponent */ +class HorizontalLayout extends Base { + static defaultProps = { + withConnectors: false, + spacing: 10 + } + + constructor(props) { + super(props); + this.state = { + childTransforms: List() + }; + } + + updateChildTransforms(childBoxes) { + return childBoxes.reduce((transforms, box, i) => ( + transforms.set(i, `translate(${ box.offsetX } ${ box.offsetY })`) + ), this.state.childTransforms); + } + + updateConnectorPaths(childBoxes) { + let last = childBoxes[0]; + + return childBoxes.slice(1).reduce((path, box) => { + try { + return path + .moveTo({ x: last.offsetX + last.axisX2, y: this.getBBox().axisY }) + .lineTo({ x: box.offsetX + box.axisX1 }); + } + finally { + last = box; + } + }, new Path()).toString(); + } + + preReflow() { + return this.children; + } + + reflow() { + if (this.children.length === 0) { + return; + } + + return new Promise(resolve => { + const { spacing, withConnectors } = this.props; + + const childBoxes = this.children.map(child => child.getBBox()); + const verticalCenter = childBoxes.reduce((center, box) => Math.max(center, box.axisY), 0); + const width = childBoxes.reduce((width, box) => width + box.width, 0) + (childBoxes.length - 1) * spacing; + const height = childBoxes.reduce((ascHeight, box) => Math.max(ascHeight, box.axisY), 0) + + childBoxes.reduce((decHeight, box) => Math.max(decHeight, box.height - box.axisY), 0); + this.setBBox({ width, height, axisY: verticalCenter }, { axisX1: true, axisX2: true }); + + let offset = 0; + childBoxes.forEach(box => { + box.offsetX = offset; + box.offsetY = this.getBBox().axisY - box.axisY; + offset += box.width + spacing; + }); + + this.setState({ + childTransforms: this.updateChildTransforms(childBoxes), + connectorPaths: withConnectors ? this.updateConnectorPaths(childBoxes) : '' + }, resolve); + }); + } + + childRef = i => child => this.children[i] = child + + render() { + const { children } = this.props; + const { childTransforms, connectorPaths } = this.state; + + this.children = []; + + return + + { React.Children.map(children, (child, i) => ( + + { React.cloneElement(child, { + ref: this.childRef(i) + }) } + + ))} + ; + } +} + +HorizontalLayout.propTypes = { + spacing: PropTypes.number, + withConnectors: PropTypes.bool, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +}; + +export default HorizontalLayout; diff --git a/src/components/SVG/Image.js b/src/components/SVG/Image.js new file mode 100644 index 0000000..58af442 --- /dev/null +++ b/src/components/SVG/Image.js @@ -0,0 +1,103 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Base from './Base'; +import style from './style'; + +const namespaceProps = { + 'xmlns': 'http://www.w3.org/2000/svg', + 'xmlns:cc': 'http://creativecommons.org/ns#', + 'xmlns:rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' +}; +const metadata = ` + + + + + + + +`; + +/** @extends React.PureComponent */ +class Image extends Base { + static defaultProps = { + padding: 10 + } + + publishedMarkup = '' + + state = { + width: 0, + height: 0 + } + + componentDidMount() { + this.doReflow().then(() => this.publishMarkup()); + } + + componentDidUpdate() { + this.doReflow().then(() => this.publishMarkup()); + } + + publishMarkup() { + const { onRender } = this.props; + const markup = this.svg.outerHTML; + + if (onRender && this.publishedMarkup !== markup) { + this.publishedMarkup = markup; + onRender(this.svg.outerHTML); + } + } + + preReflow() { + return this.contained; + } + + reflow() { + return new Promise(resolve => { + const { padding } = this.props; + const box = this.contained.getBBox(); + + this.setState({ + width: Math.round(box.width + 2 * padding), + height: Math.round(box.height + 2 * padding) + }, resolve); + }); + } + + containedRef = contained => this.contained = contained + + svgRef = svg => this.svg = svg + + render() { + const { width, height } = this.state; + const { padding, children } = this.props; + + const svgProps = { + width, + height, + viewBox: [0, 0, width, height].join(' '), + style: style.image, + ref: this.svgRef, + ...namespaceProps + }; + + return + + + { React.cloneElement(React.Children.only(children), { + ref: this.containedRef + }) } + + ; + } +} + +Image.propTypes = { + onRender: PropTypes.func, + padding: PropTypes.number, + children: PropTypes.node +}; + +export default Image; diff --git a/src/components/SVG/Loop.js b/src/components/SVG/Loop.js new file mode 100644 index 0000000..fc82b2a --- /dev/null +++ b/src/components/SVG/Loop.js @@ -0,0 +1,144 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Base from './Base'; +import style from './style'; +import Path from './path'; + +const skipPath = (box, greedy) => { + const vert = Math.max(0, box.axisY - 10); + const horiz = box.width - 10; + + let path = new Path({ relative: true }); + + if (!greedy) { + path + .moveTo({ x: 10, y: box.axisY + box.offsetY - 15, relative: false }) + .lineTo({ x: 5, y: 5 }) + .moveTo({ x: -5, y: -5 }) + .lineTo({ x: -5, y: 5 }); + } + + return path + .moveTo({ x: 0, y: box.axisY + box.offsetY, relative: false }) + .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 }) + .lineTo({ y: -vert }) + .quadraticCurveTo({ cx: 0, cy: -10, x: 10, y: -10 }) + .lineTo({ x: horiz }) + .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: 10 }) + .lineTo({ y: vert }) + .quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 }); +}; + +const repeatPath = (box, greedy) => { + const vert = box.height - box.axisY - 10; + + let path = new Path({ relative: true }); + + if (greedy) { + path + .moveTo({ x: box.offsetX + box.width + 10, y: box.axisY + box.offsetY + 15, relative: false }) + .lineTo({ x: 5, y: -5 }) + .moveTo({ x: -5, y: 5 }) + .lineTo({ x: -5, y: -5 }); + } + + return path + .moveTo({ x: box.offsetX, y: box.axisY + box.offsetY, relative: false }) + .quadraticCurveTo({ cx: -10, cy: 0, x: -10, y: 10 }) + .lineTo({ y: vert }) + .quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 }) + .lineTo({ x: box.width }) + .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 }) + .lineTo({ y: -vert }) + .quadraticCurveTo({ cx: 0, cy: -10, x: -10, y: -10 }); +}; + +/** @extends React.PureComponent */ +class Loop extends Base { + get contentOffset() { + const { skip, repeat } = this.props; + + if (skip) { + return { x: 15, y: 10 }; + } else if (repeat) { + return { x: 10, y: 0 }; + } else { + return { x: 0, y: 0 }; + } + } + + preReflow() { + return this.contained; + } + + reflow() { + return new Promise(resolve => { + const { skip, repeat, greedy } = this.props; + const box = this.contained.getBBox(); + const labelBox = this.label ? this.label.getBBox() : { width: 0, height: 0 }; + + let height = box.height + labelBox.height; + if (skip) { + height += 10; + } + if (repeat) { + height += 10; + } + + this.setBBox({ + width: box.width + this.contentOffset.x * 2, + height, + axisY: box.axisY + this.contentOffset.y, + axisX1: box.axisX1 + this.contentOffset.x, + axisX2: box.axisX2 + this.contentOffset.x + }); + + box.offsetX = this.contentOffset.x; + box.offsetY = this.contentOffset.y; + + this.setState({ + labelTransform: `translate(${ this.getBBox().width - labelBox.width - 10 } ${ this.getBBox().height + 2 })`, + loopPaths: [ + skip && skipPath(box, greedy), + repeat && repeatPath(box, greedy) + ].filter(Boolean).join('') + }, resolve); + }); + } + + containedRef = contained => this.contained = contained + + labelRef = label => this.label = label + + render() { + const { label, children } = this.props; + const { loopPaths, labelTransform } = this.state || {}; + + const textProps = { + transform: labelTransform, + style: style.infoText, + ref: this.labelRef + }; + + return + + { label && { label } } + + { React.cloneElement(React.Children.only(children), { + ref: this.containedRef + }) } + + ; + } +} + +Loop.propTypes = { + skip: PropTypes.bool, + repeat: PropTypes.bool, + greedy: PropTypes.bool, + label: PropTypes.string, + children: PropTypes.node.isRequired +}; + +export default Loop; diff --git a/src/components/SVG/Pin.js b/src/components/SVG/Pin.js new file mode 100644 index 0000000..8f7ab80 --- /dev/null +++ b/src/components/SVG/Pin.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Base from './Base'; +import style from './style'; + +/** @extends React.PureComponent */ +class Pin extends Base { + static defaultProps = { + radius: 5 + } + + reflow() { + return new Promise(resolve => { + const { radius } = this.props; + + this.setBBox({ + width: radius * 2, + height: radius * 2 + }); + + this.setState({ + transform: `translate(${ radius } ${ radius })` + }, resolve); + }); + } + + render() { + const { radius } = this.props; + const { transform } = this.state || {}; + + const circleProps = { + r: radius, + style: style.pin, + transform + }; + + return ; + } +} + +Pin.propTypes = { + radius: PropTypes.number +}; + +export default Pin; diff --git a/src/components/SVG/Text.js b/src/components/SVG/Text.js new file mode 100644 index 0000000..516014a --- /dev/null +++ b/src/components/SVG/Text.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Base from './Base'; +import style from './style'; + +/** @extends React.PureComponent */ +class Text extends Base { + reflow() { + return new Promise(resolve => { + const box = this.text.getBBox(); + + this.setBBox({ + width: box.width, + height: box.height + }); + + this.setState({ + transform: `translate(${-box.x} ${-box.y})` + }, resolve); + }); + } + + textRef = text => this.text = text + + render() { + const { transform } = this.state || {}; + const { style: styleProp, children } = this.props; + + const textProps = { + style: { ...style.text, ...styleProp }, + transform, + ref: this.textRef + }; + + return + { children } + ; + } +} + +Text.propTypes = { + style: PropTypes.object, + transform: PropTypes.string, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +}; + +export default Text; diff --git a/src/components/SVG/VerticalLayout.js b/src/components/SVG/VerticalLayout.js new file mode 100644 index 0000000..f092fbd --- /dev/null +++ b/src/components/SVG/VerticalLayout.js @@ -0,0 +1,149 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { List } from 'immutable'; + +import Base from './Base'; +import style from './style'; +import Path from './path'; + +const connectorMargin = 20; + +/** @extends React.PureComponent */ +class VerticalLayout extends Base { + static defaultProps = { + withConnectors: false, + spacing: 10 + } + + constructor(props) { + super(props); + this.state = { + childTransforms: List() + }; + } + + updateChildTransforms(childBoxes) { + return childBoxes.reduce((transforms, box, i) => ( + transforms.set(i, `translate(${ box.offsetX } ${ box.offsetY })`) + ), this.state.childTransforms); + } + + makeCurve(box) { + const thisBox = this.getBBox(); + const distance = Math.abs(box.offsetY + box.axisY - thisBox.axisY); + + if (distance >= 15) { + const curve = (box.axisY + box.offsetY > thisBox.axisY) ? 10 : -10; + + return new Path() + // Left + .moveTo({ x: 10, y: box.axisY + box.offsetY - curve }) + .quadraticCurveTo({ cx: 0, cy: curve, x: 10, y: curve, relative: true }) + .lineTo({ x: box.offsetX + box.axisX1 }) + // Right + .moveTo({ x: thisBox.width - 10, y: box.axisY + box.offsetY - curve }) + .quadraticCurveTo({ cx: 0, cy: curve, x: -10, y: curve, relative: true }) + .lineTo({ x: box.offsetX + box.axisX2 }); + } else { + const anchor = box.offsetY + box.axisY - thisBox.axisY; + + return new Path() + // Left + .moveTo({ x: 0, y: thisBox.axisY }) + .cubicCurveTo({ cx1: 15, cy1: 0, cx2: 10, cy2: anchor, x: 20, y: anchor, relative: true }) + .lineTo({ x: box.offsetX + box.axisX1 }) + // Right + .moveTo({ x: thisBox.width, y: thisBox.axisY }) + .cubicCurveTo({ cx1: -15, cy1: 0, cx2: -10, cy2: anchor, x: -20, y: anchor, relative: true }) + .lineTo({ x: box.offsetX + box.axisX2 }); + } + } + + makeSide(box) { + const thisBox = this.getBBox(); + const distance = Math.abs(box.offsetY + box.axisY - thisBox.axisY); + + if (distance >= 15) { + const shift = (box.offsetY + box.axisY > thisBox.axisY) ? 10 : -10; + const edge = box.offsetY + box.axisY - shift; + + return new Path() + // Left + .moveTo({ x: 0, y: thisBox.axisY }) + .quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: shift, relative: true }) + .lineTo({ y: edge }) + // Right + .moveTo({ x: thisBox.width, y: thisBox.axisY }) + .quadraticCurveTo({ cx: -10, cy: 0, x: -10, y: shift, relative: true }) + .lineTo({ y: edge }); + } + } + + preReflow() { + return this.children; + } + + reflow() { + if (this.children.length === 0) { + return Promise.resolve(); + } + + return new Promise(resolve => { + const { spacing, withConnectors } = this.props; + + const childBoxes = this.children.map(child => child.getBBox()); + const horizontalCenter = childBoxes.reduce((center, box) => Math.max(center, box.width / 2), 0); + const margin = withConnectors ? connectorMargin : 0; + const width = childBoxes.reduce((width, box) => Math.max(width, box.width), 0) + 2 * margin; + const height = childBoxes.reduce((height, box) => height + box.height, 0) + (childBoxes.length - 1) * spacing; + this.setBBox({ width, height }, { axisY: true, axisX1: true, axisX2: true }); + + let offset = 0; + childBoxes.forEach(box => { + box.offsetX = horizontalCenter - box.width / 2 + margin; + box.offsetY = offset; + offset += spacing + box.height; + }); + + this.setState({ + childTransforms: this.updateChildTransforms(childBoxes), + connectorPaths: withConnectors ? [ + ...childBoxes.map(box => this.makeCurve(box)), + this.makeSide(childBoxes[0]), + this.makeSide(childBoxes[childBoxes.length - 1]) + ].join('') : '' + }, resolve); + }); + } + + childRef = i => child => this.children[i] = child + + render() { + const { children } = this.props; + const { childTransforms, connectorPaths } = this.state; + + this.children = []; + + return + + { React.Children.map(children, (child, i) => ( + + { React.cloneElement(child, { + ref: this.childRef(i) + }) } + + )) } + ; + } +} + +VerticalLayout.propTypes = { + spacing: PropTypes.number, + withConnectors: PropTypes.bool, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +}; + +export default VerticalLayout; diff --git a/src/components/SVG/index.js b/src/components/SVG/index.js new file mode 100644 index 0000000..324a557 --- /dev/null +++ b/src/components/SVG/index.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import Box from './Box'; +import HorizontalLayout from './HorizontalLayout'; +import Image from './Image'; +import Loop from './Loop'; +import Pin from './Pin'; +import Text from './Text'; +import VerticalLayout from './VerticalLayout'; + +const nodeTypes = { + Box, + HorizontalLayout, + Image, + Loop, + Pin, + Text, + VerticalLayout +}; + +const renderChildren = children => { + if (!children || children.length === 0) { + return; + } + + return children.length === 1 ? + renderImage(children[0]) : + children.map(renderImage); +}; + +const renderImage = (node, key) => { + if (typeof node === 'string') { + return node; + } + + const { type, props, children } = node; + + return React.createElement(nodeTypes[type], { key, ...props }, renderChildren(children)); +}; + +export default renderImage; diff --git a/src/components/SVG/path.js b/src/components/SVG/path.js new file mode 100644 index 0000000..7cde0e5 --- /dev/null +++ b/src/components/SVG/path.js @@ -0,0 +1,103 @@ +class Path { + constructor({ relative } = {}) { + this.relative = relative || false; + this.currentPosition = { x: 0, y: 0 }; + this.lastStartPosition = { x: 0, y: 0 }; + this.pathParts = []; + } + + moveTo({ x, y, relative }) { + relative = relative === undefined ? this.relative : relative; + const command = relative ? 'm' : 'M'; + + if (relative) { + this.currentPosition = { + x: x + this.currentPosition.x, + y: y + this.currentPosition.y + }; + } else { + this.currentPosition = { x, y }; + } + + this.pathParts.push(`${command}${x},${y}`); + this.lastStartPosition = { ...this.currentPosition }; + + return this; + } + + lineTo({ x, y, relative }) { + relative = relative === undefined ? this.relative : relative; + if (x !== undefined && (y === undefined || y === this.currentPosition.y)) { + const command = relative ? 'h' : 'H'; + this.pathParts.push(`${command}${x}`); + this.currentPosition.x = relative ? this.currentPosition.x + x : x; + } else if (y !== undefined && (x === undefined || x === this.currentPosition.x)) { + const command = relative ? 'v' : 'V'; + this.pathParts.push(`${command}${y}`); + this.currentPosition.y = relative ? this.currentPosition.y + y : y; + } else { + const command = relative ? 'l' : 'L'; + this.pathParts.push(`${command}${x},${y}`); + this.currentPosition.x = relative ? this.currentPosition.x + x : x; + this.currentPosition.y = relative ? this.currentPosition.y + y : y; + } + + return this; + } + + closePath() { + this.pathParts.push('Z'); + this.currentPosition = { ...this.lastStartPosition }; + return this; + } + + cubicCurveTo({ cx1, cy1, cx2, cy2, x, y, relative }) { + relative = relative === undefined ? this.relative : relative; + if (cx1 === undefined || cy1 === undefined) { + const command = relative ? 's' : 'S'; + this.pathParts.push(`${command}${cx2},${cy2} ${x},${y}`); + } else { + const command = relative ? 'c' : 'C'; + this.pathParts.push(`${command}${cx1},${cy1} ${cx2},${cy2} ${x},${y}`); + } + + this.currentPosition.x = relative ? this.currentPosition.x + x : x; + this.currentPosition.y = relative ? this.currentPosition.y + y : y; + + return this; + } + + quadraticCurveTo({ cx, cy, x, y, relative }) { + relative = relative === undefined ? this.relative : relative; + if (cx === undefined || cy === undefined) { + const command = relative ? 't' : 'T'; + this.pathParts.push(`${command} ${x},${y}`); + } else { + const command = relative ? 'q' : 'Q'; + this.pathParts.push(`${command}${cx},${cy} ${x},${y}`); + } + + this.currentPosition.x = relative ? this.currentPosition.x + x : x; + this.currentPosition.y = relative ? this.currentPosition.y + y : y; + + return this; + } + + arcTo({ rx, ry, rotation, arc, sweep, x, y, relative }) { + relative = relative === undefined ? this.relative : relative; + const command = relative ? 'a' : 'A'; + + this.pathParts.push(`${command}${rx},${ry} ${rotation} ${arc} ${sweep},${x},${y}`); + + this.currentPosition.x = relative ? this.currentPosition.x + x : x; + this.currentPosition.y = relative ? this.currentPosition.y + y : y; + + return this; + } + + toString() { + return this.pathParts.join(''); + } +} + +export default Path; diff --git a/src/components/SVG/style.js b/src/components/SVG/style.js new file mode 100644 index 0000000..e55de45 --- /dev/null +++ b/src/components/SVG/style.js @@ -0,0 +1,43 @@ +// Styles are in JS instead of CSS so they will be inlined as attributes +// instead of served as a CSS file. This is so styles are included in +// downloaded SVG files. + +//const green = '#bada55'; +const brown = '#6b6659'; +//const tan = '#cbcbba'; +const black = '#000'; +const white = '#fff'; +//const red = '#b3151a'; +//const orange = '#fa0'; + +const fontFamily = 'Arial'; +const fontSize = '16px'; +const fontSizeSmall = '12px'; + +const strokeBase = { + strokeWidth: '2px', + stroke: black +}; + +export default { + image: { + backgroundColor: white + }, + connectors: { + fillOpacity: 0, + ...strokeBase + }, + text: { + fontSize: fontSize, + fontFamily: fontFamily + }, + infoText: { + fontSize: fontSizeSmall, + fontFamily: fontFamily, + dominantBaseline: 'text-after-edge' + }, + pin: { + fill: brown, + ...strokeBase + } +}; diff --git a/src/devel.js b/src/devel.js new file mode 100644 index 0000000..3171ca3 --- /dev/null +++ b/src/devel.js @@ -0,0 +1,138 @@ +// Data used during development. +// Once everything is built, this file will go away + +const syntaxes = { + js: 'JavaScript', + pcre: 'PCRE' +}; + +const demoImage = { + type: 'Image', + children: [ + { + type: 'HorizontalLayout', + props: { + withConnectors: true + }, + children: [ + { + type: 'Pin' + }, + { + type: 'VerticalLayout', + props: { + withConnectors: true + }, + children: [ + { + type: 'Loop', + props: { + skip: false, + repeat: true + }, + children: [ + { + type: 'Box', + props: { + style: { fill: '#bada55' }, + radius: 3 + }, + children: [ + { + type: 'Text', + children: [ + 'Demo Text' + ] + } + ] + } + ] + }, + { + type: 'Loop', + props: { + skip: true, + repeat: false + }, + children: [ + { + type: 'Box', + props: { + style: { fill: '#bada55' }, + radius: 3 + }, + children: [ + { + type: 'Text', + children: [ + 'Demo Text' + ] + } + ] + } + ] + }, + { + type: 'Loop', + props: { + skip: true, + repeat: true, + greedy: true + }, + children: [ + { + type: 'Box', + props: { + style: { fill: '#bada55' }, + radius: 3 + }, + children: [ + { + type: 'Text', + children: [ + 'Demo Text' + ] + } + ] + } + ] + }, + { + type: 'Loop', + props: { + skip: true, + repeat: true, + label: 'Loop label' + }, + children: [ + { + type: 'Box', + props: { + style: { fill: '#bada55' }, + radius: 3 + }, + children: [ + { + type: 'Text', + children: [ + 'Demo Text' + ] + } + ] + } + ] + } + ] + }, + { + type: 'Pin' + } + ] + } + ] +}; + +export { + syntaxes, + demoImage +}; diff --git a/yarn.lock b/yarn.lock index 2982caf..fa32320 100644 --- a/yarn.lock +++ b/yarn.lock @@ -650,7 +650,7 @@ babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" -babel-plugin-syntax-object-rest-spread@^6.13.0: +babel-plugin-syntax-object-rest-spread@^6.13.0, babel-plugin-syntax-object-rest-spread@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -858,6 +858,13 @@ babel-plugin-transform-flow-strip-types@^6.22.0: babel-plugin-syntax-flow "^6.18.0" babel-runtime "^6.22.0" +babel-plugin-transform-object-rest-spread@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" + dependencies: + babel-plugin-syntax-object-rest-spread "^6.8.0" + babel-runtime "^6.26.0" + babel-plugin-transform-react-display-name@^6.23.0: version "6.25.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1" @@ -3761,6 +3768,10 @@ image-size@^0.5.0: version "0.5.5" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" +immutable@^3.8.2: + version "3.8.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"