unified

Learn/Guide/Create an editor

Creating an editor

This guide shows how to create an interactive online editor with unified. Something sometimes called a “playground”. Or a “dingus”. In it we’ll visualize syntactic properties of text by “syntax highlighting” them. It’s made with React and runs in a browser.

For this example we’ll create an app that visualizes sentence length. Based on a tip by Gary Provost. The visualization is based on a tweet by @gregoryciotti.

You can also view this project with some more features online.

Stuck? Have an idea for another guide? See support.md.

Contents

Case

Before we start, let’s first outline what we want to make. We want to highlight sentences in text based on how many words they have. The user should be able to change text. And it should highlight live.

We’ll use esbuild as a bundler to compile our JavaScript to code that works in the browser in production. We’ll use xo and prettier to lint and format our code. You can swap those out for your favorite tools.

Project structure

Let’s first outline our project structure:

demo/
├─ bundle.mjs
├─ index.css
├─ index.html
├─ index.jsx
└─ package.json

…where demo/ is our folder and bundle.mjs is the JavaScript generated by compiling index.jsx.

Keep index.jsx, index.html, and index.css empty for now, and fill package.json with the following:

{
  "devDependencies": {
    "esbuild": "^0.23.0",
    "prettier": "^3.0.0",
    "xo": "^0.59.0"
  },
  "name": "demo",
  "prettier": {
    "bracketSpacing": false,
    "semi": false,
    "singleQuote": true,
    "tabWidth": 2,
    "trailingComma": "none",
    "useTabs": false
  },
  "private": true,
  "scripts": {
    "build": "esbuild index.jsx --bundle --format=esm --jsx=automatic --minify --outfile=bundle.mjs --target=es2020",
    "format": "prettier . --log-level warn --write && xo --fix",
    "test": "npm run build && npm run format"
  },
  "type": "module",
  "xo": {
    "envs": [
      "browser"
    ],
    "ignore": [
      "bundle.mjs"
    ],
    "prettier": true
  }
}

private: true means you can’t accidentally publish your package to npm.

The above package sets up xo, prettier, and esbuild. Now, after running npm install and npm test you’ll see bundle.mjs appear too.

Also add .prettierignore file to not format our build:

bundle.mjs

Fill index.html with the following:

<!doctype html>
<meta charset="utf8" />
<title>demo</title>
<link rel="stylesheet" href="index.css" />
<div id="root"></div>
<script type="module" src="bundle.mjs"></script>

