This guide describes the concepts used in Typerwriter and how they relate to each other.
Typewriter provides the tools you need to create your own rich text editor. It has sensible defaults to get you started quickly while still allowing you to customize many aspects of the editor.
Typewriter aims to be simple while still allowing robust customization. To stay conceptually simple, Typewriter uses a list-like internal data format based off the Delta format rather than a hierarchical data model. This makes it easy to understand your data. Typewriter also uses immutable data to keep code simpler and increase performance.
The main pieces that make up Typewriter are:
- Text Document represents the contents and user selection of Typewriter in memory. The
TextDocument
andTextChange
APIs can be used in headless environments (i.e. Node.js). - Editor is the core of Typewriter. It manages the contents, dispatches change events, and provides modules which render the contents to the DOM, handle keyboard shortcuts, add undo/redo, and more.
- Typeset holds the rules for what types of content is allowed in the editor and how that content is mapped to HTML and back again.
The beauty of Typewriter's mental model for thinking about your text data is you can think of it in plain text. This makes it very easy to reason about. For example, say you have the content:
What did the cheerleaders say to the ghost?
If you want to add the answer to your joke on a new line, insert the text "\nShow your spirit!"
at the end of the existing content.
Typewriter's non-text content, called embeds, exist within the document and take up a single space. In the following example, the # represents an image embedded into your document. To select the image, the selection would span from 7
to 8
, just like selecting the "I" in "Image" would need a selection that spans from 0
to 1
.
|I|mage: |#|
^ ^ ^ ^
0 1 7 8
Because the content of your editor is treated like plain text, any location in your editor can be described with a single number, an index into your document.
|H|o|w| |d|o| you get a baby alien to sleep|?|
^ ^ ^ ^ ^ ^ ^ ^ ^
0 1 2 3 4 5 6... ...35 36
Note: an index in Typewriter represents the location between characters, not a character itself, so an index of 0
points to the location before the first character. It is the location where the text cursor would appear.
If you need to reference a section of content in your document, use a range—a tuple of indexes (an array with two indexes) with the start and end of the section. The range [ 0, 3 ]
would encapsulate the word "You"
in this example:
|You| rocket.
^ ^
0...3
Any time we talk about ranges with Typewriter we are talking about an array with two numbers. The Editor's selection
property is a range.
Although a range is always two indexes, those two indexes can be the same. This happens when the selection is collapsed (i.e. no text is selected but the cursor appears at one index). For example, the selection [ 7, 7 ]
will put the cursor right after "be" in:
I’ll be| here all night.
^
7
A selection of [ 6, 9 ]
or [ 9, 6 ]
would select "you" in:
Thank |you|.
^ ^
6 9
Ranges don't have to be in document order. [ 0, 5 ]
is equivalent to [ 5, 0 ]
for most editing operations. The Editor will "normalize" ranges when it needs to, placing the lower index before the higher, when it runs change operations that need it.
The Editor's selection
property will allow the lower index to be second. This happens when the selection anchor comes after its focus. The start of the browser selection is called the anchor and the end is called the focus, but the anchor doesn't always come before the focus on the page. The anchor could come after when you click on the end of a word and drag the selection to the beginning of the word to select it. The selection could be [ 26, 20 ]
in a situation like that.
Methods on Editor
and TextChange
to modify content can be passed ranges where it makes sense. In the following example, these instructions are the same, given the editor.selection
is [ 20, 10 ]
:
editor.select([ 20, 10 ]);
// Any of these could be used to accomplish the same deletion
editor.delete(); // Delete the selected content
editor.change.delete([ 10, 20 ]).apply();
editor.change.delete([ 20, 10 ]).apply();
Typewriter uses the Delta format, borrowed from Quill.js, and builds on top of it to create its TextDocument
.
Typewriter ships with its own version of Delta
that has been slightly modified for better performance for Typewriter's immutable use and to support deep merging of attributes for comment support. A Delta can represent a whole document and can represent changes to a document. There is a great article by the Quill folks about Designing the Delta Format which is insightful. Deltas
are a representation of the document which separates structure from appearance and can be stored as JSON. Deltas are human readable and can be deterministically converted to and from HTML representations using Typesets
(described below). Delta
is used for the contents of a TextDocument
as well as the basis for changes to that document.
Typewriter's TextDocument
can be converted to and from Delta
. TextDocument
splits a Delta
document into its lines allowing for performance optimizations which are noticeable in large documents. Changes to one paragraph update only that one line in the document. Rendering updates only changed lines. And user selection translates between the Typewriter and browser much quicker. This has also allowed us to virtualize the rendering to support documents of any length, even millions of characters long. This is provided by the virtualRendering
module.
A TextDocument
contains the current selection and an array of lines. Each Line
has Delta
contents and the line attributes that specify whether the line is a paragraph, a header, or something else.
The Delta format gives us a way to associate attributes with our plain text. A Delta without any formatting will have an internal ops array that might look like this:
[
{ insert: 'What do you get when you have a cat that eats lemons?\nA sour puss\n' }
]
But if you make the first line a header and the answer italic, the delta will look like this:
[
{ insert: 'What do you get when you have a cat that eats lemons?' },
{ insert: '\n', attributes: { header: 1 } },
{ insert: 'A sour puss', attributes: { italic: true } },
{ insert: '\n' }
]
Note that block formatting are attributes attached to the newline at the end of a line, and text formatting is attributes attached to a span of text. Thus, Deltas can represent regular text along with the formatting that applies to it.
In addition to formatting (attributes), Deltas can represent embedded content like images:
[
{ insert: 'Why do hamburgers fly south for the winter?\n' },
{ insert: { image: 'https://en.wikipedia.org/wiki/Hamburger#/media/File:NYC-Diner-Bacon-Cheeseburger.jpg' } },
{ insert: '\nSo they don\'t freeze their buns!\n' }
]
And finally, Deltas can live up to their name and represent changes to a document like this fictitious spelling fix for "there" to "their":
[
{ retain: 36 },
{ insert: 'their' },
{ delete: 5 }
]
This just touches on the Delta format. You can learn more about how Deltas can be created, composed, and transformed on https://github.com/quilljs/delta/.
TextDocuments
build on Deltas
by adding selection and splitting the Delta
into its lines like this:
{
selection: null,
length: 66,
lines: [
{
attributes: { header: 1 },
content: [
{ insert: 'What do you get when you have a cat that eats lemons?' }
]
},
{
attributes: {},
content: [
{ insert: 'A sour puss', attributes: { italic: true } }
]
}
]
}
Since Typewriter changes use regular Deltas
and TextDocuments
are just Deltas
split by line, you may choose to just use Delta
and treat TextDocument
as an internal optimization.
The Editor is the core of Typewriter. It has one main property with some others:
typeset
contains theTypes
of formatting and embeds that can be used (along with how to render them)root
is theHTMLElement
Typewriter renders to.doc
, aTextDocument
, holds the state of the content and selection of the editoractiveFormats
is a hash of currently active formatting, such as{ bold: true }
, which will be applied to text inserted at the current selection.commands
is an object thatModules
andTypes
can provide API to for programmatic use. For example,editor.commands.bold()
will toggle bold formatting when bold exists in this editor's typset andeditor.commands.undo()
will undo the last action when the history module is used.modules
is a list of specific module's APIs. Example:editor.modules.history.undo()
will also undo the last action, just like the command, andeditor.modules.rendering.render()
will re-render thedoc
to theroot
.
These properties are intended to be immutable and readonly. They are regular properties, but THEY SHOULD NOT BE SET DIRECT OR ALTERED IN PLACE. You should use the methods on Editor such as insert
, select
, or setRoot
to make changes.
The Editor has several methods for updating its contents. They all update using these two methods:
set(doc: TextDocument, source: 'user' | 'api' = 'user')
will replace the current contents and selection with what is added and dispatches change events. This is used to set the entire contents of the editor and will reset the undo history.update(change: TextChange | Delta, source: 'user' | 'api' = 'user')
applies a change to the currentdoc
(creating a new one), and also dispatches change events so others know a change occurred.
All the methods are covered in more detail in the API documentation.
A Typeset is a collection of Type
definitions that are allowed in the Editor along with how they are rendered. The Editor uses it to:
- know what formatting is allowed
- translate between DOM and TextDocument, Delta and VDom (virtual DOM)
- translate indexes to DOM locations and visa versa
Because HTML is only a display mechanism for the Editor doc
, each type of data you want to display needs an HTML representation. Typesets
use CSS selectors and virtual DOM to know which elements are which and how to create them. Here is the Typeset Type
for bold:
const bold = format({
name: 'bold',
selector: 'strong, b',
styleSelector: '[style*="font-weight:bold"], [style*="font-weight: bold"]',
commands: editor => () => editor.toggleTextFormat({ bold: true }),
shortcuts: 'Mod+B',
// The JSX for this is <strong>{children}</strong> if you compile with JSX support
render: (attributes, children) => h('strong', null, children),
});
Typeset
has 3 content types: formats, embeds, and lines. Formats are inline formatting such as bold, italic, and link. Embeds are inline content that cannot be represented with simple text, such as images and line breaks (<br>). Lines are formats which apply to whole lines. Things like headers, blockquotes, paragraphs, and list items.
When parsing HTML (such as on a Paste operation) Typewriter will throw out any elements that don't match a Typeset Type
. This keeps your data clean.
Let's walk through the whole stack and see if we can understand, at least in theory, how it all works. Let's first create our Editor and add its root element to our page.
import { Editor } from 'typewriter-editor';
const editor = new Editor();
document.body.appendChild(editor.root);
The editor will have the default Typeset types which include paragraph, header, bold, italic, and more. It will also have the default modules which include keyboard, input, copy/paste, history, rendering, selection, and decorations.
Our editor will be empty (except for the required newline which must always exist, read about why on Quill's delta docs). This is what editor.getText()
looks like:
editor.getText(); // '\n'
editor.getHTML(); // '<p><br></p>'
editor.getDelta(); // 'Delta( [{ insert: '\n' }] )'
editor.doc; // 'TextDocument( [{ attributes: {}, content: [] }] )'
And the HTML in the browser will be:
<div contenteditable="true">
<p><br></p>
</div>
Empty blocks are always filled with a <br>
element to keep them open, otherwise they collapse and the user can't click into them to enter any text. View also takes care of converting spaces into non-breaking spaces when needed for display, but the contents will always just have spaces in it.
Next! We will add the text from a Shel Silverstein poem I memorized as a kid for school.
editor.select(0).insert('There‘s too many kids in this tub.\n' +
'There‘s too many elbows to scrub.\n' +
'I just washed a behind that I‘m sure wasn‘t mine.\n' +
'There‘s too many kids in this tub.'
);
Now our editor doc
will look something like this:
TextDocument({
length: 154
selection: [ 153, 153 ]
lines: [
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many kids in this tub.' }]})
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many elbows to scrub.' }]})
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'I just washed a behind that I‘m sure was) n‘t mine.' }]}
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many kids in this tub.' }]})
}
]
})
The HTML in the browser now looks like:
<div contenteditable="true">
<p>There‘s too many kids in this tub.</p>
<p>There‘s too many elbows to scrub.</p>
<p>I just washed a behind that I‘m sure wasn‘t mine.</p>
<p>There‘s too many kids in this tub.</p>
</div>
We don't see the step behind the scenes where our Delta is transformed into virtual DOM before it is applied to the actual DOM. You may never need to know or care what it looks like, but for those who are interested, this is what the virtual DOM looked like before being applied:
[
{ type: 'p', props: {}, children: ['There‘s too many kids in this tub.'] },
{ type: 'p', props: {}, children: ['There‘s too many elbows to scrub.'] },
{ type: 'p', props: {}, children: ['I just washed a behind that I‘m sure wasn‘t mine.'] },
{ type: 'p', props: {}, children: ['There‘s too many kids in this tub.'] },
]
This was created by our Typeset. We could use custom line types if we wanted.
Finally, we add a poem title and an author attribution. We will just use a regular header for the title, but perhaps we could use a custom Typeset line type for the attribution.
import { h } from 'typewriter-editor';
// Create a new block type for author attributions
editor.typeset.lines.add({
name: 'attribution',
selector: 'h3.author',
render: (attributes, children) => {
return h('h3', { class: 'author' }, children);
// If we have JSX enabled in our app we can do this instead:
// return <h3 class="author">{children}</h3>
}
});
// I'm going to insert the text first, then format the lines after
const header = 'There‘s too many kids in this tub.';
const author = 'Shel Silverstein';
editor.select(0).insert(header + '\n' + author + '\n');
editor.select(0).formatLine({ header: 1 });
editor.select(header.length + 1).formatLine({ attribution: true });
After that, our editor contents will be:
TextDocument({
length: 206
selection: [ 35, 35 ]
lines: [
{
attributes: { header: 1 },
content: Delta({ ops: [{ insert: 'There‘s too many kids in this tub.' }]})
},
{
attributes: { attribution: true },
content: Delta({ ops: [{ insert: 'Shel Silverstein' }]})
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many kids in this tub.' }]})
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many elbows to scrub.' }]})
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'I just washed a behind that I‘m sure was) n‘t mine.' }]}
},
{
attributes: {},
content: Delta({ ops: [{ insert: 'There‘s too many kids in this tub.' }]})
}
]
})
And our HTML will be:
<div contenteditable="true">
<h1>There‘s too many kids in this tub.</h1>
<h3 class="author">Shel Silverstein</h3>
<p>There‘s too many kids in this tub.</p>
<p>There‘s too many elbows to scrub.</p>
<p>I just washed a behind that I‘m sure wasn‘t mine.</p>
<p>There‘s too many kids in this tub.</p>
</div>
Editor
handles the display of content and the users keyboard input. It does not create toolbars or menus. These can be added using any framework of choice, and Typewriter provides renderless Svelte components to make some things easier.
The concept of renderless components was first introduced by the Vue framework's community. A renderless component is one which provides the functionality of a component without providing the display by passing the functionality to its children using slots. This gives complete control over how the component looks (HTML + CSS) while packaging the behavior for easier use (JavaScript). Here is an example of a Typewriter toolbar:
<script>
import { Editor } from 'typewriter-editor';
import Root from 'typewriter-editor/lib/Root.svelte';
import Toolbar from 'typewriter-editor/lib/Toolbar.svelte';
const editor = new Editor();
</script>
<Toolbar {editor} let:active let:commands>
<div class="toolbar">
<button
class="toolbar-button"
class:active={active.header === 1}
on:click={commands.header1}>H1</button>
<button
class="toolbar-button"
class:active={active.header === 2}
on:click={commands.header2}>H2</button>
<button
class="toolbar-button"
class:active={active.bold}
on:click={commands.bold}>B</button>
<button
class="toolbar-button"
class:active={active.italic}
on:click={commands.italic}>I</button>
<button
class="toolbar-button"
disabled={!active.undo}
on:click={commands.undo}>←</button>
<button
class="toolbar-button"
disabled={!active.redo}
on:click={commands.redo}>→</button>
</div>
</Toolbar>
<Root {editor}/>
<style>
.toolbar {
display: flex;
background: #eee;
padding: 8px;
margin-bottom: 8px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 2px 6px rgba(0, 0, 0, .1);
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
margin: 0;
width: 40px;
height: 40px;
margin-right: 4px;
border-radius: 4px;
border: 1px solid #ced4da;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
cursor: pointer;
}
.toolbar-button:hover {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
.toolbar-button.active {
border-color: #80bdff;
background: #eaf4ff;
}
</style>
We have complete control over the rendering of the Toolbar and it provides the helpers active
and commands
which provide the functionality.
Root
places an empty <div>
into your page and sets it as the root
of the Editor
to the doc. Root allows you to add a class, but you won't have a lot of control over it as this is not a renderless component. For more control, use the asRoot
Svelte listed below. Any content inside Root
will be set as your editor's content when loaded.
<script>
import { Editor } from 'typewriter-editor';
import Root from 'typewriter-editor/lib/Root.svelte';
const editor = new Editor();
</script>
<Root {editor}/>
<Root {editor}>
<h1>Starting Content</h1>
<p></p>
</Root>
Toolbar
is a renderless component which provides the slot props listed below. Use it to display a toolbar with UI to control you editor. You could place the toolbar at the top of your editor, at the bottom, at the top of the browser. You could have it absolute positioned or not. You can display it only when the editor is focued or always. You can dynamically hide/show buttons and menus based off available commands for greater reuse. Or you can simplify the toolbar if you know what commands will be there for your use-case.
<script>
import { Editor } from 'typewriter-editor';
import asRoot from 'typewriter-editor/lib/asRoot';
import Toolbar from 'typewriter-editor/lib/Toolbar.svelte';
const editor = new Editor();
</script>
<Toolbar {editor} let:active let:commands>
<div class="toolbar">
<button
class="toolbar-button"
class:active={active.header === 1}
on:click={commands.header1}>H1</button>
<button
class="toolbar-button"
class:active={active.bold}
on:click={commands.bold}>B</button>
<button
class="toolbar-button"
class:active={active.italic}
on:click={commands.italic}>I</button>
</div>
</Toolbar>
<div class="rich-text" use:asRoot{editor}/>
<style>
.rich-text {
/* ... */
}
.toolbar {
/* ... */
}
.toolbar-button {
/* ... */
}
</style>
The BubbleMenu
is popup menu which displays above the selected content and hides when no content is selected. It is a renderless component that provides the slot props listed below and placement
which will be a value of top
or bottom
to allow better positioning of an arrow if desired. BubbleMenu
uses Popper to position it which does a great job at keeping the menu visible when near edges.
If you want to add an arrow on your BubbleMenu, Popper will position it correctly in the center (or towards the left/right when the menu is near an edge). To get this functionality, add the attribute data-arrow
to an element and it will be positioned on the x-axis for you. You will still need to position it on the y-axis. Be sure to use the placement
property to determine where the arrow needs to point.
BubbleMenu
is not fully renderless. It wraps your menu in a <div>
so Popper can position your menu. You can still fully customize your menu and even use {#if x}
to hide your menu when necessary and the user will see nothing on the screen.
<script>
import { Editor } from 'typewriter-editor';
import asRoot from 'typewriter-editor/lib/asRoot';
import BubbleMenu from 'typewriter-editor/lib/BubbleMenu.svelte';
const editor = new Editor();
</script>
<BubbleMenu {editor} let:active let:commands let:placement>
<div class="menu">
<div data-arrow class="arrow {placement}"></div>
<button
class="menu-button"
class:active={active.header === 1}
on:click={commands.header1}>H1</button>
<button
class="menu-button"
class:active={active.bold}
on:click={commands.bold}>B</button>
<button
class="menu-button"
class:active={active.italic}
on:click={commands.italic}>I</button>
</div>
</BubbleMenu>
<div class="rich-text" use:asRoot{editor}/>
<style>
.menu {
background: #000;
/* ... */
}
.menu-button {
/* ... */
}
.arrow {
display: block;
border: 6px solid transparent;
}
.arrow.top {
bottom: -12px;
border-top-color: #000;
}
.arrow.bottom {
top: -12px;
border-bottom-color: #000;
}
</style>
The InlineMenu
is popup menu which displays at the selected or (or, optionally, hovered) empty line. It only displays when the line is the default line (usually a paragraph). It is a renderless component that provides the slot props listed below. InlineMenu
uses Popper to position it centered over the line. To show the menu when the mouse is hovered over an empty line rather than when the cursor is in the empty line, add the property hover
to it.
InlineMenu
is not fully renderless. It wraps your menu in a <div>
so Popper can position your menu. You can still fully customize your menu and even use {#if x}
to hide your menu when necessary and the user will see nothing on the screen.
<script>
import { Editor } from 'typewriter-editor';
import asRoot from 'typewriter-editor/lib/asRoot';
import InlineMenu from 'typewriter-editor/lib/InlineMenu.svelte';
const editor = new Editor();
</script>
<InlineMenu {editor} let:active let:commands hover>
<div class="menu" in:fade={{ duration: 100 }}>
<button
class="menu-button"
class:active={active.header === 1}
on:click={commands.header1}
>H1</button>
<button
class="menu-button"
class:active={active.header === 2}
on:click={commands.header2}
>H2</button>
<button
class="menu-button"
class:active={active.hr}
on:click={commands.hr}
>–</button>
</div>
</InlineMenu>
<div class="rich-text" use:asRoot{editor}/>
<style>
.menu {
/* ... */
}
.menu-button {
/* ... */
}
</style>
The asRoot
action may be used to set an Editor's root element. It gives you more flexibility than the Root
component. And content inside the element will be set as the Editor's starting content.
<div asRoot={editor}></div>
<div asRoot={editor}>
<h1>Starting Content</h1>
<p></p>
</div>
The commands prop is the editor.commands
object providing all the available commands for your component. You can use the existance of a command to optionally show/hide buttons for more reusable components.
<Toolbar let:commands>
<div class="toolbar">
{#if commands.bold}
<button on:click={commands.bold}>B</button>
{/if}
</div>
</Toolbar>
The active
prop is an object that contains all active formats for the selected text and lines and some modules (such as history). Use active
to style buttons to indicate current state.
<Toolbar let:commands let:active>
<div class="toolbar">
<button class:active={active.header === 1} on:click={commands.header1}>H1</button>
<button class:active={active.bold} on:click={commands.bold}>B</button>
</div>
</Toolbar>
The focus
prop will be true when the editor has focus (when selection is not null). This can be useful because sometimes the root element will not have focus when the Editor is still considered to have focus. This can happen when a text input field in the BubbleMenu
gains focus.
The selection
prop will contain the current editor selection range.
And there you have it. The building blocks to create the editor of your dreams! Continue learning more in our API documentation.