Adding semi-functional rendering demo of app

This commit is contained in:
Jeff Avallone 2019-01-11 22:32:20 -05:00
parent 0606325d6d
commit a4450b34b3
16 changed files with 563 additions and 8 deletions

View File

@ -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",

View File

@ -0,0 +1,5 @@
import React from 'react';
const AppContext = React.createContext();
export default AppContext;

161
src/components/App/index.js Normal file
View File

@ -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 <AppContext.Provider value={ context }>
<Form />
<Loader />
<Message type="error" heading="Render Failure">
<p>An error occurred while rendering the regular expression.</p>
<a href="#retry" onClick={ this.handleRetry }>Retry</a>
</Message>
<SVG />
</AppContext.Provider>;
}
}
export default App;

View File

@ -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 <div className={ style.form }>
<form onSubmit={ this.handleSubmit }>
<textarea
name="expr"
value={ expr }
onKeyPress={ this.handleKeyPress }
onChange={ this.handleChange }
autoFocus
placeholder="Enter regular expression to display"></textarea>
<button type="submit">Display</button>
<div className={ style.select }>
<select
name="syntax"
value={ syntax }
onChange={ this.handleChange } >
{ syntaxList.map(({ id, label }) => (
<option value={ id } key={ id }>{ label }</option>
)) }
</select>
<ExpandIcon />
</div>
<FormActions />
</form>
</div>;
}
}
export default Form;

View File

@ -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;
}

View File

@ -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 <li>
<a href={ url } download={ filename } type={ type }>
<DownloadIcon />{ label }
</a>
</li>;
}
render() {
const {
permalinkUrl,
svgLink,
pngLink
} = this.context;
return <ul className={ style.actions }>
{ pngLink && this.downloadLink(pngLink) }
{ svgLink && this.downloadLink(svgLink) }
{ permalinkUrl && <li>
<a href={ permalinkUrl }><LinkIcon />Permalink</a>
</li> }
</ul>;
}
}
export default FormActions;

View File

@ -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;
}
}

View File

@ -0,0 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Loader rendering 1`] = `
<Loader>
<div
className="loader"
>
<Loader
color="currentColor"
size="24"
>
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="6"
/>
<line
x1="12"
x2="12"
y1="18"
y2="22"
/>
<line
x1="4.93"
x2="7.76"
y1="4.93"
y2="7.76"
/>
<line
x1="16.24"
x2="19.07"
y1="16.24"
y2="19.07"
/>
<line
x1="2"
x2="6"
y1="12"
y2="12"
/>
<line
x1="18"
x2="22"
y1="12"
y2="12"
/>
<line
x1="4.93"
x2="7.76"
y1="19.07"
y2="16.24"
/>
<line
x1="16.24"
x2="19.07"
y1="7.76"
y2="4.93"
/>
</svg>
</Loader>
<div
className="message"
>
Loading...
</div>
</div>
</Loader>
`;

View File

@ -0,0 +1,14 @@
import React from 'react';
import LoaderIcon from 'react-feather/dist/icons/loader';
import style from './style.module.css';
const Loader = () => (
<div className={ style.loader }>
<LoaderIcon />
<div className={ style.message }>Loading...</div>
</div>
);
export default Loader;

View File

@ -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);
}
}

View File

@ -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(
<Loader />
);
expect(component).toMatchSnapshot();
});
});

View File

@ -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 <div className={ style.render } ref={ this.svgContainer }>
<PlaceholderIcon />
</div>;
}
}
export default SVG;

View File

@ -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;
}
}

View File

@ -22,10 +22,6 @@ exports[`Index Page rendering 1`] = `
</p>
</Message>
</noscript>
<div>
<WithMergedOptions(TransComponent)>
Hello world
</WithMergedOptions(TransComponent)>
</div>
<App />
</Fragment>
`;

View File

@ -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 = () => <>
<Metadata/>
@ -14,7 +14,7 @@ export const IndexPage = () => <>
please see the <Link to="/privacy">Privacy Policy</Link>.</p>
</Message>
</noscript>
<div><Trans>Hello world</Trans></div>
<App />
</>;
export default withNamespaces()(IndexPage);
export default IndexPage;

View File

@ -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"