This links index.css and bundle.mjs, and adds an element (#root) which we’ll add our editor to later.

Did you know that <html>, <head>, and <body> are optional? For this example we’ll keep the HTML minimal, but feel free to add them if you prefer them.

Setting up JavaScript

Alright! Now, let’s set up our JavaScript. Start by adding the following to index.jsx:

/// <reference lib="dom" />
/* eslint-env browser */
import ReactDom from 'react-dom/client'
import React from 'react'

const main = document.querySelector('#root')
if (!main) throw new Error('No root element found')
const root = ReactDom.createRoot(main)

const sample = 'The initial text.'

root.render(React.createElement(Playground))

function Playground() {
  const [text, setText] = React.useState(sample)

  return (
    <div className="editor">
      <div className="draw">
        {/* Trailing whitespace in a `textarea` is shown, but not in a `div`
        with `white-space: pre-wrap`.
        Add a `br` to make the last newline explicit. */}
        {/\n[ \t]*$/.test(text) ? <br /> : undefined}
      </div>
      <textarea
        className="write"
        onChange={(event) => setText(event.target.value)}
        rows={text.split('\n').length + 1}
        spellCheck="true"
        value={text}
      />
    </div>
  )
}
import ReactDom
(alias) namespace React
import React
const main: Element | null
var document: Document
(method) ParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

const main: Element | null
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
const root: ReactDom.Root
import ReactDom
function createRoot(container: ReactDom.Container, options?: ReactDom.RootOptions): ReactDom.Root

createRoot lets you create a root to display React components inside a browser DOM node.

const main: Element
const sample: "The initial text."
const root: ReactDom.Root
(method) Root.render(children: React.ReactNode): void
(alias) namespace React
import React
function React.createElement<any>(type: React.FunctionComponent<any>, props?: any, ...children: React.ReactNode[]): React.FunctionComponentElement<any> (+6 overloads)
function Playground(): React.JSX.Element
function Playground(): React.JSX.Element
const text: string
const setText: React.Dispatch<React.SetStateAction<string>>
(alias) namespace React
import React
function React.useState<string>(initialState: string | (() => string)): [string, React.Dispatch<React.SetStateAction<string>>] (+1 overload)

Returns a stateful value, and a function to update it.

const sample: "The initial text."
(property) JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
(property) React.HTMLAttributes<HTMLDivElement>.className?: string | undefined
(property) JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
(property) React.HTMLAttributes<HTMLDivElement>.className?: string | undefined
(method) RegExp.test(string: string): boolean

Returns a Boolean value that indicates whether or not a pattern exists in a searched string.

  • @param string String on which to perform the search.
const text: string
(property) JSX.IntrinsicElements.br: React.DetailedHTMLProps<React.HTMLAttributes<HTMLBRElement>, HTMLBRElement>
var undefined
(property) JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
(property) JSX.IntrinsicElements.textarea: React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>
(property) React.HTMLAttributes<T>.className?: string | undefined
(property) React.TextareaHTMLAttributes<HTMLTextAreaElement>.onChange?: React.ChangeEventHandler<HTMLTextAreaElement> | undefined
(parameter) event: React.ChangeEvent<HTMLTextAreaElement>
const setText: (value: React.SetStateAction<string>) => void
(parameter) event: React.ChangeEvent<HTMLTextAreaElement>
(property) React.ChangeEvent<HTMLTextAreaElement>.target: EventTarget & HTMLTextAreaElement
(property) HTMLTextAreaElement.value: string

Retrieves or sets the text in the entry field of the textArea element.

MDN Reference

(property) React.TextareaHTMLAttributes<HTMLTextAreaElement>.rows?: number | undefined
const text: string
(method) String.split(separator: string | RegExp, limit?: number): string[] (+1 overload)

Split a string into substrings using the specified separator and return them as an array.

  • @param separator A string that identifies character or characters to use in separating the string. If omitted, a single-element array containing the entire string is returned.
  • @param limit A value used to limit the number of elements returned in the array.
(property) Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

(property) React.HTMLAttributes<HTMLTextAreaElement>.spellCheck?: Booleanish | undefined
(property) React.TextareaHTMLAttributes<HTMLTextAreaElement>.value?: string | number | readonly string[] | undefined
const text: string
(property) JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>

Don’t forget to npm install @types/react-dom @types/react react-dom react.

That’s going a bit fast, I can imagine.

In Playground, we’re creating two elements: a <div> (.draw) that we’ll draw our syntax highlighting in, and a <textarea> (.write) that the user can edit. Both are wrapped in a parent <div> (.editor). when the user changes the text area, setText is called with the new value. Which in turn causes text to change.

We’ll style the text area and the drawing area exactly the same, and position the text above the drawing area, with the following styles:

html {
  font-size: 16px;
  line-height: 1.5;
}

.editor {
  position: relative;
  max-width: 37em;
  margin: auto;
  overflow: hidden;
}

textarea,
.draw {
  margin: 0;
  padding: 0;
  width: 100%;
  border: none;
  outline: none;
  resize: none;
  overflow: hidden;
  /* Can’t use a nice font: kerning renders differently in textareas. */
  font-family: monospace;
  line-height: inherit;
  font-size: inherit;
  background: transparent;
  white-space: pre-wrap;
  word-wrap: break-word;
  font-size: inherit;
  line-height: inherit;
}

textarea {
  color: inherit;
  position: absolute;
  top: 0;
}

.draw {
  min-height: 100vh;
}

That’s quite a bit of code: mainly to enforce the same styles on our text and drawing areas.

Natural language syntax tree

Now, let’s set up our natural language syntax tree parsing. We’ll use parse-english for that. It’s a parser for the retext ecosystem: it produces nlcst. We don’t need plugins and we’re in a browser where size matters more, so we can directly use utilities.

Change index.jsx like so:

--- a/index.jsx
+++ b/index.jsx
@@ -1,5 +1,6 @@
 /// <reference lib="dom" />
 /* eslint-env browser */
+import {ParseEnglish} from 'parse-english'
 import ReactDom from 'react-dom/client'
 import React from 'react'

@@ -8,11 +9,13 @@ if (!main) throw new Error('No root element found')
 const root = ReactDom.createRoot(main)

 const sample = 'The initial text.'
+const parser = new ParseEnglish()

 root.render(React.createElement(Playground))

 function Playground() {
   const [text, setText] = React.useState(sample)
+  const tree = parser.parse(text)

   return (
     <div className="editor">

Don’t forget to npm install parse-english.

Sweet, now we have access to a lot of info on the text. It still doesn’t do anything yet though. Let’s add some usefulness.

Virtual DOM

Our next task is to go from a natural language syntax tree to a virtual DOM. Change index.jsx like so:

--- a/index.jsx
+++ b/index.jsx
@@ -1,5 +1,9 @@
 /// <reference lib="dom" />
 /* eslint-env browser */
+/**
+ * @import {Nodes, Parents} from 'nlcst'
+ */
+
 import {ParseEnglish} from 'parse-english'
 import ReactDom from 'react-dom/client'
 import React from 'react'
@@ -20,6 +24,7 @@ function Playground() {
   return (
     <div className="editor">
       <div className="draw">
+        {one(tree)}
         {/* Trailing whitespace in a `textarea` is shown, but not in a `div`
           with `white-space: pre-wrap`.
           Add a `br` to make the last newline explicit. */}
@@ -35,3 +40,39 @@ function Playground() {
     </div>
   )
 }
+
+/**
+ * @param {Parents} node
+ * @returns {Array<React.JSX.Element | string>}
+ */
+function all(node) {
+  /** @type {Array<React.JSX.Element | string>} */
+  const results = []
+  let index = -1
+
+  while (++index < node.children.length) {
+    const result = one(node.children[index])
+
+    if (Array.isArray(result)) {
+      results.push(...result)
+    } else {
+      results.push(result)
+    }
+  }
+
+  return results
+}
+
+/**
+ * @param {Nodes} node
+ * @returns {Array<React.JSX.Element | string> | React.JSX.Element | string}
+ */
+function one(node) {
+  const result = 'value' in node ? node.value : all(node)
+
+  if (node.type === 'SentenceNode') {
+    return <span>{result}</span>
+  }
+
+  return result
+}

Don’t forget to npm install @types/nlcst.

all searches all children in the given node and one returns either the “text content” of a node, or the result of searching its children for all again.

When you now run npm test and open index.html in a browser, you’ll see that the drawing area already has our text. It’s not colored yet, but <span> elements hidden with styles are wrapping sentences.

Highlight

Now, let’s add colors. Update index.jsx like so:

--- a/index.jsx
+++ b/index.jsx
@@ -7,6 +7,7 @@
 import {ParseEnglish} from 'parse-english'
 import ReactDom from 'react-dom/client'
 import React from 'react'
+import {visit} from 'unist-util-visit'

 const main = document.querySelector('#root')
 if (!main) throw new Error('No root element found')
@@ -14,6 +15,7 @@ const root = ReactDom.createRoot(main)

 const sample = 'The initial text.'
 const parser = new ParseEnglish()
+const hues = [60, 60, 60, 300, 300, 0, 0, 120, 120, 120, 120, 120, 120, 180]

 root.render(React.createElement(Playground))

@@ -71,7 +73,23 @@ function one(node) {
   const result = 'value' in node ? node.value : all(node)

   if (node.type === 'SentenceNode') {
-    return <span>{result}</span>
+    let words = 0
+
+    visit(node, 'WordNode', function () {
+      words++
+    })
+
+    const hue = words < hues.length ? hues[words] : hues.at(-1)
+
+    return (
+      <span
+        style={{
+          backgroundColor: 'hsl(' + [hue, '93%', '70%', 0.5].join(', ') + ')'
+        }}
+      >
+        {result}
+      </span>
+    )
   }

   return result

Don’t forget to npm install unist-util-visit.

This imports unist-util-visit and then defines some hues. We’re trying to recreate that visual by @gregoryciotti. From that image, I deducted these hues. But you could use any hues you like!

Then, for each sentence, we count its words. Then we use an HSL color based on the number of words as the background color of each sentence.

Try it out by running npm test again and viewing index.html in your browser. If everything went okay, you should see each sentence highlighted in red.

Further exercises

💃 In your browser, you should now see The initial text in purple! If you add more sentences, they each should receive colors based on how many words they have.

It could use some better styles, but otherwise it’s a pretty cool little demo.

If you haven’t already, check out the other articles in the learn section!