DEV Community

Majo
Majo

Posted on • Edited on

PWA CodePen Clone

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 from react-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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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", "");
Enter fullscreen mode Exit fullscreen mode

Final Directory

Screen_Shot_2020-09-28_at_14.34.23

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 fullscreenstandaloneminimal-ui, and browser. You probably want to use fullscreen, 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:

Let's start to add the config

  • In the public folder create a file named worker.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);
          }
        })
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • Update with your app data public/manifest.json

Restart the server and let's inspect the site with Google Lighthouse. Press Generate Report.

Screen_Shot_2020-09-28_at_15.49.42

If everything goes well you should see something like this.

Screen_Shot_2020-09-28_at_15.51.00

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.

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"
        ]
      }
    }
Enter fullscreen mode Exit fullscreen mode
  • 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.

Top comments (0)