Documentation

Authoring tools

Footnotes, format painter & change case

Three long-document tools that the other commercial editors put behind a paid plan. Footnotes with markers and notes that renumber themselves, a format painter that copies character formatting from one selection to another, and change case for the selection. All three ship in the standard license.

Table of contents

Inserts a contents block built from the document’s headings, with links that jump to them, and keeps it in step as you write. Rename a heading, add one, delete one, move a section — the list re-derives itself from the document, so it cannot drift.

This is not the same thing as the document outline panel. That panel is editor chrome for navigation and never appears in your saved HTML. A table of contents is part of the document you publish or print, so it survives getHTMLCode() and reads correctly with no JavaScript running.

editor.insertTableOfContents();   // one per document
editor.updateTableOfContents();   // force a rebuild (normally automatic)
editor.hasTableOfContents();      // -> true | false
editor.removeTableOfContents();
editor.getTableOfContents();
// -> [{ id: "rte-h-3k1a0", level: 1, text: "Master Agreement", page: 1 }, ...]
ConfigDefaultWhat it does
tocTitle"Contents"Heading above the list. Set to "" to omit it.
tocMinLevel / tocMaxLevel1 / 3Heading levels included, inclusive. Indentation follows the level.
tocPageNumbers"auto""auto" shows page numbers when page view is on, true always tries, false never.

Page numbers.With the pagination plugin loaded and page view on, each entry gets the page its heading falls on, set with a dotted leader like a printed contents page. The numbers come from the same page boundaries the page-view overlay draws, so they always agree with what you see. Outside page view no new number is computed — a page number would be a fiction — but numbers already in the block are kept, so loading a document that was saved in page view never silently strips them. Re-enter page view to refresh.

The block is contenteditable="false"and is regenerated from the headings, so it is not hand-editable — delete it as a unit and re-insert to move it. Headings inside generated regions (the contents block itself, the footnotes section) are skipped, so it never lists its own title.

Footnotes

Inserts a numbered reference marker at the caret and a matching note in a footnotes section at the end of the document, then keeps the two in step. Numbering follows document order: add a footnote above an existing one, delete a marker, or move a paragraph, and every marker and note renumbers and the notes list re-sorts to match. Deleting a marker deletes its note, and removing the last marker removes the whole section — no orphans are left behind in the saved HTML.

<script src="richtexteditor/rte.js"></script>
<script src="richtexteditor/plugins/all_plugins.js"></script>

<script>
  var editor = new RichTextEditor("#editor");

  editor.insertFootnote();                 // marker at the caret, caret moves into the note
  editor.insertFootnote("Ibid., p. 42");   // ...or supply the note text up front

  editor.getFootnotes();
  // -> [{ id: "fn3k1a0", number: 1, text: "Ibid., p. 42" }]

  editor.syncFootnotes();      // force a renumber (normally automatic)
  editor.getFootnotesCss();    // the stylesheet, to reuse on your public page
</script>
ConfigDefaultWhat it does
footnotesTitle"Footnotes"Heading above the notes section. Set to "" to omit it.
footnotesNumbering"decimal"List style of the notes: decimal, lower-roman, upper-roman, lower-alpha, upper-alpha.
footnotesMarkerPrefix""Wraps the marker number, e.g. "[" to render [1].
footnotesMarkerSuffix""The closing half of the above, e.g. "]".

What lands in your HTML

Footnotes are content, so unlike the page-view overlay they are meant to persist in getHTMLCode(). The number is written as literal text rather than drawn by a CSS counter, so the saved HTML is self-contained: publish it, export it, or render it on a page that never loads our stylesheet and the numbering is still there and still correct.

<p>The clause was struck out<sup class="rte-fn-ref" data-fn-id="fn3k1a0"
     data-fn-number="1" id="fnref-fn3k1a0" contenteditable="false">1</sup>.</p>

<section class="rte-footnotes">
  <h2 class="rte-fn-title">Footnotes</h2>
  <ol class="rte-fn-list" data-numbering="decimal">
    <li class="rte-fn-note" data-fn-id="fn3k1a0" data-fn-number="1" id="fn-fn3k1a0">Ibid., p. 42<a
      class="rte-fn-back" href="#fnref-fn3k1a0" contenteditable="false">&#8617;</a></li>
  </ol>
</section>

Markers are contenteditable="false" so the caret treats them as a single unit and cannot land inside the number. Clicking a marker jumps to its note; clicking the returns.

Format painter

Picks up the character formatting at the caret and paints it onto the next selection. Formatting is read from the computed style, so it does not matter whether the source was <b>, style="font-weight:700"or a CSS class. Painting also strips the character formatting already inside the target — which is what makes painting unformatted text onto bold text actually work.

editor.copyFormatting();       // capture at the caret
editor.pasteFormatting();      // apply to the current selection

editor.toggleFormatPainter();      // one-shot: capture, then paint the next selection
editor.toggleFormatPainter(true);  // sticky: keep painting until cancelled
editor.cancelFormatPainter();      // or press Escape
editor.isFormatPainterActive();    // -> true | false
editor.getCapturedFormatting();    // -> { "font-weight": "700", "color": "rgb(204, 0, 0)" }

Only properties that actually differfrom the editor’s baseline are carried, so a paint emits a short style attribute rather than a wall of inherited defaults. Repainting the same run does not nest wrappers. Which properties travel is configurable via formatPainterProperties; block-level properties such as alignment and margins are deliberately excluded, because a painter that reflowed the document is not what the tool is for.

Change case

Rewrites the actual characters in the selection — not a text-transform, which only looks right until the HTML is consumed somewhere else. Inline markup survives: change the case of the <strong>Data</strong> Act and the <strong> is still there afterwards. With nothing selected it operates on the word under the caret.

editor.changeCase("upper");      // THE DATA ACT
editor.changeCase("lower");      // the data act
editor.changeCase("title");      // The Data Act
editor.changeCase("sentence");   // The data act
editor.changeCase("toggle");     // tHE dATA aCT

editor.getChangeCaseModes();     // -> ["upper","lower","title","sentence","toggle"]

Title Case keeps small words (a, an, and, of, the, to…) lowercase unless they are first or last, and applies the same rule inside hyphenated compounds — so state-of-the-art becomes State-of-the-Art, not State-Of-The-Art. Edit the list with titleCaseSmallWords.

Autocorrect & typography

Fixes common typos as you type, capitalises the start of sentences, and turns typed approximations into the real characters: straight quotes become curly, -- becomes an em dash, ... becomes an ellipsis, (c) becomes ©, 1/2becomes ½.

editor.setAutocorrectEnabled(false);   // off at runtime
editor.isAutocorrectEnabled();

editor.addAutocorrectRule("acheive", "achieve");
editor.getAutocorrectRules();

// Run the same typography normalisation over imported or pasted text:
editor.applyTypography('He said "hello" -- it is 1/2 done...');
// -> 'He said “hello” — it is ½ done…'
ConfigDefaultWhat it does
autocorrectEnabledtrueMaster switch.
autocorrectSpellingtrueCommon-misspelling table (tehthe).
autocorrectCapitalizetrueCapitalise the first word of a sentence.
autocorrectTypographytrueSmart quotes, dashes, ellipsis, arrows, symbols, fractions.
autocorrectReplacementsnullExtra rules, merged over the defaults. Map a word to itself to disable a built-in one.

Nothing fires inside code. <code>, <pre>, <kbd>, <samp> and <var> are skipped entirely, as is any subtree marked data-rte-no-autocorrect. Curling the quotes inside a code sample corrupts it, which is the most damaging thing a feature like this can do.

Press Backspace immediately after a correction to undo itand keep exactly what you typed — the same escape hatch Word and Google Docs give you. The built-in word list is deliberately conservative: strings that are also real words (ill, lets, its) are left alone, because guessing wrong on a real word is worse than missing a correction.

Merge fields

Placeholders that stand in for data, so one document becomes a template you render against many records — mail merge, invoice runs, personalised letters, generated contracts. Each field is an atomiccontenteditable="false" chip, so it can never be half-deleted into {{customer.na}} and silently stop resolving.

var editor = new RichTextEditor("#editor", {
  mergeFields: [
    { id: "recipient.name",  label: "Recipient name", sample: "Ada Lovelace" },
    { id: "invoice.total",   label: "Invoice total",  sample: "£1,240.00" },
    { id: "date.today",      label: "Today's date",   sample: function () { return new Date().toLocaleDateString(); } }
  ]
});

editor.insertMergeField("recipient.name");
editor.listMergeFields();
// -> [{ id: "recipient.name", label: "Recipient name", known: true, count: 1 }]

editor.setMergeFieldPreview(true);    // show sample data instead of labels
editor.toggleMergeFieldPreview();
editor.isMergeFieldPreview();

// Render one record. Flat or nested data both work.
editor.renderMergeFields({ "recipient.name": "Grace Hopper", invoice: { total: "£90" } });
// -> HTML with the values substituted. The document itself is untouched.
ConfigDefaultWhat it does
mergeFieldsexample set{ id, label, sample }definitions. The shipped list is an example — replace it with your own schema.
mergeFieldPrefix / Suffix"{{" / "}}"Wrappers drawn around the label so a placeholder is visually obvious.
mergeFieldMissing"placeholder"What renderMergeFields does when the data has no value: leave the placeholder visible, or "empty" to remove it.

Preview never reaches your saved HTML.This is the one genuinely dangerous thing about the feature: if turning on preview and pressing save wrote “Ada Lovelace” into the template where {{Recipient name}}used to be, the user would destroy their template merely by looking at it. So the serializers are wrapped — getHTMLCode, getJSON, getHTMLContent and getText all restore every chip to its label for the duration of the call. Save while previewing and you still get the template back.

By the same principle, renderMergeFields() works on a detached clone: producing merged output never mutates the document being edited. A field with no matching data keeps its placeholder rather than rendering blank, because a silently empty invoice line is worse than an obviously unfilled one. Fields pasted from a template built elsewhere are reported with known: false rather than dropped, so you can tell the user what their data needs to supply.

Table sorting & row numbering

Sort a table by the column the caret is in, and add an automatic numbering column that renumbers itself after every sort.

editor.sortTableAtCaret("asc");        // or "desc"
editor.sortTableByColumn(2, "desc");
// -> { ok: true, sortedBy: 2, direction: "desc", detectedType: "number" }

editor.canSortTable();                 // -> { ok: false, reason: "...merged cells..." }

editor.addTableRowNumbers({ series: "upper-roman" });
editor.removeTableRowNumbers();
editor.hasTableRowNumbers();

The column type is detected, not assumed. A column of prices sorts numerically even when the cells read $1,240.00 or (500) (accounting notation for a negative), and a column of dates sorts chronologically rather than alphabetically. Text-sorting a numeric column is the classic wrong answer that puts 10 before 9. Empty cells sink to the bottom in bothdirections, because a blank means “no value”, not “the smallest value”. The sort is stable, so sorting by a second column preserves the order of the first.

It refuses on tables with merged cells and tells you why, rather than sorting them badly. Arowspan has no meaning once the rows move; silently shredding a table someone spent an hour on is far worse than declining to sort it. <thead> and <tfoot> never move, and a first row of all <th> is treated as a header even without a <thead>. Row numbering is positional, so after a sort row 1 is whatever is now on top.

Permanent pen

Switch on a fixed character format and everything you type from then on gets it — the marker you pick up to annotate a document in a colour that is obviously not the original text. Unlike the format painter, pen output is real content and persists in your saved HTML.

editor.setPermanentPen(true);
editor.togglePermanentPen();
editor.isPermanentPenActive();

editor.setPermanentPenStyle({ "color": "#0b6", "font-style": "italic" });
editor.getPermanentPenStyle();

A whole stroke goes into onespan that the pen keeps extending, rather than one span per keystroke. Moving the caret ends the stroke, so the next run gets its own span instead of text teleporting into the previous one. Switching the pen off steps the caret out of the run — otherwise typing would continue inside the styled span and the pen would appear to ignore being switched off.

