YurbaEditor
A full-featured lightweight WYSIWYG editor in a single dependency-free file — rich text in, clean allowlist-sanitized HTML out.
<link rel="stylesheet" href="/dist/yurba-editor.min.css">
<script src="/dist/yurba-editor.min.js"></script>
Point it at a <textarea> — the library hides it and keeps its value in sync, so the form submits HTML normally.
<form>
<textarea name="body"><p>Hello</p></textarea>
</form>
YurbaEditor.create({ field: 'textarea[name=body]' })
Or mount it standalone — it is a custom element, so you can also write <yurba-editor></yurba-editor> directly and any inner markup becomes the initial value.
const editor = YurbaEditor.create({ mount: '#editor', value: '<p>Start here</p>' })
console.log(editor.getHTML())
Live demo
Everything you type is sanitized and mirrored below as it would be stored.
A drop-in for a contenteditable input: toolbar: false, footer: false, inline: true — Enter is a line break, no block paragraphs. Formatting via right-click menu and shortcuts (Ctrl/⌘+B/I/U).
Creating an editor
YurbaEditor is a custom element (<yurba-editor>). Create one with the factory and it returns the element. Pass field to sync a form control, or mount to place it in a container.
const editor = YurbaEditor.create(options?)
| Option | Type | Default | Description |
|---|---|---|---|
field optional |
string | Element | — |
A <textarea>/<input> to hide and keep in sync. The editor is inserted right after it. |
mount optional |
string | Element | — |
Container to append the editor into. Omit both to get a detached element you place yourself. |
toolbar optional |
string[] | false | full set |
Toolbar tokens (see reference); '|' is a separator. false hides the toolbar entirely. |
footer optional |
boolean | true |
false hides the footer (brand link + word/char count). |
inline optional |
boolean | false |
Inline mode: Enter inserts <br>, blocks (headings/lists/quotes/tables) are flattened, and markdown/block shortcuts are off — the value stays one flow of text. |
value optional |
string | target value | Initial HTML. Falls back to the textarea value / element innerHTML. |
placeholder optional |
string | 'Start writing…' |
Empty-state text. |
minHeight optional |
number | 160 |
Minimum editing height, in pixels. |
height optional |
number | — | Max height (px) before the surface scrolls. |
embedHosts optional |
string[] | YouTube / Vimeo | Allowed <iframe> hosts for video embeds. |
allowData optional |
boolean | false |
Keep data-* attributes through sanitization (off by default — they are stripped). |
allowClasses optional |
boolean | false |
Keep all class attributes (off by default — classes are filtered to the internal allowlist). |
contextMenu optional |
boolean | array | built-in | Right-click / long-press text menu. false disables it (native menu shows); an array replaces its items (see Context menu). |
onChange optional |
function | — | Called with the sanitized HTML on every change. |
onCount optional |
function | — | Called with { words, chars } whenever the count changes. |
maxChars optional |
number | — | Hard character cap; typing past it is blocked and the footer shows n / max. |
uploadUrl optional |
string | — | Endpoint the editor POSTs an image to (multipart), expecting { url } back. Enables the upload UI. |
onImageUpload optional |
function | — | file => Promise<url> custom uploader; takes precedence over uploadUrl. |
uploadField optional |
string | 'file' |
Multipart field name for uploadUrl. |
uploadHeaders optional |
object | {} |
Extra request headers for uploadUrl (e.g. CSRF). |
maxImageKb optional |
number | — | Client-side size limit before upload. |
icons optional |
object | — | Override toolbar icons by token, e.g. { bold: '<svg…>' }. |
| Method | Returns | Description |
|---|---|---|
getHTML() | string | Sanitized HTML; empty string when blank. |
setHTML(html) | this | Replace the content. |
getText() | string | Plain-text content. |
getCount() | { words, chars } | Word / character count; works even with the footer hidden. |
focus() | this | Focus the editing surface. |
on(event, cb) | this | Subscribe to 'change' (html) or 'count' ({ words, chars }). |
destroy() | void | Remove the editor and restore the target. |
Two events fire: change (the sanitized HTML) on every edit, and count ({ words, chars }) when the word/char count changes. Subscribe three ways — the onChange/onCount options, the chainable on() method, or native DOM events on the element (yurba-editor.<name>, payload in event.detail).
const ed = YurbaEditor.create({ field: '#body' })
ed.on('change', html => save(html))
ed.on('count', ({ words, chars }) => label.textContent = `${words} words · ${chars} chars`)
ed.addEventListener('yurba-editor.count', e => console.log(e.detail))
Members on the YurbaEditor global (the custom-element class). They need no instance.
| Member | Description |
|---|---|
YurbaEditor.create(options?) | The factory that builds a <yurba-editor> and returns it (see Creating an editor). |
YurbaEditor.sanitize(html, opts?) | Run the same allowlist sanitizer on a string — e.g. to clean stored HTML before rendering it. opts takes embedHosts, allowData, and allowClasses. |
YurbaEditor.DEFAULT_TOOLBAR | The default array of toolbar tokens — read it to build a custom toolbar from the default set. |
YurbaEditor.EMBED_HOSTS | The default <iframe> embed-host allowlist (YouTube / Vimeo). |
// sanitize stored HTML before printing it — no editor needed
el.innerHTML = YurbaEditor.sanitize(post.body)
// start from the default toolbar, minus the media buttons
const toolbar = YurbaEditor.DEFAULT_TOOLBAR.filter(t => !['image', 'video', 'table'].includes(t))
YurbaEditor.create({ field: '#body', toolbar })
Toolbar tokens
Pass any subset as the toolbar option to control which controls appear and in what order.
undoredoheadingbolditalicunderlinestrikelowercasecapitalizeuppercasesupsubforecolorbackcoloralignleftaligncenteralignrightalignjustifyuloloutdentindentlinkimagevideotablehrblockquotecodecodeblockclearfindsourcefullscreen
'|' renders a separator between groups.
YurbaEditor.create({
field: '#body',
toolbar: ['heading', '|', 'bold', 'italic', 'forecolor', '|', 'ul', 'ol', 'link', 'clear']
})
Image upload
By default the image button only inserts by URL. Provide uploadUrl (or onImageUpload) and it becomes an Upload image… / By URL… menu, and the editor also accepts drag-and-drop and paste of image files.
YurbaEditor.create({
field: '#body',
uploadUrl: '/admin/_editor/upload',
uploadHeaders: { 'X-CSRF-TOKEN': token },
maxImageKb: 4096
})
The endpoint receives a multipart POST (field name file, override with uploadField) and must respond { "url": "/path/to/image.jpg" }. The URL is re-validated before insertion. For custom flows (e.g. S3 presigned) use onImageUpload(file) => Promise<url> instead. Validate on the server and reject SVG.
Theming
Every color, the corner radius, the shadow and the font are --ye-* CSS variables on .ye. Override any of them on the editor element or an ancestor to reskin — no build step.
| Variable | Default (light) | Role |
|---|---|---|
--ye-surface | #ffffff | Editor background |
--ye-surface-2 | #f9fafb | Toolbar, code, cell fills |
--ye-border | #e5e7eb | Dividers, light borders |
--ye-border-strong | #d1d5db | Editor frame, inputs, table cells |
--ye-text | #1f2937 | Body text |
--ye-text-soft | #374151 | Toolbar icons, secondary text |
--ye-muted | #6b7280 | Placeholder, counts, hints |
--ye-accent | #2563eb | Links, focus, active state |
--ye-accent-ink | #1d4ed8 | Text/icon on accent fills |
--ye-accent-soft | #dbeafe | Active-button / highlight fill |
--ye-danger | #dc2626 | Destructive actions |
--ye-danger-soft | #fef2f2 | Destructive-action fill |
--ye-radius | 9px | Corner radius |
--ye-shadow | elevation | Popup / menu shadow |
--ye-font | system UI stack | Editor font family |
.ye {
--ye-accent: #16a34a;
--ye-radius: 14px;
}
Dark mode remaps the color variables only — radius, shadow and font stay. It applies automatically under prefers-color-scheme: dark; opt a single editor out with the ye--light class, or force dark anywhere with ye--dark. Toggle it live with the switch in the header above.
Security
The value is HTML — render it as such, and re-sanitize on the server. The browser is never the only gate.
A tag allowlist, a per-tag attribute allowlist, style filtered to a safe property set, javascript:/data: URLs rejected, and <iframe> restricted to the configured embed hosts. YurbaEditor.sanitize() applies the exact same rules.