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 = () => (
+
+);
+
+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"