OpenLeaf

A rich text editor for the web that is actually free.

Apache-2.0. No paid tier, no license keys, no telemetry, no cloud dependency — and built so that it cannot quietly destroy your content.

Apache-2.0 107 KB gzipped No framework Built on ProseMirror Source on GitHub

⚠️ Pre-alpha. Not for production.

The editor works and is well tested, but nobody has run it in production and its accessibility has never been driven by a real screen reader. If you use one, trying this page and telling us what you heard is the single most valuable thing anyone can do for this project right now.

Try it

A custom element bound to a <textarea>, exactly as it would sit in a CMS form. Everything you type comes back out as ordinary HTML.

A skin is a block of CSS custom properties and nothing else — it cannot reference an internal class, so an internal rename cannot break it. Switching one keeps your undo history, because nothing is rebuilt.

Plus one declared word: a skin that replaces the surface says whether it is light or dark. Custom properties cannot reach a native <select>'s popup, scrollbar chrome, or the syntax palette above — those follow the colour scheme, so without that word they keep following your system and contradict the skin. Try Paper on a machine set to dark: the code block used to come out near-black. That also means a skin outranks the colour scheme buttons below it, since its tokens own the surface either way.

A note for integrators, learned the hard way while building this page: a &lt;textarea&gt;'s contents are escapable raw text, so character references in it are decoded when the browser reads .value. Markup you escaped once becomes live tags again on re-parse. Server templates writing stored HTML into a textarea must escape it for that context — double-escaping &amp;lt; where the content itself contains escaped markup.

What the server would receive

Updated on every change. This is the value of the bound <textarea>, so existing server code reading $_POST['body'] keeps working untouched.

Paste from Microsoft Word

This is the reason organisations pay for a rich text editor, and it is free here.

Word does not emit lists. It emits a flat run of paragraphs that merely look like a list, with the real structure hidden in a proprietary CSS property and the bullet glyph baked in as literal text. Press the button to push genuine Word clipboard HTML through the editor.

What Word puts on the clipboard


  

What OpenLeaf stores

Tables, and what "opt-in" actually means

Everyone reads and writes tables. Only sites that want table editing download the code for it.

Table node types are in the base schema, which was not the original plan. The fidelity harness is what changed it: without them, a <table> in stored content gets claimed by the preservation layer and becomes an opaque, uneditable card. Faithful, but "we read your tables and you may not touch them" is not something you can tell a CMS.

So the split falls at the editing machinery instead — cell selection, column resizing, the row and column commands, the toolbar controls. Two script tags, and the second borrows the first one's ProseMirror runtime rather than shipping a second copy:

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-tables.min.js"></script>   <!-- 12.8 KB, optional -->

The table above is live — click into it and use the table controls in the toolbar. Drag a column border to resize.

Alignment, colour and image upload

The formatting authors ask for first, and the one place OpenLeaf writes a style attribute on purpose.

Alignment and the colour marks are in the core bundle. Not because everyone wants the buttons, but because everyone has the content: <p style="text-align: center"> and <span style="color:#c00"> are what fifteen years of CMS content looks like. Without schema support for them, a coloured span is claimed by the preservation layer and becomes an opaque card — round-tripped perfectly and impossible to type in. It is the same reasoning that keeps the table schema in core while table editing is opt-in.

So OpenLeaf reads text-align, the legacy align attribute, color, background-color and <font color>, and writes back the one spelling that is still valid HTML. Two things it deliberately does not do: it does not expand your hex colours into rgb(), and it does not drop the declarations it has no opinion about. A paragraph stored as style="line-height:1.8;text-align:center" keeps its line height.

The colour picker is a 3.7 KB opt-in bundle. The swatch grid, its keyboard model and its popover are the part with real weight, and the core bundle's budget is a hard number:

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-colour.min.js"></script>   <!-- 3.7 KB, optional -->