Cross-references

“See clause 3.2”, “Figure 4”, “described in Scope of Work” — references that still point at the right thing after the document is edited around them. This is the piece that makes the rest of the document tooling hold together: legal numbering renumbers clauses and the contents block re-derives itself, but a sentence saying “see clause 3.2” stays frozen unless something updates it. In a contract, a stale reference changes what the document says.

editor.listCrossReferenceTargets();
// -> [{ id, type: "heading"|"clause"|"footnote"|"table"|"figure", label, number }]

editor.insertCrossReference(targetId, "label");   // "clause 3.2" / "Table 2"
editor.insertCrossReference(targetId, "text");    // the heading's own text
editor.insertCrossReference(targetId, "number");  // "3.2"
editor.insertCrossReference(targetId, "page");    // page number, in page view
editor.insertCrossReference(targetId, "position");// "above" / "below"

editor.updateCrossReferences();
editor.getCrossReferences();

Clause numbers are derived from position in the list, the same way the CSS counters that render them are, so inserting a clause above renumbers every reference below it. Heading references follow a retitled heading. Tables and figures are auto-numbered, or use their <caption> / <figcaption> when they have one.

A reference whose target is deleted fails visibly — it renders [reference not found] in red rather than keeping its last value. Word does the same thing, and for the same reason: a plausible-but-wrong clause number is far more dangerous than an obviously broken one. The displayed text is real text in the saved HTML, so the reference reads correctly once the document leaves the editor.

Margin line numbers

Numbers every line in the left gutter, the way a word processor does for pleadings, statutes, transcripts and anything people cite by line (“page 4, lines 12–15”). Court filing rules in several jurisdictions require numbered lines, which is why document editors have this and browser editors generally do not.

editor.setLineNumbers(true);
editor.toggleLineNumbers();
editor.isLineNumbers();
editor.getLineCount();          // visual lines, not blocks

editor.setLineNumberOptions({
  restart:  "page",   // "continuous" | "page" (needs page view) | "block"
  interval: 5,        // show every 5th number
  start:    1,
  gutter:   38        // gutter width in px
});

It counts visual lines, not paragraphs.A paragraph that wraps over four lines gets four numbers, because that is what “line 12” means to whoever is citing it. Line boxes are found with Range.getClientRects(), so the count follows the actual rendered layout — change the window width and the numbering re-flows with the text.

With restart: "page" and page view on, numbering restarts at the top of every page, which is what the filing rules specify. Like the page-view overlay itself, the gutter is purely presentational: it is contenteditable="false"and stripped around every serialize, so the HTML you save is byte-identical whether line numbers are on or off — including the gutter padding, which would otherwise leak onto the editable as an inline style.

Formatting marks

Word’s button: paragraph marks, block outlines, and — the genuinely useful part — highlighting for characters that are invisible but not harmless. Non-breaking spaces, zero-width spaces, soft hyphens and bidi marks all arrive with pasted content and cause layout bugs nobody can see.

editor.setFormattingMarks(true);
editor.toggleFormattingMarks();
editor.isFormattingMarks();

// config
formattingMarkPilcrow:    true,   // ¶ at the end of each block
formattingMarkBlocks:     true,   // dashed outline around block elements
formattingMarkInvisibles: true    // nbsp / zero-width / soft hyphen / bidi

Pilcrows and outlines are drawn entirely in CSS, so they cannot alter the document no matter what. Invisible characters can’t be reached from CSS, so those get a wrapper — but every wrapper is stripped around serialize and removed when the feature is switched off, and the original character is restored, not the label glyph. Marks are never applied inside <code> or <pre>, where the character may be deliberate.

In fairness: this one is parity, not an advantage. TinyMCE ships visualchars and visualblocks in its free core, and CKEditor has show-blocks.

Watermark

Draws DRAFT, CONFIDENTIAL or a customer name behind the content, so a reader can tell at a glance not to treat the document as final or not to circulate it.

