npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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, no execCommand-era APIs

Range is used, but only as a measuring tapegetBoundingClientRect() 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-editor

Peer 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:

  1. DOM utilities (dom-utils.ts) — leaf-node traversal; nearly all operations work with leaf nodes
  2. 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)
  3. 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 18px

Use + 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 blockquote

Block styling — setBlocks

Applies CSS to selected blocks:

setBlocks text-align center
setBlocks margin-left 40px
setBlocks line-height 2.5

Drag 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 pastemode normalisation 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 hand

Links 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 row

Column widths can be resized by dragging cell borders.

Other commands

updateUndo undo
updateUndo redo
setDebug                   # Toggle debug visualization
annotate note              # Insert annotation at caret

Tables

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	Kursivointi

Row 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