Every swatch is a real button with the colour's name as its accessible name, so the grid works with a screen reader and in a forced-colours mode where the swatch itself conveys nothing. Arrow keys move within the grid, Escape closes and returns focus to the trigger, and there is a native <input type="color"> for anything the palette does not cover. Installing the bundle does not rearrange your toolbar: name textColor and highlightColor in the toolbar attribute, or use the ready-made LAYOUT_WITH_COLOUR.

Image upload is a hook, not an endpoint

OpenLeaf has no server, so it cannot upload anything. What it owns is the flow — pick or drop a file, hand it over, get a URL back, ask for alternative text, insert — and the transport belongs to the one part of the stack that knows your media library:

OpenLeaf.registerImageUploader(async (file) => {
  const body = new FormData()
  body.append('file', file)
  const res = await fetch('/admin/media', { method: 'POST', body })
  if (!res.ok) throw new Error('The server rejected the upload.')
  const { url, width, height } = await res.json()
  return { src: url, width, height }
})

Register one and the image dialog grows a file picker, and dropping or pasting an image file into the editor routes through the same hook. Register nothing and the picker is not offered at all — the dialog is insert-by-URL, exactly as before. There is no data: URL fallback: OpenLeaf refuses data: URLs because data:text/html is a full XSS vector, so a fallback that "worked" would produce content the schema drops on the next parse, and the author would watch their image vanish on save.

The alternative text is asked for in the same dialog as the file, which is why the upload happens when you press Save rather than when you choose the file. One decision point, and no path through it that inserts an image nobody described. If the upload fails, the dialog stays open with your description still in it.

The uploader on this page is a stand-in: there is no server behind a static demo, so it waits half a second and hands back an image that already exists in this repository. Everything else — the picker, the alt-text requirement, the drop handling, the failure message — is the real thing. Try dropping a PNG onto the editor above, and try naming a file broken.png to see a failure.

One consequence worth knowing before you turn this on: under a strict Content-Security-Policy without unsafe-inline in style-src, a browser refuses to parse style attributes — so aligned and coloured content will not render on the published page, wherever it came from. The editor itself keeps working, because it falls back to a CSSOM write, which CSP does not gate.

Typography, and why the storage format came first

Font family, size and line height are in the toolbar. They are in the schema first, which is the part that decides whether an inherited document is editable at all.

A schema-based editor has to decide what it understands. Anything it does not understand, OpenLeaf keeps as an opaque atom: faithful, movable, and not editable. So for an archive full of <font face> and font-family spans, the first question is not "is there a dropdown" — it is whether that text is a paragraph you can fix a typo in, or a grey card you cannot get a caret into. That is why these landed in the storage format one release before the controls arrived.

The editor below is seeded with exactly that kind of markup. Change a font with the picker, and also click into any line and type — both work, and the second one is the one that was ever in doubt:

Open source to see what it stored. Three things changed on the way in, and each is a deliberate modernisation rather than a loss:

In the toolbar: fontFamily, fontSize, lineHeight, indent and outdent, all five in the default layout. Subscript and superscript are Mod+= and Mod+Shift+=; indent and outdent also answer to Mod+] and Mod+[. All four are in the F1 list.

Text direction, per-run language, list style and clearFormatting have no control yet. They are commands, and a preset list is now a declared item type rather than a bespoke widget — the built-in pickers are registered exactly this way:

import { registerToolbarItem } from '@openleaf-editor/ui'
import { setListStyle, activeListStyle, LIST_STYLES } from '@openleaf-editor/core'

registerToolbarItem({
  id: 'listStyle',
  type: 'select',
  label: 'List style',
  options: [{ value: '', label: 'Default' },
            ...LIST_STYLES.map((s) => ({ value: s, label: s }))],
  getValue: (state) => activeListStyle(state) ?? '',
  applyValue: (value) => setListStyle(value === '' ? null : value),
})

FONT_FAMILIES, FONT_SIZE_PRESETS and LINE_HEIGHT_PRESETS are exported for the same purpose, each with an active* predicate so a control can show the current value. Pick an id that is not already taken — the five above are registered by default.

Inserting things that are not text

Media, collapsible sections, anchors, symbols and page breaks — one opt-in bundle, and the node types they store are in core.

Same split as tables. <figure>, <video>, <details> and allowlisted <iframe> embeds are in the base schema so stored documents stay editable; the dialogs, the character map and the drag-resize handles are the download.

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-insert.min.js"></script>   <!-- 5.0 KB, optional -->

One shape worth calling out, because it used to be destroyed rather than merely left uneditable. A player whose addresses are all in <source> children has no src of its own:

<video controls>
  <source src="/clip.webm" type="video/webm">
  <source src="/clip.mp4"  type="video/mp4">
</video>

OpenLeaf declined that element, and the preservation layer's drop rule for declined media then deleted it — so the whole player, and every address in it, saved as <p></p>. It is a real node now, with its sources and any <track> captions kept on it. Media carrying fallback content for browsers that cannot play the file is preserved whole instead of having the fallback stripped.

Syntax highlighting and source formatting

A second opt-in bundle, 6.1 KB. It colours code blocks and it formats the HTML source view — which is arguably the bigger win.

Press HTML source in the toolbar. The editor serializes to one long line, which is correct output and unreadable source, so the bundle indents it and highlights it. The formatting is verified, not trusted: the reindented text is only shown after it has been proved to parse to exactly the same document. If that check fails you get the original, unformatted. A formatter that cannot demonstrate it preserved your document does not get to touch it.

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-highlight.min.js"></script>   <!-- 6.1 KB, optional -->

The built-in tokenizer covers HTML, CSS and JavaScript in 1.9 KB — which is the complete set for a source view of the editor's own output, and honestly not enough for a code block containing Python. So the highlighter is a seam rather than a fixture: setHighlighter() swaps in Prism, refractor or highlight.js in about five lines. Measured, for the record: refractor with three languages is 14 KB and 19 dependencies, against 1.9 KB and none.

Importing a file

A third opt-in bundle, 2.8 KB. A toolbar button and drag-and-drop.

HTML and plain text import with no dependency at all — and that covers more than it sounds, because Word's own Save as Web Page produces exactly the mso-list markup the paste normalizer was written to reconstruct. Importing an HTML file therefore gets you real nested lists out of a Word document for zero extra bytes.

Download the .docx to drag in yourself Download the HTML export

The button drops the file into the editor above through the real drag-and-drop path — scroll up to see the result. The sample is a genuine Word HTML export, mso-list bullets and all. Its <title> and <style> block are deliberately not imported.

Imported content is inserted at the cursor, never used to replace the document. Replacing is something you can do by selecting all first; silently discarding what you had written is not recoverable afterwards.

.docx works, and it is a separate bundle. Measured: mammoth is 122.9 KB gzipped, larger than the entire editor, so a site that only imports HTML must not pay for it. Load one more script and Word documents work; do not, and they do not.

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-import.min.js"></script>        <!-- 2.8 KB -->
<script src="/js/openleaf-import-docx.min.js"></script>   <!-- 122.9 KB, Word only -->

Images are the one thing that does not come across. Word embeds them in the file and mammoth inlines them as data: URIs, which OpenLeaf blocks on purpose — data:text/html is a full XSS vector. So they are dropped and counted, and the count is reported to you rather than logged, because you need to know while the original is still open.

There is deliberately no PDF import. PDF is a layout format — positioned glyphs, not paragraphs. There is no heading, list or table in a PDF, only text arranged to look like one, so every converter guesses and the guesses fail the same way: line breaks become paragraph breaks, headings vanish, multi-column layouts interleave, tables arrive as unrelated numbers. A feature called "import" that reliably destroys structure is the failure this project exists to avoid, with your permission attached.

Keyboard and screen readers

The whole toolbar is a single tab stop, and Tab always leaves the editor.

Keyboard shortcuts
KeysAction
Alt+F10Move focus into the toolbar
EscReturn focus and the selection to the content
Move between toolbar buttons
Ctrl/+B I UBold, italic, underline
Ctrl/+Alt+16Headings
Ctrl/+Shift+7 / 8Numbered / bulleted list
Ctrl/+[ ]Outdent / indent a list item
TabLeaves the editor. Deliberately never captured.

Formatting changes are announced through a polite live region — but only on a real change, never when the cursor merely moves through already-bold text. Toggling bold with the keyboard says “Bold on”, because otherwise nothing observes it.

Finding, counting, and recovery

The authoring tools a CMS form already expects, as a separate bundle so a comment box does not download them.

Find and replace, a word count, autosave to this browser with restore on return, a warning before leaving with unsaved changes, Save as a toolbar action (it submits the form, or calls a callback you register), print, a read-only preview, and new document. Load the bundle and name the controls:

<script src="/js/openleaf.min.js"></script>
<script src="/js/openleaf-session.min.js"></script>

<openleaf-editor toolbar="bold italic | find wordCount save preview print newDocument | source"></openleaf-editor>

Autosave, restore, the leave warning, and the word-count status attach to every editor once the bundle is present — they are not toolbar items. Save submits the bound form by default; OpenLeaf.registerSaveHandler(fn) is the callback path, and a cancelable openleaf:save event is the hook for anything else.

Chrome around the canvas

A menubar, floating toolbars, fullscreen, a shortcut sheet and visual aids. All optional, all attributes.

The editor below turns most of it on at once. Right-click a link, an image or a table for a context menu; select some text for the floating toolbar; press F1 for the shortcut sheet. Visual aids marks empty blocks and non-breaking spaces, which is the difference between "there is a stray nbsp in this paragraph" and an hour of squinting.

<openleaf-editor
  for="body"
  menubar
  toolbar="bold italic | link | help fullscreen visualAids"
  selection-toolbar="bold italic | link"
  insert-toolbar="image insertTable"
  content-css="/css/article.css"
></openleaf-editor>

menubar takes a list, so menubar="edit help" gives exactly those two. Left bare it means all five.

When the toolbar does not fit

Wrapping keeps every control visible and is the default, because a control that has silently moved into a menu is a control an author cannot find. toolbar-overflow opts into collapsing the groups that do not fit into a More menu instead. The editor below is deliberately narrow:

Translations

lang sets the interface language per editor, so two editors on one page can differ. Register the strings you want to override and leave the rest in English — a missing translation shows the source string rather than a key:

OpenLeaf.registerTranslations('fr', { Bold: 'Gras', Italic: 'Italique' })

A smaller toolbar

Plugins declare capability. Integrators declare layout. Installing a plugin never silently rearranges somebody's toolbar.

<openleaf-editor for="comment" toolbar="bold italic | link | undo redo"></openleaf-editor>

React, Vue and Angular

Thin hosts around the same custom element. The editor is not reimplemented once per framework.

Each wrapper forwards attributes, keeps a controlled value in sync and re-emits openleaf:change. Nothing about editing lives in them, which is the point: three copies of a schema is three sets of bugs, and a node built by one is not a node type another accepts.

// React
import { OpenLeafEditor } from '@openleaf-editor/react'
<OpenLeafEditor value={html} onOpenLeafChange={setHtml} toolbar="bold italic | link" />

// Vue — v-model maps onto the element's value
import { OpenLeafEditor } from '@openleaf-editor/vue'
<OpenLeafEditor v-model="html" toolbar="bold italic | link" />

// Angular — standalone component, [(value)] two-way binding
import { OpenLeafComponent } from '@openleaf-editor/angular'
<openleaf [(value)]="html" toolbar="bold italic | link"></openleaf>

If you are not using a framework, skip all three. The element works on its own and that is the supported path, not a fallback.

Using it

No build step. A script tag and an element.

<form method="post">
  <label for="body">Post body</label>
  <openleaf-editor for="body" aria-label="Post body"></openleaf-editor>
  <textarea id="body" name="body" hidden><?= $post->body ?></textarea>
  <button type="submit">Save</button>
</form>

<script src="/js/openleaf.min.js"></script>

Sanitize on the server. Client-side sanitization is a user-experience feature, not a security control — anything the editor strips can be put back with developer tools. Treating editor output as trusted HTML is a vulnerability in your application, and no configuration of OpenLeaf can fix it.