Majo
Posted on September 29, 2020
This article is going to based on a Youtube tutorial to create a CodePen Clone using React, additionally we are going to make it a PWA and upload it to GitHub Pages.
You will be able to write HTML, CSS and JavaScript and render the result in the page. It will also save your work to not loose what you been working on if the page is refreshed and continue to work later.
You can watch the original tutorial How To Build CodePen With React
You can also watch the live site at https://mariavla.github.io/codepen-clone/
This solution uses this two npm package codemirror
and react-codemirror2
to add a text editor to React.
Note: The site is responsive but is not very easy to use in mobile.
Initial Setup
$ npx create-react-app codepen-clone
$ cd codepen-clone
$ yarn start
Make sure everything works.
Install The Necessary Libraries
$ npm i codemirror react-codemirror2
$ npm i --save @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome
Let's create a components
folder and move App.js
inside.
Editor Component
Inside components
create a file name Editor.js
.
This component is going to have:
- the editor calling
Controlled
fromreact-codemirror2
- a button to expand and collapse the editor
import React, { useState } from "react";
import "codemirror/lib/codemirror.css";
import "codemirror/theme/material.css";
import "codemirror/mode/xml/xml";
import "codemirror/mode/javascript/javascript";
import "codemirror/mode/css/css";
import { Controlled as ControlledEditor } from "react-codemirror2";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCompressAlt, faExpandAlt } from "@fortawesome/free-solid-svg-icons";
export default function Editor(props) {
const { language, displayName, value, onChange } = props;
const [open, setOpen] = useState(true);
function handleChange(editor, data, value) {
onChange(value);
}
return (
<div className={`editor-container ${open ? "" : "collapsed"}`}>
<div className="editor-title">
{displayName}
<button
type="button"
className="expand-collapse-btn"
onClick={() => setOpen((prevOpen) => !prevOpen)}
>
<FontAwesomeIcon icon={open ? faCompressAlt : faExpandAlt} />
</button>
</div>
<ControlledEditor
onBeforeChange={handleChange}
value={value}
className="code-mirror-wrapper"
options={{
lineWrapping: true,
lint: true,
mode: language,
theme: "material",
lineNumbers: true,
}}
/>
</div>
);
}
You can see other themes in codemirror website https://codemirror.net/theme/ with demo at https://codemirror.net/demo/theme.html.
You can also see all the languages codemirror supports https://codemirror.net/mode/.
App.js
This component is going to have:
- The basic layout of the page
- 3 codemirror editors
- an iframe to render all the HTML, CSS and JavaScript
import React, { useState, useEffect } from "react";
import Editor from "./Editor";
function App() {
const [html, setHtml] = useState("");
const [css, setCss] = useState("");
const [js, setJs] = useState("");
const [srcDoc, setSrcDoc] = useState("");
useEffect(() => {
const timeout = setTimeout(() => {
setSrcDoc(`
<html>
<body>${html}</body>
<style>${css}</style>
<script>${js}</script>
</html>
`);
}, 250);
return () => clearTimeout(timeout);
}, [html, css, js]);
return (
<>
<div className="pane top-pane">
<Editor
language="xml"
displayName="HTML"
value={html}
onChange={setHtml}
/>
<Editor
language="css"
displayName="CSS"
value={css}
onChange={setCss}
/>
<Editor
language="javascript"
displayName="JS"
value={js}
onChange={setJs}
/>
</div>
<div className="pane">
<iframe
srcDoc={srcDoc}
title="output"
sandbox="allow-scripts"
frameBorder="0"
width="100%"
height="100%"
/>
</div>
</>
);
}
export default App;
Let's check iframe attributes
- srcDoc: https://www.w3schools.com/tags/att_iframe_srcdoc.asp
-
sandbox="allow-scripts"
→ Enables an extra set of restrictions for the content in an .The sandbox attribute enables an extra set of restrictions for the content in the iframe.
When the sandbox attribute is present, and it will:
- treat the content as being from a unique origin
- block form submission
- block script execution
- disable APIs
- prevent links from targeting other browsing contexts
- prevent content from using plugins (through , , , or other)
- prevent the content to navigate its top-level browsing context
- block automatically triggered features (such as automatically playing a video or automatically focusing a form control)
The value of the sandbox attribute can either be just sandbox (then all restrictions are applied), or a space-separated list of pre-defined values that will REMOVE the particular restrictions. In this case is going to allow scripts.
To render all the HTML, CSS and JS in the iframe we need to pass the srcDoc
. When we pass the srcDoc
to the iframe is going to render immediately, which is going to slow down the browser. For this we use useEffect
and set a timeout to update srcDoc
. Now, every time the html
, css
or js
change, the srcDoc
is going to be updated.
If we make changes before the timeout completes we are going to restart the timeout, for this add: return () => clearTimeout(timeout);
Styles
Let's add some styles at src/index.css
to give it structure and make it responsive.
body {
margin: 0;
}
.top-pane {
background-color: hsl(225, 6%, 25%);
flex-wrap: wrap;
justify-content: center;
max-height: 50vh;
overflow: auto;
}
.pane {
height: 50vh;
display: flex;
}
.editor-container {
flex-grow: 1;
flex-basis: 0;
display: flex;
flex-direction: column;
padding: 0.5rem;
background-color: hsl(225, 6%, 25%);
flex: 1 1 300px; /* Stretching: */
}
.editor-container.collapsed {
flex-grow: 0;
}
.editor-container.collapsed .CodeMirror-scroll {
position: absolute;
overflow: hidden !important;
}
.expand-collapse-btn {
margin-left: 0.5rem;
background: none;
border: none;
color: white;
cursor: pointer;
}
.editor-title {
display: flex;
justify-content: space-between;
background-color: hsl(225, 6%, 13%);
color: white;
padding: 0.5rem 0.5rem 0.5rem 1rem;
border-top-right-radius: 0.5rem;
border-top-left-radius: 0.5rem;
}
.CodeMirror {
height: 100% !important;
}
.code-mirror-wrapper {
flex-grow: 1;
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
overflow: hidden;
}
Add the possibility to save
For this we use localStorage and hooks.
Custom Hook to use Local Storage
In src
create a folder name hooks
and inside create a file named useLocalStorage.js
.
To do this we are going to add a function in useState
because getting the values from local storage is pretty slow, so we want to get the value once. For more info on this here is an article about how-to-store-a-function-with-the-usestate-hook-in-react.
import { useEffect, useState } from "react";
const PREFIX = "codepen-clone-";
export default function useLocalStorage(key, initialValue) {
const prefixedKey = PREFIX + key;
const [value, setValue] = useState(() => {
const jsonValue = localStorage.getItem(prefixedKey);
if (jsonValue != null) return JSON.parse(jsonValue);
if (typeof initialValue === "function") {
return initialValue();
} else {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(prefixedKey, JSON.stringify(value));
}, [prefixedKey, value]);
return [value, setValue];
}
In App.js
change the useState
hooks to useLocalStorage
custom hook.
import useLocalStorage from "../hooks/useLocalStorage";
...
const [html, setHtml] = useLocalStorage("html", "");
const [css, setCss] = useLocalStorage("css", "");
const [js, setJs] = useLocalStorage("js", "");
Final Directory
Turn it into a PWA
A Progressive Web App is an application that expands the functionality of a regular website adding features that previously were exclusive for native applications. Such as offline capabilities, access through an icon on the home screen, or push notifications (except maybe for ios https://www.pushpro.io/blog/web-push-notifications-for-ios).
The installation process of a PWA doesn't involve an app store. It's installed directly through the browser.
The two very essential features that a Progressive Web App should have is a Service Worker and a manifest.
Service Worker
They enable native features like an offline experience or push notifications.
Service Workers allow JavaScript code to be run in the background, they keep working when the tab is closed and can intercept network request, important for offline capabilities.
Web App Manifest
We still need to give the feel of a native application. Here is where the Web App Manifest enters. In a file named manifest.json
, we will be adding a splash screen, name, icons and more to out app.
Let's have a look at what are the essential fields for a PWA:
-
name and short_name
The short name is what will be displayed on the home screen below your icon. The full name will be used in the android splash screen.
-
start_url
The entry point of the installed app.
-
display
Possible values are
fullscreen
,standalone
,minimal-ui
, andbrowser
. You probably want to usefullscreen
, which will make the URL-bar disappear. -
icons
These will be used for the app icon and the generated splash screen.
-
theme_color
This affects how the operating system displays the application. For example, this color can be used in the task switcher.
-
background_color
This color will be shown while the application's styles are loading.
More resources about PWA:
- https://felixgerschau.com/how-to-make-your-react-app-a-progressive-web-app-pwa/
- https://web.dev/pwa-checklist/
- https://web.dev/add-manifest/
Let's start to add the config
- In the
public
folder create a file namedworker.js
and paste:
let CACHE_NAME = "codepen-clone";
let urlsToCache = ["/", "/completed"];
let self = this;
// Install a service worker
self.addEventListener("install", (event) => {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
console.log("Opened cache");
return cache.addAll(urlsToCache);
})
);
});
// Cache and return requests
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(function (response) {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
})
);
});
// Update a service worker
self.addEventListener("activate", (event) => {
let cacheWhitelist = ["codepen-clone"];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
- Register the service worker in
src/index.js
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./components/App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register();
- In
public/index.html
paste: below<div id="root"></div>
:
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker
.register("worker.js")
.then(
function (registration) {
console.log(
"Worker registration successful",
registration.scope
);
},
function (err) {
console.log("Worker registration failed", err);
}
)
.catch(function (err) {
console.log(err);
});
});
} else {
console.log("Service Worker is not supported by browser.");
}
</script>
- Update with your app data
public/manifest.json
Restart the server and let's inspect the site with Google Lighthouse. Press Generate Report.
If everything goes well you should see something like this.
Deploy PWA to GitHub Pages
- In the project folder:
$ npm i gh-pages
- In
package.json
- Add below
"private"
:"homepage": "http://<username>.github.io/<projectname>"
- Add a pre-deploy script:
"predeploy": "npm run build"
to build the project before upload it to gh-pages. - Add a deploy script:
"deploy": "gh-pages -d build"
to tell gh-pages where is the build directory.
- Add below
package.json
{
"name": "codepen-clone",
"version": "0.1.0",
"private": true,
"homepage": "http://<username>.github.io/codepen-clone",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"codemirror": "^5.58.1",
"gh-pages": "^3.1.0",
"react": "^16.13.1",
"react-codemirror2": "^7.2.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.3"
},
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
- Upload the changes to github like always.
-
$ npm run deploy
-> This is going to publish the site to GitHub Pages.
Now if you go to the site on your cellphone, you should have the option of adding the application to your home screen.
Posted on September 29, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.