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:

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:

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:

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.

Spell checking

This editor has no browser spell checking at all. Browsers only check editing hosts — textarea, input, contenteditable — and nothing here is one. That is a cost of replacing contentEditable, and worth knowing before you assume squiggles will appear on their own.

In exchange you get the thing a contentEditable editor cannot have: an application that can ask.

editor.spellChecker = (words) => new Set(words.filter((w) => !dictionary.has(w)))
await editor.checkSpelling()

editor.spellingErrors          // [{ word, element }, …] in document order
editor.validity.customError    // true — a real form submit is blocked
editor.clearSpelling()         // remove every mark, change no text

Resolution is the workflow

In a jargon-heavy domain — contracts, medicine, anything with terms of art — the usual answer to an unknown word is "that is a real word", not "I mistyped". So accepting has to be as cheap as correcting, and every flagged word has to end up resolved one way or the other.

That is what the validity is for. The field stays invalid until every flagged word is corrected or accepted, so a real form submit is blocked rather than relying on anyone to remember to look.

Accepting has two scopes, because they have different lifetimes:

editor.acceptWord('indemnitor', 'document')    // this document only
editor.acceptWord('lessor', 'dictionary')      // everywhere, for this user

Collapsing those into one list is what makes a spell checker unusable in these domains: either every later document inherits one contract's party names, or the user re-accepts the same terminology forever.

The editor persists neither — it does not know where either store lives. It tells you what to write, and you decide where:

editor.handleWordAccepted = (word, scope) => {
  if (scope === 'dictionary') saveToUserDictionary(word)
  else saveWithDocument(word)
}

Load them back by assigning the sets before checking.

Accepting jargon does not launder a real typo: with indemnitor and lessor accepted, borwn stays flagged and the field stays invalid.

No dictionary ships with this component. Which words are real is a localization question with a different answer per document, and a hunspell dictionary is roughly forty times the size of the whole editor.

Wiring a real checker

The checker is (distinctWords) => the subset that is wrong, sync or async, so any off-the-shelf engine fits behind it. With a hunspell-style library:

import nspell from 'nspell'

const spell = nspell(await loadAffix(), await loadDictionary())
editor.spellChecker = (words) => new Set(words.filter((w) => !spell.correct(w)))

Or against a service, which is the case the batching exists for:

editor.spellChecker = async (words) => {
  const res = await fetch('/api/spell', {
    method: 'POST',
    body: JSON.stringify(words),
  })
  return new Set(await res.json())
}

The editor asks about distinct words, once per check — so a 10,000-word document with a 2,000-word vocabulary is one call carrying 2,000 entries, not 10,000 lookups and not one request per word.

What it does, and does not, do

Tokenization uses Intl.Segmenter word segmentation, so don't is one word and l'objet is two — neither of which splitting on whitespace gives you. code, kbd, samp, pre and any spellcheck="false" subtree are skipped.

Marks are view state: cleared on every check and stripped from value, so they never reach the form value, an undo snapshot, or whatever you persist. A document should not carry a record of which words some dictionary once disliked.

Not implemented, and worth knowing before you build UI on this:

Tracked changes and LLM proofreading

Insertions and deletions are content — <tosi-ins> and <tosi-del> elements carrying author and timestamp — not an operation log. A tracked document is still a document: it serializes, round-trips, and can be read by something that has never heard of this component.

editor.changeAuthor = { id: 'alex', name: 'Alex' }

await editor.reviseWith(async (text) => {
  const res = await fetch('/api/proofread', { method: 'POST', body: text })
  return (await res.json()).text
}, { id: 'gpt-x', name: 'Proofreader' })

editor.changes                    // [{ id, kind, author, authorName, time, text, element }]
editor.acceptChanges(id)          // take this one
editor.rejectChanges(id)          // keep the original
editor.acceptChanges()            // all of them

Both readings stay in the document until someone decides. A reviewer can put the caret inside a proposed insertion and adjust it before accepting — the text inside a change mark is ordinary editable content.

What crosses the boundary

Out goes plain text, one text node at a time. Formatting is deliberately not sent: a model asked to preserve markup will sometimes not, and a reviewer should be reviewing prose rather than diffing HTML. Marks inside a block — a link, a bold run — survive because each text node is revised in place. What the model never sees, it cannot damage.

Back comes text, and only text. The response is inserted as text nodes inside change marks and is never parsed as HTML, so a model that returns <script> produces those literal characters, visible for review. That is a stronger guarantee than sanitizing the response would be — there is no parse step to attack — which is why this path does not go through editor.sanitize.

Text already under review is skipped, so a second pass cannot mark up the marks.

Why word-level

The diff is word-level because the unit has to be something a reviewer can meaningfully accept or reject. A character diff turns teh → the into three separate changes, and a rewritten sentence into confetti.

Styling is correctness here

<tosi-ins> and <tosi-del> are styled in the core stylesheet even though the behaviour is a plugin. An unloaded footnote plugin is benign — you see a stray marker. A <tosi-del> without its strikethrough renders deleted text as ordinary prose, which reads as the opposite of what the document means.

Tracking live edits

editor.changeAuthor = { id: 'alex', name: 'Alex' }
editor.trackChanges = true

Structural edits

Merging paragraphs is tracked too, the brute-force way: these blocks out, these blocks in. A change mark wraps content and a paragraph break is not content, so merging two paragraphs strikes both originals and proposes a third:

<p><tosi-del data-change="c1" data-block-delete>Alpha.</tosi-del></p>
<p><tosi-del data-change="c1" data-block-delete>Beta.</tosi-del></p>
<p><tosi-ins data-change="c1" data-block-insert>Alpha. Beta.</tosi-ins></p>

All three share one data-change, because one keystroke is one change — the same rule paste already follows. Accepting removes the struck blocks and keeps the replacement; rejecting restores the originals and drops the proposal. Covers Backspace at the start of a block, Delete at the end of one, and a selection spanning blocks (where the proposal reads as the deletion would read once accepted).

The text is present twice until someone resolves it. That is the cost of the brute-force representation, and it is worth knowing before you persist value on every keystroke: a document mid-merge serializes both the originals and the proposal.

data-block-delete / data-block-insert say a mark's scope is a whole block rather than a run of text, and they are what tell acceptChanges to remove the struck block instead of leaving it empty. A downstream sanitizer that strips unknown data-* attributes turns a proposed merge back into three ordinary blocks.

A merge is an ordinary edit. It has no veto and no special group: a pending change in either paragraph — yours or anyone's — does not block it, and it does not absorb that change or resolve it for you. Every mark keeps its own id and stays independently resolvable, because the point of tracking changes is that you see the old text and the new text and you decide what stays.

The corollary is that you can make contradictory choices, and the editor will do what you said. Reject an insertion inside a paragraph and then accept a merge whose proposed text was taken from that paragraph, and the inserted words are in the result — you rejected them in the old text and accepted a new paragraph that visibly contains them. Both are on screen when you choose. A review UI that wants to prevent this should resolve a merge and the edits inside it together; the editor does not decide that for you.

Lists and grid tables are the one refusal (merge-blocks-not-mergeable), and it is about valid DOM rather than about review policy: merging them produces loose text as a direct child of a <ul>, or a mark that becomes a grid item and shifts every cellIndex. It is therefore not overridable — see structural-edit-refused below.

Everything else

Typing then lands inside a <tosi-ins>, and every deletion wraps in <tosi-del> instead of removing — caret Backspace and Delete, a selection delete, a cut, inside a list, inside a table cell. Nothing leaves the document without a mark and an entry in changes, which is the only property that makes rejectChanges() mean anything.

The whole mechanism is one predicate, re-evaluated only when the insertion point might have moved: is the caret already inside an insertion that is mine, from this session? If yes, typing appends to it. If no, a new one opens. There is no per-operation bookkeeping, because a continuous run of typing keeps the predicate true and it stops being true exactly when it should — a click elsewhere, an arrow key, a new line, a different author, a later session.

Session, not just author. Reopening a document and typing at the edge of your own earlier tracked insertion opens a new change. That edit happened at a different time and a reviewer may want to treat it separately; without this they would silently merge into one change bearing the older timestamp.

Cut and paste are tracked too. A cut wraps in <tosi-del> like any other deletion. A paste is one change rather than one per word — you did not build it a keystroke at a time, and a reviewer wants to accept or reject the paste, not its individual words — so it gets its own mark even mid-typing-run. Pasted content is still sanitized before it is marked.

Two deletions behave specially, because the pedantic version would be noise:

Not implemented

Resolved changes leave nothing behind

Accepting or rejecting a change evaporates the mark entirely — no wrapper, no data-change, no attribution residue, and the text is re-normalized rather than left fragmented. A document does not accumulate its own history.

That is deliberate. Undo, and whatever version store the document lives in, already record past states; a document that carries every resolved edit becomes unreadable, larger than its content, and awkward to share with anyone who was not part of the review.

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 Character precision; a FINGER snaps to words

Word stickiness is per pointer, and since 0.6.0 a mouse drag does NOT snap. A mouse user gets the platform's behaviour — character precision, and double-click when they want a word — because rounding a drag they aimed out to the nearest word boundaries overrides a precise gesture. A fingertip has no such precision to override, so a touch drag snaps, which is the only way a drag across a word ends up selecting that word.

