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
<textarea>'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 &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:
<font face="Verdana">became<span style="font-family:Verdana">. A legacy tag whose every attribute a mark can hold gets modelled; one carrying more than that is still preserved whole.<ol type="a">becamelist-style-type:lower-alpha, the same conversionalign="center"has always had.- Declarations OpenLeaf models come back in its own spelling and order. Anything it does not model goes back byte-for-byte.
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.
| Keys | Action |
|---|---|
| Alt+F10 | Move focus into the toolbar |
| Esc | Return focus and the selection to the content |
| ← → | Move between toolbar buttons |
| Ctrl/⌘+B I U | Bold, italic, underline |
| Ctrl/⌘+Alt+1–6 | Headings |
| Ctrl/⌘+Shift+7 / 8 | Numbered / bulleted list |
| Ctrl/⌘+[ ] | Outdent / indent a list item |
| Tab | Leaves 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.