diff --git a/package.json b/package.json index 288f6aa..66ceb33 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "dependencies": { "@babel/core": "^7.2.2", + "@ungap/url-search-params": "^0.1.2", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^23.6.0", "babel-preset-gatsby": "^0.1.6", diff --git a/src/components/App/context.js b/src/components/App/context.js new file mode 100644 index 0000000..e4dab10 --- /dev/null +++ b/src/components/App/context.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const AppContext = React.createContext(); + +export default AppContext; diff --git a/src/components/App/index.js b/src/components/App/index.js new file mode 100644 index 0000000..e201f78 --- /dev/null +++ b/src/components/App/index.js @@ -0,0 +1,161 @@ +import React from 'react'; +import URLSearchParams from '@ungap/url-search-params'; + +import AppContext from 'components/App/context'; +import Form from 'components/Form'; +import Loader from 'components/Loader'; +import Message from 'components/Message'; +import SVG from 'components/SVG'; + +const toUrl = params => new URLSearchParams(params).toString(); + +const readURLHash = () => { + const query = document.location.hash.slice(1); + const params = new URLSearchParams(query); + + if (params.get('syntax')) { + return { + syntax: params.get('syntax'), + expr: params.get('expr') + }; + } else { + // Assuming old-style URL + return { + syntax: 'js', + expr: query || '' + }; + } +}; + +const createSvgLink = async ({ svg }) => { + try { + const type = 'image/svg+xml'; + const blob = new Blob([svg], { type }); + + return { + url: URL.createObjectURL(blob), + label: 'Download SVG', + filename: 'image.svg', + type + }; + } + catch (e) { + console.error(e); // eslint-disable-line no-console + } +}; + +const createPngLink = async ({ svg, width, height }) => { + try { + const type = 'image/png'; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const loader = new Image(); + + loader.width = canvas.width = Number(width) * 2; + loader.height = canvas.height = Number(height) * 2; + + await new Promise(resolve => { + loader.onload = resolve; + loader.src = 'data:image/svg+xml,' + encodeURIComponent(svg); + }); + + context.drawImage(loader, 0, 0, loader.width, loader.height); + + const blob = await new Promise(resolve => canvas.toBlob(resolve, type)); + + return { + url: URL.createObjectURL(blob), + label: 'Download PNG', + filename: 'image.png', + type + }; + } + catch (e) { + console.error(e); // eslint-disable-line no-console + } +}; + +class App extends React.PureComponent { + state={ + ...readURLHash() + } + + componentDidMount() { + window.addEventListener('hashchange', this.handleHashChange); + this.handleHashChange(); + } + + componentWillUnmount() { + window.removeEventListener('hashchange', this.handleHashChange); + } + + handleHashChange = () => { + const { expr, syntax } = readURLHash(); + + if (!expr) { + return; + } + + this.setState({ + syntax, + expr, + permalinkUrl: document.location.toString() + }); + console.log('Render:', syntax, expr); // eslint-disable-line no-console + } + + handleRetry = event => { + event.preventDefault(); + this.handleHashChange(); + } + + renderExpr = ({ expr, syntax }) => { + if (expr) { + document.location.hash = toUrl({ syntax, expr }); + } + } + + svgData = async ({ svg, width, height }) => { + if (svg !== this.state.svg) { + this.setState({ + svg, + svgLink: await createSvgLink({ svg }), + pngLink: await createPngLink({ svg, width, height }) + }); + } + } + + render() { + const { + svgLink, + pngLink, + permalinkUrl, + expr, + syntax + } = this.state; + const context = { + svgLink, + pngLink, + permalinkUrl, + expr, + syntax, + renderExpr: this.renderExpr, + svgData: this.svgData + }; + + return +
+ + + + +

An error occurred while rendering the regular expression.

+ Retry +
+ + + ; + } +} + +export default App; diff --git a/src/components/Form/index.js b/src/components/Form/index.js new file mode 100644 index 0000000..e7dc39e --- /dev/null +++ b/src/components/Form/index.js @@ -0,0 +1,71 @@ +import React from 'react'; + +import ExpandIcon from 'react-feather/dist/icons/chevrons-down'; + +import style from './style.module.css'; + +import AppContext from 'components/App/context'; +import FormActions from 'components/FormActions'; + +const syntaxList = [ + { id: 'js', label: 'JavaScript' }, + { id: 'pcre', label: 'PCRE' } +]; + +class Form extends React.PureComponent { + static contextType = AppContext + + state = { + expr: this.context.expr, + syntax: this.context.syntax + } + + handleSubmit = event => { + event.preventDefault(); + + const { expr, syntax } = this.state; + + this.context.renderExpr({ expr, syntax }); + } + + handleKeyPress = event => { + if (event.charCode === 13 && event.shiftKey) { + this.handleSubmit(event); + } + } + + handleChange = event => this.setState({ + [event.target.name]: event.target.value + }) + + render() { + const { expr, syntax } = this.state; + + return
+ + + +
+ + +
+ + +
; + } +} + +export default Form; diff --git a/src/components/Form/style.module.css b/src/components/Form/style.module.css new file mode 100644 index 0000000..9bda21b --- /dev/null +++ b/src/components/Form/style.module.css @@ -0,0 +1,49 @@ +@import url('../../globals.module.css'); + +:root { + --control-gradient: var(--color-green) var(--gradient-green); + --select-height: 2.8rem; + --select-width: 12rem; + --entry-line-height: 1.5em; +} + +.form { + margin: var(--spacing-margin) 0; + overflow: hidden; /* Keep floated content in the box */ + + & textarea { + display: block; + font-size: inherit; + line-height: var(--entry-line-height); + border: 0 none; + outline: none; + background: var(--color-tan); + padding: 0 1rem; + margin-bottom: var(--spacing-margin); + width: 100% !important; /* "!important" to prevent user changing width */ + height: calc(3 * var(--entry-line-height)); + box-sizing: border-box; + font-family: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace; + } + + & textarea::placeholder { + color: var(--color-brown); + } + + & button { + font-size: inherit; + font-weight: bold; + line-height: 2.8rem; + width: 10rem; + border: 0 none; + background: var(--control-gradient); + color: var(--color-black); + cursor: pointer; + padding: 0; + margin-right: 1rem; + } +} + +.select { + composes: fancy-select; +} diff --git a/src/components/FormActions/index.js b/src/components/FormActions/index.js new file mode 100644 index 0000000..5021925 --- /dev/null +++ b/src/components/FormActions/index.js @@ -0,0 +1,38 @@ +import React from 'react'; + +import DownloadIcon from 'react-feather/dist/icons/download'; +import LinkIcon from 'react-feather/dist/icons/link'; + +import style from './style.module.css'; + +import AppContext from 'components/App/context'; + +class FormActions extends React.PureComponent { + static contextType = AppContext + + downloadLink({ url, filename, type, label }) { + return
  • + + { label } + +
  • ; + } + + render() { + const { + permalinkUrl, + svgLink, + pngLink + } = this.context; + + return
      + { pngLink && this.downloadLink(pngLink) } + { svgLink && this.downloadLink(svgLink) } + { permalinkUrl &&
    • + Permalink +
    • } +
    ; + } +} + +export default FormActions; diff --git a/src/components/FormActions/style.module.css b/src/components/FormActions/style.module.css new file mode 100644 index 0000000..ec41eaa --- /dev/null +++ b/src/components/FormActions/style.module.css @@ -0,0 +1,19 @@ +@import url('../../globals.module.css'); + +.actions { + composes: inline-list with-separator-left; + float: right; + + @media (max-width: 700px) { + & li { + display: block; + } + } + + & svg { + width: 1em; + height: 1em; + margin-right: 0.5rem; + vertical-align: middle; + } +} diff --git a/src/components/Loader/__snapshots__/test.js.snap b/src/components/Loader/__snapshots__/test.js.snap new file mode 100644 index 0000000..0aba97d --- /dev/null +++ b/src/components/Loader/__snapshots__/test.js.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Loader rendering 1`] = ` + +
    + + + + + + + + + + + + +
    + Loading... +
    +
    +
    +`; diff --git a/src/components/Loader/index.js b/src/components/Loader/index.js new file mode 100644 index 0000000..a08241e --- /dev/null +++ b/src/components/Loader/index.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import LoaderIcon from 'react-feather/dist/icons/loader'; + +import style from './style.module.css'; + +const Loader = () => ( +
    + +
    Loading...
    +
    +); + +export default Loader; diff --git a/src/components/Loader/style.module.css b/src/components/Loader/style.module.css new file mode 100644 index 0000000..a029b45 --- /dev/null +++ b/src/components/Loader/style.module.css @@ -0,0 +1,43 @@ +@import url('../../globals.module.css'); + +.loader { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: var(--spacing-margin) 0; + padding: 2rem; + background: var(--color-white); + color: var(--color-black); + + & .message { + font-weight: bold; + font-size: 2.5rem; + margin-top: 2rem; + padding: 0; + text-align: center; + } + + & svg { + display: block; + transform: scaleZ(1); /* Move to separate render layer in Chrome */ + width: 4rem; + height: 4rem; + stroke: var(--color-black); + animation: loader-spin 1s steps(8) infinite; + + & line:nth-of-type(1) { stroke: color(var(--color-black) alpha(0.75)); } + & line:nth-of-type(3) { stroke: color(var(--color-black) alpha(0.50)); } + & line:nth-of-type(5) { stroke: color(var(--color-black) alpha(0.25)); } + & line:nth-of-type(7) { stroke: color(var(--color-black) alpha(0)); } + } +} + +@keyframes loader-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/test.js b/src/components/Loader/test.js new file mode 100644 index 0000000..5bddc0a --- /dev/null +++ b/src/components/Loader/test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import Loader from 'components/Loader'; + +describe('Loader', () => { + test('rendering', () => { + // Using full rendering here since styles for this depend on the structure + // of the SVG. + const component = mount( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/SVG/index.js b/src/components/SVG/index.js new file mode 100644 index 0000000..761fdaa --- /dev/null +++ b/src/components/SVG/index.js @@ -0,0 +1,43 @@ +import React from 'react'; + +import PlaceholderIcon from 'react-feather/dist/icons/file-text'; + +import style from './style.module.css'; + +import AppContext from 'components/App/context'; + +class SVG extends React.PureComponent { + static contextType = AppContext + + svgContainer = React.createRef() + + provideSVGData() { + if (!this.svgContainer.current) { + return; + } + + const svg = this.svgContainer.current.querySelector('svg'); + this.context.svgData({ + svg: svg.outerHTML, + width: svg.getAttribute('width'), + height: svg.getAttribute('height') + }); + } + + componentDidMount() { + this.provideSVGData(); + } + + componentDidUpdate() { + this.provideSVGData(); + } + + render() { + // Demo rendering for now + return
    + +
    ; + } +} + +export default SVG; diff --git a/src/components/SVG/style.module.css b/src/components/SVG/style.module.css new file mode 100644 index 0000000..62bfd86 --- /dev/null +++ b/src/components/SVG/style.module.css @@ -0,0 +1,15 @@ +@import url('../../globals.module.css'); + +.render { + 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 */ + margin: 0 auto; + } +} diff --git a/src/pages/__snapshots__/index.test.js.snap b/src/pages/__snapshots__/index.test.js.snap index e56e6c5..29e6f63 100644 --- a/src/pages/__snapshots__/index.test.js.snap +++ b/src/pages/__snapshots__/index.test.js.snap @@ -22,10 +22,6 @@ exports[`Index Page rendering 1`] = `

    -
    - - Hello world - -
    + `; diff --git a/src/pages/index.js b/src/pages/index.js index 262e6b9..a5d22ec 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -1,9 +1,9 @@ import React from 'react'; import { Link } from 'gatsby'; -import { withNamespaces, Trans } from 'react-i18next'; import Metadata from 'components/Metadata'; import Message from 'components/Message'; +import App from 'components/App'; export const IndexPage = () => <> @@ -14,7 +14,7 @@ export const IndexPage = () => <> please see the Privacy Policy.

    -
    Hello world
    + ; -export default withNamespaces()(IndexPage); +export default IndexPage; diff --git a/yarn.lock b/yarn.lock index cee9ce7..a590a3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -940,6 +940,11 @@ resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.0.32.tgz#0d3cb31022f8427ea58c008af32b80da126ca4e3" integrity sha1-DTyzECL4Qn6ljACK8yuA2hJspOM= +"@ungap/url-search-params@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@ungap/url-search-params/-/url-search-params-0.1.2.tgz#8ba8c0527543fe675d1c29ae0a2daca842e8ee4f" + integrity sha512-WVk5+lJ+AoNLh2sIDMhnEAgLsVQuI067hWLJCzirErH1GYiy1gs09q4+XZxYWSvdAsslKsaO4q1iXXMx2c72dA== + "@webassemblyjs/ast@1.7.11": version "1.7.11" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace"