tosijs-styled-editor
v0.4.5
Published
Rich text editor web component — no contentEditable, no browser selection APIs
Readme
A Rich Text Editor Component
<tosijs-styled-editor widgets="default" localized>
<h2>Try it</h2>
<p>This is a live editor. Click anywhere to place the cursor and start typing,
then use the menus and toolbar above to format what you write. The 🇬🇧 menu
switches the interface to Suomi.</p>
<p>Double-click selects a word, triple-click selects a block, and clicking past
the end of a line puts the cursor at the end of that line.</p>
<ul>
<li>Select these two items</li>
<li>Press the numbered-list button to renumber them</li>
</ul>
</tosijs-styled-editor>tosijs-styled-editor {
--editor-ink: #27488c;
}A pure web-component. What it does not use:
- No
document.execCommand - No
contentEditable - No
getSelection, no browser selection model, noexecCommand-era APIs
Range is used, but only as a measuring tape — getBoundingClientRect() to
ask the layout engine where a character is. It is never a selection model, and
nothing is handed back to the browser to edit.
What you get instead:
- Fully styleable selections
- Exact control over editing behavior
- Exact control over cursor behavior
- Touch-friendly selection
Development
bun install
bun run tls # once — locally-trusted dev certs (needs mkcert)
bun start # dev server + doc site on https://localhost:8789
bun test # unit tests
bun run lint # typecheck, including unused locals/params
bun run format # Prettier
bun run make # full build (NOT `bun run build` — `bun build` is a Bun builtin)bin/site.ts is the only build/dev entry; it wraps tosijs-ui's doc system and
is configured in tosijs-editor-site.config.ts.
Live site and docs: https://editor.tosijs.net
Installation
npm install tosijs-styled-editorPeer dependencies (you install these): tosijs, tosijs-ui
Runtime dependency (installed automatically):
tosijs-kilpi — the sanitizer applied
to pasted and dropped content. It has no dependencies of its own and is about
0.8 kB gzipped. The drop-in dist/index.js build bundles it; the ESM build
leaves it external so you get one copy if you also depend on it directly.
Usage
Drop it in and it is editable. widgets picks a built-in toolbar preset —
none (the default), minimal, or default (toolbar + menus), as in the
example above.
<tosijs-styled-editor widgets="default">
<p>Edit this text!</p>
<p>It supports <b>bold</b>, <i>italic</i>, and more.</p>
</tosijs-styled-editor>The whole chrome is mixed from one custom property, so re-theming is a single value:
<style>
tosijs-styled-editor { --editor-ink: #27488c; }
</style>Setting up toolbars and menus
import { tosijsStyledEditor, type TosijsStyledEditor } from 'tosijs-styled-editor'
import { defaultToolbar, defaultMenubar } from 'tosijs-styled-editor'
const editor = tosijsStyledEditor() as TosijsStyledEditor
editor.value = '<p>Hello world</p>'
document.body.appendChild(editor)
// Add menus (slot="menubar")
for (const menu of defaultMenubar(editor)) {
editor.appendChild(menu)
}
// Add toolbar buttons (slot="toolbar")
for (const widget of defaultToolbar()) {
widget.setAttribute('slot', 'toolbar')
editor.appendChild(widget)
}How It Works
The editor uses three layers:
- DOM utilities (
dom-utils.ts) — leaf-node traversal; nearly all operations work with leaf nodes - Selection (
selection.ts) — a custom selection model. Character positions are found by MEASURING with a Range, which does not touch the document; wrapping characters in spans to measure them changes the thing being measured (it breaks shaping, so cursive scripts come apart and lines re-wrap) - Commands (
commands.ts) — extensible command system for formatting and editing
The caret is an <input> element, so mobile browsers show their keyboard automatically.
Security: what is sanitized, and what is not
Replacing contentEditable also means replacing the sanitization the browser was
doing on your behalf.
Sanitized — content arriving from outside the document, which is the path an attacker controls: paste and drop, both through one shared choke point.
The filtering itself is tosijs-kilpi,
and its SECURITY.md is the authoritative policy —
read it before relying on this. It is deliberately not restated here, because a
copy of a policy drifts from the policy. The one thing worth repeating, because
it is a trade rather than a detail:
kilpi is a denylist for elements and attributes and an allowlist for URL schemes. That is why unknown elements survive — your plugin markup round-trips intact — and it is also why an element that becomes dangerous in a future browser, and that kilpi has never heard of, would pass through. If protection from the not-yet-known matters more to you than preserving unknown markup, use DOMPurify instead (see the hook below).
NOT sanitized — content you supply, which is inside your own trust boundary:
editor.value = html- initial light-DOM content
If you are upgrading from 0.4.3 or earlier: documents your users created
before 0.4.4 may already contain a payload that was pasted in, and this component
cannot fix that for you — setting value does not filter. Sanitize your stored
corpus as part of the upgrade.
Using a different sanitizer. editor.sanitize is the hook — it receives a
detached element and mutates it:
editor.sanitize = (root) => {
DOMPurify.sanitize(root, {
IN_PLACE: true,
FORBID_TAGS: ['style'],
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: /^[a-z][a-z0-9]*-[a-z0-9-]*$/,
attributeNameCheck: /^data-|^slot$|^dir$/,
},
})
}It takes an element rather than an HTML string on purpose: a string signature
would force a serialize-and-reparse round trip, and that round trip is where
mutation XSS lives. Note the CUSTOM_ELEMENT_HANDLING block — DOMPurify unwraps
unknown custom elements by default, which would discard plugin markup kilpi
preserves.
Reporting a vulnerability. If it is in the sanitizer, file it against kilpi — that is where the code lives. Anything else, this repository.
Keyboard Behavior
General editing
| Key | Action | | ---------------------------------- | ------------------------------------------------------------ | | Typing | Inserts character at caret; replaces selection if any | | Backspace | Deletes character before caret, or deletes selection | | Delete | Deletes character after caret, or deletes selection | | Enter | Splits the current block at the caret | | ArrowLeft / ArrowRight | Moves caret one character | | ArrowUp / ArrowDown | Moves caret one visual line, maintaining horizontal position | | Alt+ArrowLeft / Alt+ArrowRight | Moves caret one word | | Shift+Arrow | Extends selection |
Mouse selection
| Action | Selects | | ---------------- | ----------------------------------- | | Click | Places caret at character position | | Shift+Click | Extends selection to click position | | Double-click | Selects word | | Triple-click | Selects block | | Click-drag | Selects character range |
Inside a table cell
| Key | Action |
| ---------------------- | ---------------------------------------------------------------- |
| Tab | Move to next cell; at last cell, creates a new row |
| Shift+Tab | Move to previous cell |
| ArrowDown | Move to cell below; at last row, exit table downward |
| ArrowUp | Move to cell above; at first row, exit table upward |
| Shift+Enter | Move to cell below (same column); at last row, creates a new row |
| Shift+ArrowDown | Same as Shift+Enter |
| Enter | Insert line break (<br>) within the cell |
| Backspace / Delete | Delete within cell only (won't escape the cell) |
Inside a list item (<ul>/<ol>)
| Key | Action |
| ------------- | ---------------------------------------------------------------------- |
| Enter | Split into a new list item; if item is empty, exit the list as a <p> |
| Backspace | Delete within item; at start, merge with previous item or exit list |
| Delete | Delete within item; at end, merge with next item |
Keyboard shortcuts
Shortcuts are defined by toolbar buttons via data-shortcut attributes. The default toolbar provides:
| Shortcut | Action | | -------------- | --------- | | Ctrl/Cmd+B | Bold | | Ctrl/Cmd+I | Italic | | Ctrl/Cmd+U | Underline | | Ctrl/Cmd+Z | Undo | | Ctrl/Cmd+Y | Redo |
Commands
Commands are invoked via editor.doCommand(commandString). Multiple commands
can be chained with semicolons.
Character styling — setText
Applies CSS to selected characters:
setText font-weight bold
setText font-style italic
setText text-decoration underline
setText background-color rgba(255,255,64,0.5)
setText font-family Helvetica
setText font-size 18pxUse + for spaces in values: setText font-family Times+New+Roman
Block type — setBlockType
Changes the element type of selected blocks:
setBlockType h1
setBlockType p
setBlockType pre
setBlockType blockquoteBlock styling — setBlocks
Applies CSS to selected blocks:
setBlocks text-align center
setBlocks margin-left 40px
setBlocks line-height 2.5Drag and drop
Selected text becomes a real draggable object — the selection is marked on elements, which is exactly what HTML5 drag and drop wants, so dragging works between windows, between browsers, and to and from the desktop with no extra machinery.
Every drag offers both representations, and the receiver picks:
| type | what it gets |
| ------------ | ----------------- |
| text/html | the styled markup |
| text/plain | clean text |
- Move within the editor, Alt to copy.
- Leaving the editor is always a copy. The source is only deleted by this editor's own drop handler, so text dragged into another app is never removed from a document you can no longer see.
- The drop indicator IS the caret. Since the editor owns its caret, the thing showing where text will land is the thing that receives it.
- Dropped image files are read in as data URIs; dropped HTML goes through
the same
pastemodenormalisation as a paste, so dropping and pasting the same content produce the same document.
The editor declares data-drop="text/html;text/plain;Files;image/*" and each
selected element data-drag="text/html;text/plain", matching the conventions
tosijs-ui's drag library uses to mark compatible drop zones.
Links, images and footnotes
setLink https://example.com # wraps the selection; opens in a new tab
setLink https://example.com _self # same tab
setLink https://example.com pane # a named target
removeLink # unwrap, keeping the text
insertImage https://host/cat.png A cat
insertFootnote optional initial text
renumberFootnotes # recompute after editing by handLinks default to target="_blank" and get rel="noopener" with it — without
that, the opened page receives a live window.opener reference back to yours.
Pass _self to clear both.
Footnote numbers are never stored; they are derived from document order, so
inserting one in the middle renumbers the rest and reorders the list to match.
Deleting a marker drops its entry on the next renumber, and deleting the last
one removes the list. The stable identity is data-footnote, not the number.
Inside the editor a link is text you are editing, so clicking it places the caret rather than navigating. Ctrl/Cmd-click follows it.
Table commands
insertTable 3 # 3 columns, 2 rows (1 header)
insertTable 4 5 1 # 4 columns, 5 rows, 1 header row
insertTableRow after # Insert row after current
insertTableRow before # Insert row before current
insertTableCol after # Insert column after current
insertTableCol before # Insert column before current
deleteTableRow # Delete current row
deleteTableCol # Delete current column
toggleHeaderRow # Toggle header styling on current rowColumn widths can be resized by dragging cell borders.
Other commands
updateUndo undo
updateUndo redo
setDebug # Toggle debug visualization
annotate note # Insert annotation at caretTables
Tables use CSS Grid layout instead of <table> elements:
<ul class="editor-table" style="grid-template-columns: 1fr 1fr 1fr">
<li class="table-header">Name</li>
<li class="table-header">Role</li>
<li class="table-header">Location</li>
<li>Alice</li>
<li>Engineer</li>
<li>New York</li>
</ul>Extending the editor
Add custom commands by extending editor.commands:
editor.commands.myCommand = (ctx, ...args) => {
// ctx provides: root, selectable, commands, find(), findAll(),
// selectedLeafNodes(), selectedBlocks(), insertionPoint(),
// block(), normalize(), focus(), updateUndo()
const nodes = ctx.selectedLeafNodes()
// ... manipulate nodes ...
ctx.updateUndo('new')
}
editor.doCommand('myCommand arg1 arg2')Localization
Add localized and the built-in widgets translate themselves, with a flag-only
language picker in the menubar:
<tosijs-styled-editor widgets="default" localized></tosijs-styled-editor>Strings live in one tab-separated table, localized-strings.tsv. To add a
language, add a column — nothing else changes:
en fi
(row 1 is ignored — notes go here)
English Suomi
🇬🇧 🇫🇮
Bold Lihavointi
Italic KursivointiRow 0 is the locale codes, row 2 the language names, row 3 the flag emoji, and
every row after that is one string. Column 0 is both the lookup key and the
English text, so a missing cell or a missing row falls back to English rather
than showing a key — you can ship a half-translated column safely. A " cell
means "same as English", which is what proper nouns and numerals use.
Load it once at startup:
import { initLocalization } from 'tosijs-ui'
initLocalization(await (await fetch('/localized-strings.txt')).text())A doc site built on tosijs-ui/site does this for you — pass the table as
localizedStrings in the site config.
Under the hood this is tosijs-ui's convention, not a private one: buttons carry
data-tosi-localized (a JSON map of attribute to key, re-applied on locale
change), menus set localized, and menu labels are <tosi-localized> elements.
Custom widgets you add follow the same rules and get translated too.
Component API
| Property | Type | Description |
| ----------- | --------------------------------------------------- | --------------------------------------------------------------------- |
| value | string | Get/set the editor content as HTML |
| active | boolean | Enable/disable editing |
| pastemode | 'merge' \| 'remove' \| 'preserve' \| 'paragraphs' | How pasted HTML is handled |
| commands | object | Command registry (extend to add custom commands) |
| widgets | 'none' \| 'minimal' \| 'default' | Attribute — built-in toolbar preset |
| localized | boolean | Attribute — translate the built-in widgets and show a language picker |
| Method | Description |
| ---------------- | ------------------------ |
| doCommand(str) | Execute a command string |
| focus() | Focus the caret |
License
Apache-2.0
