YurbaEditor

YurbaEditor

A full-featured lightweight WYSIWYG editor in a single dependency-free file — rich text in, clean allowlist-sanitized HTML out.

Usage
<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.

Full editor
Stored value — getHTML()

            
Compact toolbar
Bare input — no toolbar / footer

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.

Signature
const editor = YurbaEditor.create(options?)
Options
OptionTypeDefaultDescription
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…>' }.
Methods
MethodReturnsDescription
getHTML()stringSanitized HTML; empty string when blank.
setHTML(html)thisReplace the content.
getText()stringPlain-text content.
getCount(){ words, chars }Word / character count; works even with the footer hidden.
focus()thisFocus the editing surface.
on(event, cb)thisSubscribe to 'change' (html) or 'count' ({ words, chars }).
destroy()voidRemove the editor and restore the target.
Events

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))
Static

Members on the YurbaEditor global (the custom-element class). They need no instance.

MemberDescription
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_TOOLBARThe default array of toolbar tokens — read it to build a custom toolbar from the default set.
YurbaEditor.EMBED_HOSTSThe 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.

Available tokens
undoredoheadingbolditalicunderlinestrikelowercasecapitalizeuppercasesupsubforecolorbackcoloralignleftaligncenteralignrightalignjustifyuloloutdentindentlinkimagevideotablehrblockquotecodecodeblockclearfindsourcefullscreen

'|' renders a separator between groups.

YurbaEditor.create({
    field: '#body',
    toolbar: ['heading', '|', 'bold', 'italic', 'forecolor', '|', 'ul', 'ol', 'link', 'clear']
})

Context menu

Right-clicking (or long-pressing) text opens a built-in menu — it works even with the toolbar hidden, so it is the primary way to format and search a bare input.

Default items

Select all / Copy / Paste / Clear, a Formatting submenu (Clear formatting, bold, italic, underline, strikethrough, inline code, link), a Case submenu (lowercase / Capitalize / UPPERCASE), and Find & replace. Right-clicking a link, image, or table cell still opens its own menu.

Disable or customize
YurbaEditor.create({ field: '#body', contextMenu: false })

Pass an array to replace the items. Each is { label, icon, action | onClick, children, separator, danger }icon is a Material Symbols name, action a built-in id (selectAll, copy, paste, clear, clearFormat, find, bold, italic, underline, strike, code, link, lower, capitalize, upper), onClick(editor) runs custom code, children makes a submenu, and danger tints it red.

YurbaEditor.create({
    field: '#body',
    contextMenu: [
        { action: 'selectAll', icon: 'select_all', label: 'Select all' },
        { separator: true },
        { icon: 'auto_awesome', label: 'Insert date', onClick: ed => ed.focus() }
    ]
})

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.

Endpoint upload
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.

CSS variables
VariableDefault (light)Role
--ye-surface#ffffffEditor background
--ye-surface-2#f9fafbToolbar, code, cell fills
--ye-border#e5e7ebDividers, light borders
--ye-border-strong#d1d5dbEditor frame, inputs, table cells
--ye-text#1f2937Body text
--ye-text-soft#374151Toolbar icons, secondary text
--ye-muted#6b7280Placeholder, counts, hints
--ye-accent#2563ebLinks, focus, active state
--ye-accent-ink#1d4ed8Text/icon on accent fills
--ye-accent-soft#dbeafeActive-button / highlight fill
--ye-danger#dc2626Destructive actions
--ye-danger-soft#fef2f2Destructive-action fill
--ye-radius9pxCorner radius
--ye-shadowelevationPopup / menu shadow
--ye-fontsystem UI stackEditor font family
Custom accent & radius
.ye {
    --ye-accent: #16a34a;
    --ye-radius: 14px;
}
Dark mode

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.

What sanitize enforces

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.