editor.setWatermark(true);
editor.toggleWatermark();
editor.isWatermark();

editor.setWatermarkOptions({
  text:     "CONFIDENTIAL",
  mode:     "tile",     // "tile" | "single"
  opacity:  0.18,
  angle:    -30,
  fontSize: 48,
  color:    "#94a3b8",
  print:    false,      // opt in to include it when printing / exporting PDF
  image:    null        // data URI or URL; overrides the text
});

It is never part of your content.The mark is rendered as an SVG background, so it cannot be selected, copied, or found by search-and-replace, and it is stripped around every serialize. That matters for more than tidiness: a watermark written into the document would survive into a published page long after the draft stopped being a draft, and “CONFIDENTIAL” as content is trivially deleted, whereas a rendering-layer mark is not.

Printing is a separate decision from screen marking, so the watermark is excluded from print by default. Set print: true to carry it into printed output and PDF export.

Right-to-left & bidirectional text

Arabic, Hebrew, Persian and Urdu run right to left. Direction can be set per block or for the whole document, detected from the content, or left to the browser with dir="auto".

editor.setTextDirection("rtl");     // the block(s) in the selection
editor.toggleTextDirection();
editor.getTextDirection();          // -> "rtl" | "ltr"

editor.setBaseDirection("rtl");     // the whole document
editor.getBaseDirection();

editor.detectTextDirection("123 שלום");   // -> "rtl"  (first-strong)
editor.autoDetectTextDirection();          // stamp every block from its own text

// Mixed content: isolate a run so the surrounding direction cannot reorder it
editor.isolateBidi("https://example.com", "ltr");   // -> <bdi dir="ltr">…</bdi>
editor.insertIsolated("https://example.com", "ltr");

Direction is content. Unlike the page-view overlay or the watermark, dir is written into the document and persists in getHTMLCode()— a document whose direction vanished on save would be unreadable.

Detection uses the first-strong rule, the same algorithm the HTML spec defines for dir="auto": a paragraph takes the direction of its first strongly-directional character, skipping digits and punctuation. That is why "123 שלום" is right-to-left while "123 hello" is left-to-right, and why a paragraph of only numbers is left alone rather than guessed at.

Flipping direction swaps an explicit alignment. Setting dir only changes the default alignment, so a paragraph explicitly set to text-align:leftwould stay pinned to the left edge in an RTL block and look broken. Explicit physical alignment is mirrored, and dropped entirely when it lands on the new direction’s natural side so the block follows dir from then on.

