YurbaEditor

YurbaEditor

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

Usage
<link rel="stylesheet" href="/dist/yurba-editor.min.css">
<script src="/dist/yurba-editor.min.js"></script>

This is the standalone build. On a page that loads YurbaUI use the .ui build instead, see Builds.

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

Builds

Two builds of the same editor with the same API. They differ in who draws the menus and dialogs: the editor itself, or YurbaUI.

Which one
BuildFilesMenus and dialogs
yurba-editor dist/yurba-editor.min.js
dist/yurba-editor.min.css
Its own popups, no dependencies. Pick it for a page without YurbaUI.
yurba-editor.ui dist/yurba-editor.ui.min.js
dist/yurba-editor.ui.min.css
Toolbar menus are YurbaUI.Dropdown, the right-click and link / image / table menus are YurbaUI.ContextMenu, the link / image / video prompts and Find & replace open in a YurbaUI.Modal. Pick it for a page that already loads YurbaUI.
Standalone
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0">
<link rel="stylesheet" href="/dist/yurba-editor.min.css">
<script src="/dist/yurba-editor.min.js"></script>

Its CSS styles the .y-dropdown classes of its right-click menu at zero specificity, so if YurbaUI's CSS is on the page too, YurbaUI's look wins.

With YurbaUI
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0">
<link rel="stylesheet" href="/dist/yurba-ui.min.css">
<link rel="stylesheet" href="/dist/yurba-editor.ui.min.css">
<script src="/dist/yurba-ui.min.js"></script>
<script src="/dist/yurba-editor.ui.min.js"></script>

Load YurbaUI first. The build calls the YurbaUI global when an editor with toolbar menus is created and whenever a menu or dialog opens, and has no fallback to its own popups.

Both builds

Icons are Material Symbols Rounded, so the page loads that font (any built-in icon can be replaced with the icons option, see Icons). Colors and radii follow the Yurba theme variables (--yurba-main-color, --yurba-brand-color and others) when the page defines them, see Theming. Each build also has non-minified .js and .css in dist/.


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, images are dropped (except glyphClass ones), 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).
glyphClass optional string - Images with this class act as text glyphs (e.g. emoji from a picker): the class survives sanitization, they stay in inline mode, and they can't be selected, resized or opened in the image menu. Their src may also be a data:image/ URL, such as an animated emoji's first frame drawn in the page.
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 built-in icons, e.g. { bold: '<svg…>' }. Each value is an HTML string inserted as given; keys left out keep the default. Keys are listed in Icons.
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, allowClasses, and strict (false for the looser mode the editor uses by default).
YurbaEditor.DEFAULT_TOOLBARThe default array of toolbar tokens - read it to build a custom toolbar from the default set.
YurbaEditor.DEFAULT_CONTEXT_MENUThe default right-click menu items - read it to build a custom menu 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, key, action | onClick, children, separator, danger } - icon is a Material Symbols name or your own HTML (which wins over the icons option), key picks an entry of the icons option that replaces it (see Icons), 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() }
    ]
})

Icons

Every built-in icon goes through the icons option: an object of key to HTML string. Values are inserted as HTML as given, keys left out keep the default. Both builds use the same keys.

Keys
KeysWhereDefault
undo redo bold italic underline strike sup sub lowercase capitalize uppercase alignleft aligncenter alignright alignjustify ul ol outdent indent link image video table hr blockquote code codeblock clear find source fullscreenToolbar buttons, by tokenMaterial Symbols icon
forecolor backcolorText color and highlight buttonsA
caretArrow of the paragraph style button▾
color-noneRemove color swatch in the color menus✕
edit open removeLink menuMaterial Symbols icon
align-left align-center align-right align-none alt img-delImage menuMaterial Symbols icon
row-above row-below col-left col-right cell-left cell-center cell-right merge-right merge-down split row-del col-del header grid delTable menuMaterial Symbols icon
find-prev find-next find-closeFind & replace. The .ui build hides the close button, its dialog has its ownMaterial Symbols icon, ✕ for close
select-all copy paste clear-all formatting caseContext menu items without a toolbar twin (clear-all is Clear, which empties the editor)The item's icon
clear bold italic underline strike code link lowercase capitalize uppercase findContext menu items, shared with the toolbar key (clear is Clear formatting)The item's icon
submenuSubmenu arrow of the context menu. Standalone build only, in the .ui build YurbaUI draws it›

A context menu item, built-in or custom, takes its key from key, else from action: selectAll uses select-all, clear uses clear-all, clearFormat uses clear, lower and upper use lowercase and uppercase, any other action is its own key. With no entry in icons it shows its icon.

YurbaEditor.create({
    field: '#body',
    icons: {
        bold: '<svg class="ye-ico" viewBox="0 0 24 24">…</svg>',
        copy: '<i class="fa fa-copy"></i>',
        caret: '<i class="fa fa-caret-down"></i>'
    }
})

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-on-accent#ffffffText on a solid accent fill
--ye-hover#0000000fHover fill of buttons and menu items
--ye-fill#00000021Hover fill of neutral buttons
--ye-danger#dc2626Destructive actions
--ye-danger-soft#fef2f2Destructive-action fill
--ye-radius14pxCorner radius
--ye-shadowelevationPopup / menu shadow
--ye-fontpage fontEditor font family (unset: inherited)
Custom accent & radius
.ye {
    --ye-accent: #16a34a;
    --ye-radius: 8px;
}
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.