editor.stickySelection chooses: 'touch' (the default), 'always' — which restores the pre-0.6.0 behaviour for the mouse as well — or 'never'. It is a live property, settable at any time, and it takes effect on the next drag.

When snapping does apply, it engages only once the drag leaves the word it began in — in practice, as soon as you cross a space. Inside that first word you keep character precision, so pulling fix out of prefix still works; cross into another word and both ends snap, including the anchor, because a selection that spans words but starts mid-word is almost never what was meant. Punctuation comes along only when the pointer reaches it, and a selection never ends in a trailing space you did not ask for. Sticky within a block only — a double-click drag is already word-granular, and a cross-block selection has larger units than words.

Whether the pointer has left the anchor word is asked visually, per line box, not by comparing logical offsets: inside a right-to-left run on a left-to-right line, x decreases as the logical index rises, so the two directions oppose and an offset comparison answers about the wrong one. stickySelectionBounds(text, anchor, head, leftTheAnchorWord?) is exported if you want the rule without the editor.

Touch affordances

A selection made or adjusted by touch gets two things, modelled on iOS because iOS got there first and copying observable behaviour beats inventing our own.

Round drag handles. A small painted dot — AFFORDANCE_DOT, 14px — above the start of the selection and below its end, each centred in an invisible 44px hit box (AFFORDANCE_HIT, Apple's own minimum). The two are separate constants on purpose: before 0.7.0 there was one AFFORDANCE_SIZE meaning both, so the handles were visually as large as their hit area and covered the text they were bracketing. Dragging a handle is the SAME gesture as dragging across text, so it obeys stickySelection.

An action lozenge, translucent, centred on the PAGE and placed above the selection when there is room, below it otherwise, never over it. Cut / Copy / Paste inline; a chevron expands the same element into a vertical menu with Delete, Bold, Italic and Plain. It fades out while a handle is being dragged.

Centring on the page rather than on the selection is what retired menuAffordanceX (removed in 0.7.0): that function existed to keep a selection-centred menu off the start handle, and with page centring the overlap cannot arise. lozengePlacement(selection, size, bounds, gap?) is exported if you want the geometry.

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

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.

Numbering is per NOTE, not per reference (since 0.6.0): two references sharing a data-footnote are two pointers at one note, so they show the same number and share its single list entry. That matters because a reference can legitimately exist twice — copy one, or make a tracked edit that proposes a merged paragraph, and the same marker is in the document twice until the change is resolved. Numbering per reference minted a second list entry for the same note, sharing one id, carrying placeholder text.

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
stickySelection 'touch' | 'always' | 'never' When a drag snaps to word boundaries. Default 'touch': a finger snaps, a mouse keeps character precision. 'always' restores pre-0.6.0 mouse behaviour
trackStructuralEdits boolean Record cross-block merges as blocks-out/blocks-in instead of refusing them. Off, and not a supported configuration. The resolution defect it was held back for in 0.6.0 is fixed in 0.7.0; what gates it now is identity. While a merge is pending the proposal coexists with the originals it supersedes, so every id in the merged block answers twice, and who owns an id while pending — or inherits it on resolution — is undecided. Turning it on gives you correct STRUCTURE and an undecided identity model; a document with no id attributes is believed sound

Change tracking

Member Type Description
trackChanges boolean Record live edits as tracked changes
changeAuthor { id, name? } Who the next change is attributed to
sessionId string (readonly) Distinguishes this editing session from an earlier one by the same author
changes TrackedChange[] Every change in the document, in document order
acceptChanges(id?) void Accept one change, or all of them with no argument
rejectChanges(id?) void Reject one change, or all of them with no argument
reviseWith(revise, author?) Promise<number> Round-trip the prose through a proofreader; returns changes introduced

Spell checking

Member Type Description
spellChecker (words) => Set | Promise<Set> Supply a checker; without one, nothing is checked
checkSpelling() Promise<SpellingError[]> Check now and mark what comes back wrong
spellingErrors SpellingError[] The current errors — the query browsers refuse to answer
acceptWord(word, scope?) void 'document' (default) or 'dictionary'
documentWords / userDictionary Set<string> The two accepted-word scopes, for persisting
handleWordAccepted (word, scope) => void Called when a word is accepted, so the host can persist
clearSpelling() void Drop every mark without changing the text

Other

Member Description
doCommand(str) Execute a command string
focus() Focus the caret
sanitize (root: Element) => void applied to pasted and dropped content

Also exported from the package: stickySelectionBounds, lozengePlacement, AFFORDANCE_HIT, AFFORDANCE_DOT, LOZENGE_GAP, sanitizeInPlace and isSafeNavigationUrl (re-exported from tosijs-kilpi), changeId, diffWords, acceptChange/rejectChange, checkSpelling, wordsIn, renumberFootnotes, and the element classes behind the four content tags.

License

Apache-2.0