Mixed content is isolated, not just marked.Dropping an LTR URL into an RTL sentence without isolation lets the bidi algorithm reorder the surrounding punctuation — which is how (https://example.com) ends up rendering with its brackets on the wrong sides. insertIsolated() wraps the run in <bdi> so it cannot happen.

Lists, block quotes and tables are flipped in CSS so bullets and quote bars sit on the correct edge.

The editor itself mirrors too.Document direction is only half the job — an Arabic or Hebrew author should not be typing right-to-left text inside a left-to-right application. The toolbar, menus, dropdowns, dialogs and status bar all flip:

editor.setRtlUserInterface(true);
editor.toggleRtlUserInterface();
editor.isRtlUserInterface();

// config: rtlUserInterface — "auto" (follow the document), true, or false

Chrome direction is kept strictly separate from content direction: mirroring the interface writes nothing into your document, and getHTMLCode() is unchanged. With several editors on one page, only the one you mirror is affected. Icons are deliberately notflipped — Word doesn’t flip its toolbar icons either, and bold/italic/alignment glyphs mean the same thing in both directions. Only the ones that encode reading order (indent, outdent, menu arrows) mirror.

In fairness, both CKEditor and TinyMCE ship basic text direction in their free cores; the auto-detection, bidi isolation and interface mirroring here go beyond that baseline, but the baseline itself is not something they charge for.

Drag handle & block reordering

Hover any paragraph, heading, list, table or callout and a grip appears beside it — drag it to move the whole block, with an insertion line showing exactly where it will land. The block-reordering interaction Notion made standard, and a far better way to move a clause than cut-and-paste.

// The drag is a thin layer over a scriptable API:
editor.moveBlockUp();               // block containing the caret
editor.moveBlockDown();
editor.moveBlock(el, beforeEl);     // programmatic move

editor.setDragHandleEnabled(false); // hide the grip
// config: dragHandleEnabled, dragHandleKeyboard

Alt+Shift+↑/↓ moves the caret’s block— the same keys Word uses to move paragraphs. This is not a bonus shortcut: a pointer-only reordering feature is unusable from the keyboard, so the keyboard path is what makes the feature accessible at all. The caret travels with the moved block in both paths.

The grip and the insertion line live outside the editable area entirely, so they are structurally incapable of appearing in your saved HTML. Dragging uses plain mouse events rather than native HTML5 drag-and-drop, which inside contenteditableis the browser behaviour that half-works everywhere — it starts a text drag, draws the wrong ghost, and drops serialized HTML instead of moving the node. In an RTL document the grip appears on the right, where the line starts.

PDF export with real text

There are two PDF exporters, and the difference matters. html2pdf renders the document with html2canvas and puts an image on each page: pixel-accurate, and the right choice when the exact visual layout is the deliverable. But the result is a pictureof a document — nothing is selectable or searchable, a screen reader finds an empty file, links are painted rather than clickable, and a page of plain text costs hundreds of kilobytes.

exportpdfwrites the PDF file format directly instead. Text stays text. It uses the 14 standard PDF fonts, which need no embedding — that removes font subsetting, the genuinely hard part of a PDF generator, and keeps a typical page under 15 KB. No library is loaded and nothing is uploaded.

  • Headings, bold/italic/underline/strike, colour, font size, bullets and numbered lists, tables with repeating header rows, block quotes, rules and images
  • Links become real PDF link annotations, not underlined text
  • Page size, orientation and margins come from the document’s own page setup, so the PDF matches page view and the Word export
  • Explicit page breaks are honoured, and a heading is never left stranded at the foot of a page
  • Title, author and document language are written as metadata

One subtlety worth knowing about: a viewer lays text out using the font’s own metrics, so a generator that wraps lines with its own numbers will disagree with it and overrun the margin. This measures each character and writes those widths into the PDF as /Widths, which a viewer is required to honour — layout and rendering then use the same numbers by construction. Verified against pdf.js (the parser Firefox ships): text extracts cleanly, links resolve, and nothing crosses the margin.

Tagged for accessibility

Selectable text is necessary but not sufficient. An untagged PDF still fails a Section 508 or EN 301 549 audit: assistive technology receives a flat stream of words with no headings, no list structure, no table semantics and no guaranteed reading order. The export writes a full structure tree, so the document is navigable rather than merely readable.

  • H1H6 for headings, P for paragraphs, BlockQuote for quotations
  • L / LI / Lbl / LBody, so a screen reader announces “list, 3 items” rather than three loose paragraphs
  • Table / TR / TH / TD with /Scope on header cells — the difference between hearing “Revenue, 1,240” and just “1,240”
  • Figure elements carry /Alt from the image’s alt text
  • Rules, cell borders, the quote bar and the running header/footer are marked as artifacts, which removes them from the reading order — a repeated header read aloud on every page is one of the most common audit findings
  • /MarkInfo, /StructTreeRoot, a /ParentTree, /Lang, and /DisplayDocTitle so the viewer announces the document’s title instead of its filename

The tree is verified by reading it back through pdf.js the way assistive technology would, not by inspecting the bytes: 25 assertions covering every role, document reading order, and the absence of artifacts from the content tree.

editor.exportToPdf("quarterly-report");
const bytes = editor.getPdfBytes({ title: "Q3", author: "Finance" });

// config
pdfExportFont: "helvetica" | "times" | "courier"
pdfExportBaseFontSize: 11
pdfExportHeader: "Confidential"
pdfExportFooter: "Page {page} of {total}"

Real .docx export, in the browser

Import already read a genuine OOXML package client-side. Export did not: wordexport wraps the HTML in an MSO-flavoured container and names it .doc. Word opens that, but it is not a .docx— it round-trips badly, Word nags on save, and some corporate policies block HTML-with-a-doc-extension outright. exportdocx writes a real OOXML package instead: ZIP built with CompressionStream, the mirror image of the DecompressionStream the importer uses. No library, no upload.

It deliberately emits exactly what the importer reads back — the same hyperlink relationships, numbering ids, footnote and comment parts, and revision conventions — so HTML → .docx → HTML is a genuine round trip rather than two features that happen to share a file extension. Verified both ways: the editor re-imports its own output (headings, inline formatting, colour, link URLs, nested lists, tables, quotes, footnotes, tracked changes and equations all recovered), and the package itself is validated part-by-part — every XML part well-formed, every r:id resolving, every numId defined, and every part declared in [Content_Types].xml.

Two details that quietly break hand-built .docx files, both handled here: w:sz is in half-points, so writing points renders the whole document at half size; and a single control character anywhere in a part makes Word reject the file outright rather than skipping the character.

await editor.exportToDocx("quarterly-report");
const blob = await editor.getDocxBlob({ title: "Q3", author: "Finance" });

Content sanitizer

“Nothing executes in the editor” is the wrong bar. The bar is nothing executes in the application that renders what the editor saved— because that is the shape every deployment has: one user writes content, it is stored, and another user’s browser renders it.

The editor core already strips inline event handlers and rejects javascript:on links and form actions, and none of a 20-payload battery executed inside the editor. But three constructs survived into saved output, and one of them — <iframe srcdoc>— ran as soon as that saved HTML was rendered in an ordinary container. The sanitizer closes that.

  • Allowlist, not blocklist — a blocklist is a promise to have thought of every element HTML will ever gain. Unknown elements are unwrapped so the text a user typed inside them is never lost.
  • Runs on the way in (set, paste, drop) and on the way out. Output is the guaranteed path: it is what gets stored, it works on a detached copy so it can never disturb the caret, and content that predates the plugin is cleaned on its next save.
  • URLs are checked after control characters are stripped — java&#9;script: is parsed as javascript: by browsers, so testing the raw string is trivially bypassed.
  • style is filtered per declaration, not per fragment: deleting javascript: from url(javascript:f()) would leave url(f()), which then reads as a harmless relative URL and passes the very check meant to catch it.
  • Re-parses until stable, to defeat mutation XSS — markup that is inert in one parse and dangerous once the serializer has rewritten it.
  • target="_blank" links gain rel="noopener".

Embeds still work: <iframe> is kept for httpssources. With no host allowlist configured it is given a sandbox that permits what a video embed needs and withholds top-navigation, popups, modals and forms — an injected frame cannot script the parent, but it can otherwise navigate the top window, which is a phishing move. Setting sanitizerAllowedIframeHosts is the stronger control.

This is defence in depth, not a substitute for server-side validation. It runs in the browser, and anything that runs in the browser can be bypassed by a client that simply does not run it. Sanitize on the server before you store, and escape on output. This layer protects the honest path and narrows the dishonest one; it does not make untrusted HTML safe to render unconditionally.

contentSanitizer: true                       // default
sanitizerAllowedIframeHosts: ["www.youtube.com"]
sanitizerAllowStyleTags: false               // default
sanitizerAllowTags: ["my-element"]
sanitizerAllowAttributes: ["my-attr"]

editor.sanitizeHtml(html)        // clean a string yourself
editor.getSanitizerReport()      // what has been stripped so far
All three are in the base license

CKEditor 5 gates footnotes behind its Essential, Professional and Custom plans, and puts table of contents, format painter, case change and merge fields behind “selected CKEditor Plans”. TinyMCE ships every one of these as a premium plugin “only available for paid TinyMCE subscriptions” — autocorrect and typography as two separate ones. Here they are all part of the $129 perpetual license. (In fairness: CKEditor’s automatic text transformation is in its open-source core, so autocorrect is parity there rather than a gap.) See the full comparison.