The documentation is a Work in Progress and will be continuously improved and extended. We'd love to hear your feedback in our documentation repository.

Getting Started

We create our own Edtr.io-powered WYSIWYG editor during this tutorial. It is divided into several sections:

Prerequisites

We'll assume that you are familiar with React and the npm ecosystem. If you need to review your knowledge, we recommend the official React Tutorial.

Setup

First, we install Edtr.io and its peer dependencies:

# Using yarn
yarn add @edtr-io/core@^2.0.0 react@^17.0.0 react-dom@^17.0.0 react-is@^17.0.0 react-dnd@^14.0.0 react-dnd-html5-backend@^14.0.0 redux@^4.0.0 styled-components@^5.0.0
# OR Using npm
npm i --save @edtr-io/core@^2.0.0 react@^17.0.0 react-dom@^17.0.0 react-is@^17.0.0 react-dnd@^14.0.0 react-dnd-html5-backend@^14.0.0 redux@^4.0.0 styled-components@^5.0.0

Furthermore, we need at least one plugin. There are some "official" plugins provided by Edtr.io (though none of these are required). Let's start with the text plugin:

# Using yarn
yarn add @edtr-io/plugin-text@^2.0.0 @fortawesome/fontawesome-svg-core@^1.0.0 @fortawesome/free-solid-svg-icons@^5.0.0 @fortawesome/react-fontawesome@^0.1.0 immutable@^4.0.0-0
# OR Using npm
npm i --save @edtr-io/plugin-text@^2.0.0 @fortawesome/fontawesome-svg-core@^1.0.0 @fortawesome/free-solid-svg-icons@^5.0.0 @fortawesome/react-fontawesome@^0.1.0 immutable@^4.0.0-0

Creating the editor

We now have all the building blocks to create a basic Edtr.io editor:

import { Editor } from '@edtr-io/core'
import { createTextPlugin } from '@edtr-io/plugin-text'
import * as React from 'react'
const textPlugin = createTextPlugin({
placeholder: 'Hello world',
registry: [],
})
const plugins = {
text: textPlugin,
}
const initialState = { plugin: 'text' }
export default function App() {
return <Editor plugins={plugins} initialState={initialState} />
}

Note

Depending on your bundler, you might get the error ReferenceError: regeneratorRuntime is not defined. If so, add the line import 'regenerator-runtime/runtime' to the top of the file.

At this point, we should have a glorified rich-text editor. Let's go over the things we did in detail:

Firstly, we initialized an Edtr.io plugin using the createTextPlugin factory provided by @edtr-io/plugin-text. Like every "official" Edtr.io plugin, this factory accepts a config specific to the plugin. Some options are required (e.g. registry), while others are optional (e.g. placeholder).

Note

The factory pattern used by the "official" Edtr.io plugins is only a convention. Feel free to write your own plugins the way you like.

After that, we specified the plugins that our editor should know about. This is a mapping of (arbitrary) keys to Edtr.io plugins. You can name the plugins how you prefer (e.g. rich-text instead of text), and they will appear in the serialized Edtr.io state.

The last thing we need is the state our editor should start with. The state describes an Edtr.io document and will be used to initialize the store. A document consists of the plugin used (i.e. with the same name as declared in plugins) and an optional state. If no state is provided (like in our example), the plugin creates its own initial state (e.g. empty text in our case).

Lastly, we render the Editor component provided by @edtr-io/core with the allowed plugins and the initial state.

Nesting Documents

The whole thing becomes much more interesting when we nest documents: Plugins may contain other Edtr.io documents and therefore create a tree of documents. The rows plugin provided by Edtr.io, for example, renders a list of documents.

First, we install the rows plugin

# Using yarn
yarn add @edtr-io/plugin-rows@^2.0.0
# OR using npm
npm i --save @edtr-io/plugin-rows@^2.0.0

and add it to our editor:

import { Editor } from '@edtr-io/core'
import { createRowsPlugin } from '@edtr-io/plugin-rows'
import { createTextPlugin } from '@edtr-io/plugin-text'
import * as React from 'react'
const registry = [
{
name: 'text',
title: 'Text',
description: 'A rich-text editor',
},
]
const rowsPlugin = createRowsPlugin({
content: { plugin: 'text' },
plugins: registry,
})
const textPlugin = createTextPlugin({
placeholder: 'Hello world',
registry: [],
})
const plugins = {
rows: rowsPlugin,
text: textPlugin,
}
const initialState = { plugin: 'rows' }
export default function App() {
return <Editor plugins={plugins} initialState={initialState} />
}

We created the rows plugin via createRowsPlugin similarly to our text plugin. The rows plugin requires content which specifies which plugin should be used by default, and plugins which defines the plugins that may be added, including some metadata (e.g. a human-readable title). After that, we added the rows plugin to our known plugins, and replaced the initial state to start with the rows plugin.

Note

The plugins passed to the Editor component are not necessarily the same as the plugins passed to the rows plugin. The former describes any plugin the editor knows about and is therefore able to render. The latter may be a subset of that and defines the plugins that the user is actually able to add. For example, you might specify plugins that are only used internally or should only be used in a specific context. Furthermore, you can also override a plugin's configuration (e.g. a plugin may override the configuration of any child plugin it renders) and for example limit what users may do.

Persisting Documents

So far, we haven't talked about how you'd persist the documents created by the editor. The editor keeps track of its own state (and additional things like what document is currently focused, etc.), but does not actually persist the state. One way to tie the editor to your actual backend is to add a change listener:

import { Editor } from '@edtr-io/core'
import { createRowsPlugin } from '@edtr-io/plugin-rows'
import { createTextPlugin } from '@edtr-io/plugin-text'
import * as React from 'react'
const registry = [
{
name: 'text',
title: 'Text',
description: 'A rich-text editor',
},
]
const rowsPlugin = createRowsPlugin({
content: { plugin: 'text' },
plugins: registry,
})
const textPlugin = createTextPlugin({
placeholder: 'Hello world',
registry: [],
})
const plugins = {
rows: rowsPlugin,
text: textPlugin,
}
const initialState = { plugin: 'rows' }
export default function App() {
return (
<div style={{ padding: '20px 40px' }}>
<Editor
plugins={plugins}
initialState={initialState}
onChange={({ changed, getDocument }) => {
console.log('Changed', changed)
console.log('Serialized state', getDocument())
}}
/>
</div>
)
}

The change listener receives an object containing changed and getDocument:

  • changed is a boolean that tells you whether there are any pending changes (compared to the initial state you provided). You may use this to disable a save button, for example.
  • getDocument is a function that returns the serialized document (e.g. to persist in a database, file, local storage, etc.). Since this operation gets more expensive the longer the document is, you should decide yourself when you really need the serialized document (e.g. when the user actually presses a save button or by some time-based autosave).

Note

If your backend is only able to handle strings, you still need to stringify the JSON returned by the editor.

Note

A serialized document is easy to parse by design. For example, you could write your own custom renderer that is completely independent of Edtr.io (and possibly even from React). Or create a single Edtr.io document of multiple entities in your domain. The possibilities are endless.

Writing Plugins

Until now, we only used "official" Edtr.io plugins. While these plugins are often heavily customizable, it is often helpful to create domain-specific plugins that are tied to your own use case.

An Edtr.io plugin consists of the following parts:

  • A React Component that defines how the plugin is rendered in the editor
  • A description of the state (including how the state should be serialized if needed)
  • A plugin configuration that allows to change the behavior of the plugin in different contexts
  • Optionally, some overrides of the Edtr.io default behavior (e.g. disabling default Edtr.io hotkey bindings)

Example: a Counter Plugin

For educational purposes, let's define a simple plugin that represents a counter.

Firstly, we need to install another package intended for plugin developers:

# Using yarn
yarn add @edtr-io/plugin
# OR using npm
npm i --save @edtr-io/plugin

Then, we define the state of the plugin:

import { number } from '@edtr-io/plugin'
const counterState = number(0)

Edtr.io has the concept of a so-called "state type". A state type encapsulates:

  • The structure of the state used at runtime
  • How the state is going to be serialized
  • What the initial state should look like
  • State type specific helpers intended to make development easier (e.g. handling file uploads)

The goals are similar to React hooks. And similarly to React hooks, Edtr.io already provides state types for the most typical use cases.

In our counter plugin, we can just use the number state type that represents - surprise - a number, and accepts the initial value (0 in our case).

Next, we define how the counter plugin is rendered:

function CounterEditor({ editable, focused, state }) {
return (
<React.Fragment>
Current counter value:
{editable ? (
<button
onClick={() => {
state.set((value) => value - 1)
}}
>
-
</button>
) : null}
{state.value}
{editable ? (
<button
onClick={() => {
state.set((value) => value + 1)
}}
>
+
</button>
) : null}
</React.Fragment>
)
}

The component of an Edtr.io plugin receives - among other things - the following props:

  • editable is a boolean that tells you whether your plugin is editable. You can use this to hide editor-only elements when the document is not editable.
  • focused is a boolean that tells you whether your plugin is currently focused. You may use this to hide some elements when the user is editing a different document at the moment (to get a better WYSIWYG feeling). There are also ways to check whether any of your child documents are focused (that isn't in the scope of this tutorial, though).
  • state contains the helpers defined by the state type used by your plugin. In our example, state.value contains the current counter value and state.set can be used to update the state (similarly to React's setState, you may also provide an update callback to make sure that you actually use the current state).

In our counter example, we render the current value (surrounded by two buttons to decrement or increment the state respectively, if the document is editable).

Tying these things together, we have an Edtr.io plugin definition:

const counterPlugin = {
Component: CounterEditor,
config: {}
state: counterState
}

Example: a Card Plugin

Let's explore a more complicated example: a card. A card consists of a title and its content (which should be just some nested Edtr.io document). When you want to combine multiple values in your state, you can use the object state type. To render child documents, there is the child state type:

import { child, string, object } from '@edtr-io/plugin'
const cardState = object({
title: string(''),
content: child({ plugin: 'rows' }),
})

State types are composable by design. For example, the object state type expects a mapping of arbitrary keys to state types (and exposes those as an object to the plugin developer). The string state type pretty much works the same as the number state type. The child state type expects an object with

  • plugin that describes the plugin that should be used by the child (as defined in plugins),
  • optional initialState to override the initial state used by the plugin,
  • optional config to (deeply) override the plugin configuration. For example, we could override the plugins of the rows plugin or override the placeholder used by the text plugin.

We can use the state type as follows:

function CardEditor({ editable, state }) {
// `state.title` contains all the `string` state type helpers
console.log(state.title.value)
// `state.content` contains all the `child` state type helpers, e.g. `render`
const children = state.content.render()
return (
<div>
<strong>
{editable ? (
<input
type="text"
onChange={(e) => {
state.title.set(e.target.value)
}}
value={state.title.value}
/>
) : (
state.title.value
)}
</strong>
{children}
</div>
)
}

Where to go from here