@richscripts/richtexteditor

A full-featured JavaScript rich text editor (WYSIWYG) with toolbar, plugins, i18n support, a built-in AI Toolkit (Ask AI, AI Chat, AI Review drawer, BYOK resolver), structured content APIs (JSON/Markdown), and an optional collaboration / review stack: sla
6,160 2
Install
npm install @richscripts/richtexteditor
Latest Version:2.15.0
Vue:3
License:Custom
Last Updated:Sep 24, 2026
Links: GitHub  ·  npm
Maintainer: richscripts

Rich Text Editor

Rich Text Editor is a browser-based WYSIWYG editor with toolbar customization, plugins, localization, image editing, PDF export, and classic HTML APIs.

What's in the box

A full collaborative-document workspace, not just a textarea replacement:

  • AI Toolkit — in-editor proofread / rewrite / summarize / translate / chat, with streaming responses, an inline preview drawer, and a persistent accept/reject review ledger. Bring your own provider (OpenAI / Anthropic / Azure OpenAI / Groq / Ollama / custom) via editor.aiToolkit.setResolver() or the server-side IRichTextBoxAiResolver interface.
  • Per-node Yjs CRDT engine (new in 2.2) — editor.collab.attach({ doc, provider, textSync: "crdt" }) activates character-level concurrent typing on the same paragraph with deterministic merge resolution, presence cursors via Y.RelativePosition-encoded awareness, and per-author Y.UndoManager (Ctrl-Z undoes only your edits). Pairs with any Yjs provider you self-host. Ships in the base license.
  • Slash commands & mentions — / at the start of a line opens a command palette; @ and # open mention pickers via editor.slashCommands.register() and editor.mentions.register().
  • Comments & tracked changes — threaded inline comments on text selections and block/media content such as images or tables (editor.comments.add/reply/resolve), tracked-changes mode, per-author colors and labels.
  • Comments-only review mode — set commentsOnly: true or call editor.setCommentsOnly(true) to lock document edits while still allowing inline comments and review discussion.
  • Restricted editing templates — set restrictedEditingMode: true to lock the document except for spans tagged data-rte-editable="true". Mark fillable regions at runtime with editor.restrictedEditing.markSelection(); toggle the lock via editor.restrictedEditing.enable() / disable() / goToNext(). Use it for forms, contracts, fillable templates — only the marked regions accept input.
  • Revision history — named snapshots, diff view, restore (editor.revisionHistory.snapshot/diff/restore).
  • Dictation — Web Speech API integration (editor.dictation.toggle()) with multi-language support.
  • DOCX import + export — import Word documents through editor.aiToolkit.importDocx({ file?, mode? }) and generate deterministic HTML → .docx via editor.aiToolkit.exportDocx({ filename }); preserves headings, lists, tables, images.
  • Structured content bridge — editor.getJSON() / setJSON(), fromMarkdown() / toMarkdown(), renderHTML(), validateStructuredContent() — for SSR, JSON storage, and Markdown round-trip. The packaged schema now preserves merge fields, ordered-list marker styles (A / a / I / i), table-of-contents blocks, footnotes, and editor page breaks instead of collapsing them into opaque HTML.
  • Accessibility audit helper — RichTextEditor.auditAccessibility() / auditAccessibility() catches empty headings, skipped heading levels, missing image alt text, and tables without header cells in browser or Node workflows. Tables marked role="presentation" are exempt from the header rule and checked for the opposite fault instead — data-table markup on a layout table.
  • In-editor accessibility repair panel — the packaged accessibilitychecker plugin adds a side panel (editor.accessibilityChecker.open/close/toggle/run/repair) with focused fixes for missing image alt text, empty headings, skipped heading levels, headerless tables, unmarked language runs, and layout tables that still carry header cells. Use accessibilityCheckerAutoOpen to keep the workflow visible during review passes.
  • Layout tables (new in 2.6) — a <table> that only positions content is not a data table, and marking it as one makes a screen reader announce relationships that do not exist. editor.setTableLayoutMode("layout") writes role="presentation" and strips <th> / scope / <caption>; insertLayoutTable(rows, cols) starts one for an email or signature layout. Nested data tables keep their headers.
  • Crash recovery (new in 2.6) — richtexteditor/rte-watchdog.js snapshots the document, notices when the editing surface has actually died, recreates the instance and restores the text. It ignores errors from unrelated page code, and stops after repeated crashes instead of looping — crashedPermanently hands you the last good snapshot.
  • Shadow DOM (new in 2.6) — the packaged shadowdom plugin copies the editor's own stylesheets into the shadow root that contains it. Without it the editor edits correctly and renders completely unstyled there, because no document stylesheet crosses a shadow boundary.
  • Restricted editing templates — the packaged restrictedediting plugin lets apps lock the document root while keeping marked islands editable (editor.restrictedEditing.enable/disable/toggle/list/goToNext/goToPrevious). Mark reusable fields with data-rte-editable in template HTML or call editor.restrictedEditing.markSelection({ label }) while authoring.
  • Document outline + TOC helpers — RichTextEditor.getDocumentOutline() / getDocumentOutline() extracts a flat heading outline (id, text, level, path), while getTableOfContents() returns a nested tree and syncTableOfContents() repopulates packaged TOC blocks from live headings. The same TOC helpers can also persist TinyMCE-style display settings such as ordered, orderedListType, and includeTitle for numbered or titleless navigation blocks. TOC items can also expose optional itemIndex / indexLabel metadata via indexMode: "linear" | "hierarchical" so custom sidebars can mirror Tiptap-style numbered navigation without reparsing headings twice.
  • Footnote extraction helper — RichTextEditor.getFootnotes() / getFootnotes() returns sorted { id, number, refId, text, path } metadata from HTML or structured JSON so review sidebars and DOCX import screens can inspect Word-style citations without reparsing the document.
  • Live document outline panel — the packaged documentoutline plugin adds a toggleable browser-side heading sidebar (editor.documentOutline.open/close/toggle/list/refresh) so long documents get CKEditor-style section navigation without custom app code. Use documentOutlineAutoOpen, documentOutlineMinLevel, and documentOutlineMaxLevel to tune the panel.
  • Packaged content minimap — the packaged contentminimap plugin adds a TinyMCE/CKEditor-style miniature document preview (editor.contentMinimap.open/close/toggle/refresh/isOpen) with click-and-drag viewport navigation for long documents. Use contentMinimapAutoOpen, contentMinimapWidth, and contentMinimapScale to tune the panel.
  • Checklist / task-list Markdown — - [x] done and - [ ] todo round-trip through fromMarkdown() / toMarkdown() and render as disabled checkbox lists in HTML / SSR output.
  • Markdown shortcut helper parity — getMarkdownShortcutAction() now recognizes headings, quotes, ordered/bullet/task lists, horizontal rules (---) and fenced code blocks (````` / ~~~lang) so custom wrappers can mirror the same typing affordances exposed by current editor competitors.
  • Word + character statistics — editor.getTextStatistics() / getSelectionStatistics() expose document + selection counts, and the built-in bottom bar can show characters, words, or both.
  • Case change commands — built-in ucase, lcase, and titlecase commands let toolbars or custom integrations quickly apply Upper Case, Lower Case, or Title Case transforms without leaving the editor.
  • Document metrics helper — RichTextEditor.getDocumentMetrics() / getDocumentMetrics() adds paragraph, sentence, heading, image, table, link, and estimated reading-time counts for analytics and publishing workflows.
  • Document page setup metadata — RichTextEditor.getDocumentPageSetup() / setDocumentPageSetup() lets apps persist page format, margins, orientation, and export header/footer HTML in the structured-content document for print/PDF workflows.
  • Framework wrappers — React (@richscripts/richtexteditor/react), Vue (@richscripts/richtexteditor/vue), Angular (@richscripts/richtexteditor/angular), Svelte (@richscripts/richtexteditor/svelte), Blazor wrapper, ASP.NET Core tag helper (<richtextbox> from the sister NuGet package).
  • Headless core — @richscripts/richtexteditor/headless mounts a chrome-less editing surface and hands you a controller (run, isActive, state, getHTML/getJSON/getMarkdown), so you can build the entire UI yourself. The Svelte binding is a thin action over it. Framework refs/components also expose the document-workflow helpers needed for outline, TOC, page-setup, metrics, statistics, and accessibility flows without manually round-tripping through getJSON().
  • AI ghost-text autocomplete (2.3.0) — Copilot / Notion-AI-style inline completion: after a typing pause a greyed suggestion appears after the caret continuing your sentence; Tab accepts, Esc / typing dismisses. Bring-your-own resolver (config.ghostTextResolver({ before, after, full }) → string | Promise); inert until config.ghostText and a resolver are set, so no model ships and no network call happens on its own. Serialization-safe (the suggestion is a contenteditable=false span the caret stays before; getHTMLCode/getJSON strip it) and undo-friendly (accept via execCommand insertText). API: editor.requestGhostText() / acceptGhostText() / dismissGhostText() / hasGhostText() / setGhostTextResolver(); slash /ai complete.
  • Local draft autosave & crash recovery (2.3.0) — persists the document to localStorage as you type and offers to restore the unsaved draft on the next load (Gmail/Notion "never lose your work"). Opt-in via config.localDraft = true (or { key, intervalMs, ttlMs, promptRestore }). The draft is never auto-applied (server content shows until the user clicks Restore); stale drafts are pruned; saves on beforeunload. API: editor.saveLocalDraft() / hasLocalDraft() / getLocalDraft() / restoreLocalDraft() / clearLocalDraft().
  • Copy / Download as Markdown (2.3.0) — Notion-style export surfacing the core toMarkdown() engine: slash /copy as markdown + /download as markdown, or editor.getMarkdown() / editor.copyAsMarkdown() (clipboard, execCommand fallback) / editor.downloadMarkdown(name?) (saves a .md file, filename derived from the first heading). Read-only; disable with config.markdownExportEnabled = false.
  • Typewriter & focus writing modes (2.3.0) — two distraction-free modes. Typewriter mode pins the caret line to a fixed vertical anchor and scrolls the editable as you type (iA Writer / Typora / Ulysses style); focus mode dims every block except the one the caret is in (Notion / iA Writer style). Toggle independently via slash /typewriter + /focus mode or editor.toggleTypewriterMode() / editor.toggleFocusMode() (plus set…/is…). Both off by default (config.typewriterModeEnabled, config.focusModeEnabled, anchor via config.typewriterAnchor). Serialization-safe: purely presentational — focus state is runtime-only CSS classes, stripped around getHTMLCode/getJSON; typewriter mode only scrolls.
  • Foldable headings (2.3.0) — Obsidian/Notion/Word-style section folding: hover a heading and click the gutter chevron (or editor.toggleHeadingFold(h) / editor.foldAtCaret() / editor.unfoldAll(), slash /fold section) to collapse every block beneath it, up to the next heading of the same or higher level. Serialization-safe: fold state is runtime-only CSS classes (the chevron is a CSS ::before), and getHTMLCode/getJSON are wrapped to strip them — saved markup stays clean and folded content is never lost. Caret-safe. Disable with config.foldHeadingsEnabled = false.
  • Auto-embed media URLs on paste (2.3.0) — paste a bare YouTube or Vimeo URL on its own line and it becomes a responsive 16:9 embed (Notion/Medium style); also available as editor.embedUrl(url). Injection-safe by construction: the iframe src is rebuilt from a sanitized video id, never the raw input. Mid-paragraph and text-selected pastes stay links. Add providers via config.autoEmbedResolver(url); disable with config.autoEmbedEnabled = false.
  • Interactive to-do / task lists (2.3.0) — editor.execCommand("inserttodolist") (slash /to-do, toolbar) inserts a checklist whose items each have a clickable checkbox; clicking toggles open/done (check + strike-through), Notion/GitHub style. The done state lives in data-checked and round-trips through getHTMLCode / getJSON; checkboxes are contenteditable="false" spans (no fragile <input>s) and re-bind on load via event delegation. Enter-created items are auto-normalized to stay well-formed.
  • Emoji :shortcode: autocomplete (2.3.0) — GitHub/Slack/Discord-style inline emoji entry: type a complete :shortcode: and it becomes the emoji (:fire: → 🔥, :+1: → 👍, :tada: → 🎉), or type : + a partial query for a keyboard-navigable popup (↑/↓, Enter/Tab, Esc). ~140 curated codes built in; extend or replace via config.emojiShortcodes. Word-boundary gated (so path:fire: is ignored) and inert inside code. Complements the emoji picker with fast keyboard-only entry. Disable with config.emojiAutocompleteEnabled = false.
  • Read-aloud / text-to-speech (2.3.0) — editor.execCommand("readaloud") (slash /read aloud) opens a non-modal control bar (play/pause, stop, voice, rate) and reads the selection — or the whole document — aloud, highlighting each sentence as it is spoken (native selection, content untouched). Browser-native via the Web Speech API: no library bundled, no backend, no key. The speech-out complement to dictation; an accessibility win. API: editor.readAloud({rate,voice}) / editor.stopReadAloud(); tune with config.readAloudRate / config.readAloudHighlight.
  • Mermaid diagrams (2.3.0) — editor.execCommand("insertdiagram") (slash /diagram) authors diagrams-as-code (flowchart, sequence, gantt, pie, class, state…) with a live preview and renders to SVG. No renderer bundled — uses config.diagramResolver (server-side render like Kroki), else a host-page window.mermaid, else a source <pre> fallback; the Mermaid source round-trips in data-diagram and re-renders via editor.renderDiagrams(). Double-click to edit; injection-safe contenteditable blocks. Pairs with the equation editor's same host-supplies-the-renderer model.
  • Inline Markdown autoformat (2.3.0) — type the closing delimiter and the span converts in place: **bold**/__bold__ → bold, *italic*/_italic_ → italic, `code` → inline code, ~~strike~~ → strikethrough. Complements the existing block shortcuts (# , > , - , 1. , ---). Word-boundary gated so math (2*3), globs and snake_case are never reformatted; inert inside code/links; fully undoable. Disable with config.markdownInlineEnabled = false.
  • Email inline-style export (2.3.0) — editor.getEmailHTML() returns email-client-safe HTML: curated computed styles written inline on every element (since Outlook/Gmail strip <style> and classes), class/id/contenteditable/data-rte-* removed, <script>/<style> dropped, content wrapped in a centered max-width table. { wrap:false } for a bare fragment, { width:N } for a custom width; tune the inlined set via config.emailInlineProperties. editor.execCommand("emailexport") (slash /email or toolbar) opens a copy-ready dialog.
  • Smart chips (2.3.0) — Google-Docs-style inline chips: editor.execCommand("insertdatechip") drops an interactive date pill (picker + machine-readable value); insertchip opens a Date/Person/Link menu. Person/Link chips use the optional config.chipResolver(query, type) for rich data, with a plain-input fallback. Atomic, injection-safe, round-trips through getHTMLCode.
  • Block drag-handles (2.3.0) — Notion-style ⋮⋮ handle on hover lets you drag-reorder top-level blocks. On by default; disable with config.blockDragHandles = false. The handle never enters the saved content.
  • Spell check (2.3.0) — editor.execCommand("spellcheck"). Native browser spell-check by default (zero infra); wire config.spellCheckResolver(text) => Promise<[{word, suggestions}]> (LanguageTool / server / LLM) for a review panel with click-to-replace suggestions. Insert from the slash menu (/spellcheck) or toolbar.
  • Web-bookmark preview cards (2.3.0) — editor.execCommand("insertbookmark") turns a URL into a rich link-preview card (favicon + title + description + domain). Works with zero backend (basic card from URL + favicon) or wire config.bookmarkResolver(url) => Promise<{title,description,image,favicon}> for full unfurled previews. Insert from the slash menu (/bookmark) or toolbar; http(s)-only with escaped metadata and rel="noopener".
  • Equation editor (2.3.0) — editor.execCommand("insertmath") opens a TeX/LaTeX dialog with a live KaTeX/MathJax preview and edit-in-place support; emits stable .rte-math-inline markup that renders via whatever math renderer the host page loads. Insert from the slash menu (/equation) or toolbar.
  • Modern block types (2.3.0) — callouts (info / success / warning / danger / note admonition boxes), multi-column layouts (2–4 responsive columns), and toggle / collapsible blocks (native <details>). Insert from the slash menu (/) or the toolbar; customize callout variants via config.calloutTypes. Editor-native (no server), round-trips through getHTMLCode / getJSON. Closes the Notion / Google Docs / CKEditor block-type gap.
  • Paste-pipeline hardening (2.2.4) — Word lists pre-converted before the attribute scrubber strips the metadata; TOC / PAGEREF / HYPERLINK / SEQ / STYLEREF / NOTEREF / REF / FORMTEXT / FORMCHECKBOX / DATE / TIME / FILENAME / AUTHOR / TITLE / SUBJECT / INDEX / MERGEFIELD field instructions stripped even when scrubbed-bare; instruction text split across continuation <span> runs caught with false-positive-safe guards; <style> / <script> / <head> / <meta> defense-in-depth; tool-specific class + data-* strip for Notion (notion-*, data-block-id), CKEditor (cke_*, data-cke-*), TinyMCE (mce_*, data-mce-*), Atlassian (confluence-*, data-confluence-*), and Google Docs (docs-internal-*, data-google-*); cleanup of empty <span></span> / <p></p> debris; smart-paste source toast detects Word / Google Docs / LibreOffice / Notion / Excel / Confluence. Opt-out via smartPasteToast: false.
  • Smart link paste (2.2.4) — pasting a URL or email while text is selected wraps the selection in <a href> instead of replacing it. Matches Google Docs / Notion / TipTap / ProseMirror behavior. Opt-out via smartLinkPaste: false.
  • Mobile toolbar mode, classic toolbar customization, plugins, localization, image editing, PDF export, paste-from-Word fidelity, find & replace, autosave, draft recovery.
  • Configurable empty-editor placeholders — set placeholder, placeholderShowOnlyWhenEditable, and placeholderColor to match the guided empty-state UX offered by current Tiptap and TinyMCE setups.
  • TypeScript declarations — typed editor methods, commands, event handlers, plugin namespaces, and configuration interfaces, with strict integration compilation tests.

The structured content bridge now returns a semantic document model while still carrying compatibility HTML and plain text:

{
  "type": "doc",
  "version": 2,
  "format": "richtexteditor-json",
  "html": "<p>Hello world</p>",
  "text": "Hello world",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Hello world" }
      ]
    }
  ]
}

Asset setup

Serve the richtexteditor/ folder from your app. The React and Vue wrappers default to loading assets from /richtexteditor.

Markdown, validation, and static rendering

import {
  auditAccessibility,
  createStructuredContent,
  fromMarkdown,
  getDocumentPageSetup,
  getDocumentMetrics,
  getDocumentOutline,
  getTableOfContents,
  getTextStatistics,
  renderHTML,
  repairAccessibilityIssue,
  setDocumentPageSetup,
  syncTableOfContents,
  toMarkdown,
  validateStructuredContent
} from "@richscripts/richtexteditor/integrations/shared";

const documentModel = fromMarkdown("## Hello\n\nThis now round-trips through **Markdown**.");
const html = renderHTML(documentModel);
const markdown = toMarkdown(documentModel);
const pagedDocument = setDocumentPageSetup(documentModel, {
  format: "Letter",
  margins: { top: "1in", right: "0.75in", bottom: "1in", left: "0.75in" }
});
const pageSetup = getDocumentPageSetup(pagedDocument);
const metrics = getDocumentMetrics(documentModel);
const outline = getDocumentOutline(documentModel);
const toc = getTableOfContents(documentModel, {
  minLevel: 2,
  maxLevel: 4,
  indexMode: "hierarchical"
});
const stats = getTextStatistics(documentModel);
const accessibility = auditAccessibility(documentModel);
const repairedDocument = repairAccessibilityIssue(documentModel, accessibility.issues[0], {
  altText: "Hero illustration"
});
const validation = validateStructuredContent(documentModel);
const syncedTocDocument = syncTableOfContents(documentModel, {
  ordered: true,
  orderedListType: "I",
  includeTitle: false
});

Structured content can also preserve document-automation nodes emitted by the editor UI:

const documentModel = createStructuredContent(
  '<p>Hello <span data-rte-merge-field="Customer.Name">Customer Name</span><sup data-rte-footnote-ref="1"><a href="#note-1">[1]</a></sup></p>' +
  '<div data-rte-footnotes="true"><div>Footnotes</div><ol><li id="note-1" data-rte-footnote-item="1">Imported from a template. <a href="#ref-1">↩</a></li></ol></div>'
);

console.log(documentModel.content[0].content[1].type); // "mergeField"
console.log(toMarkdown(documentModel)); // includes {{Customer.Name}} and [^1]

These helpers can now also run in Node for server-side rendering and content conversion without requiring a browser DOM.

const {
  auditAccessibility,
  createStructuredContent,
  getDocumentMetrics,
  getDocumentPageSetup,
  getDocumentOutline,
  getTableOfContents,
  getTextStatistics,
  renderHTML,
  repairAccessibilityIssue,
  setDocumentPageSetup,
  syncTableOfContents,
  toMarkdown,
  validateStructuredContent
} = require("@richscripts/richtexteditor/server");

const documentModel = createStructuredContent("<div data-rte-toc=\"true\"><div>Contents</div></div><h2>Hello</h2><p><strong>Server</strong> render</p>");
const html = renderHTML(documentModel);
const markdown = toMarkdown(documentModel);
const pagedDocument = setDocumentPageSetup(documentModel, {
  format: "A4",
  orientation: "portrait",
  margins: { top: "20mm", right: "16mm", bottom: "20mm", left: "16mm" }
});
const pageSetup = getDocumentPageSetup(pagedDocument);
const metrics = getDocumentMetrics(documentModel);
const outline = getDocumentOutline(documentModel);
const toc = getTableOfContents(documentModel, {
  minLevel: 2,
  maxLevel: 4,
  indexMode: "hierarchical"
});
const synced = syncTableOfContents(documentModel, {
  ordered: true,
  orderedListType: "A",
  includeTitle: false
});
const stats = getTextStatistics(documentModel);
const accessibility = auditAccessibility(documentModel);
const repairedDocument = repairAccessibilityIssue(documentModel, accessibility.issues[0], {
  altText: "Server-rendered architecture diagram"
});
const validation = validateStructuredContent(documentModel);

Use @richscripts/richtexteditor/server when you only need conversion and rendering on the server. Use @richscripts/richtexteditor/integrations/shared when you also need browser asset loading and editor bootstrapping helpers.

The same server/shared helpers also expose getTextStatistics(value) for consistent word and character counts outside the browser. The React, Vue, and Angular package entry points re-export this same helper family so framework apps can import editor components and document-workflow utilities from one place.

DOCX import

Current competitors like CKEditor, TinyMCE, and Tiptap all market Word/DOCX import alongside export. Rich Text Editor now ships a matching browser helper for the client side of that flow:

await editor.aiToolkit.importDocx({
  url: "/richtextbox/import/docx",
  mode: "replace"
});

Pass file to skip the built-in file picker, or use mode: "insert" to insert the converted HTML at the current caret instead of replacing the whole document. The helper expects the server endpoint to return either raw HTML or JSON with an html field.

React

"use client"; // Required for your own state/callback wrapper in Next.js App Router.

import { useState } from "react";
import { RichTextEditorReact } from "@richscripts/richtexteditor/react";

export default function EditorPage() {
  const [html, setHtml] = useState("<p>Hello world</p>");

  return (
    <RichTextEditorReact
      value={html}
      onChange={setHtml}
      assetBasePath="/richtexteditor"
      style={{ minHeight: 360 }}
    />
  );
}

The /react ESM entry declares its own client boundary. A Next.js App Router Server Component may render <RichTextEditorReact value="<p>Hello</p>" /> with serializable props. Put useState, onChange, refs and function-valued editor configuration in your own "use client" wrapper, as above; do not pass ordinary callbacks across the server/client boundary. Copy the runtime to public/richtexteditor as in the installation steps. Server-side HTML/Markdown conversion belongs in @richscripts/richtexteditor/server, not in the React client entry.

CommonJS require("@richscripts/richtexteditor/react") remains available with the same named API. No ssr: false workaround is needed just to render the wrapper; the browser-only editor is created after the component mounts.

Vue

import { createApp, ref } from "vue";
import { RichTextEditorVue } from "@richscripts/richtexteditor/vue";

const app = createApp({
  components: { RichTextEditorVue },
  setup() {
    const documentModel = ref({
      type: "doc",
      version: 2,
      format: "richtexteditor-json",
      content: [
        {
          type: "paragraph",
          content: [{ type: "text", text: "Hello world" }]
        }
      ]
    });

    return { documentModel };
  }
});
<RichTextEditorVue
  v-model="documentModel"
  value-format="json"
  asset-base-path="/richtexteditor"
  style="min-height: 360px"
/>

Angular

Angular 20.3+ apps can use the packaged standalone wrapper component. The public entry point is precompiled partial-Ivy ESM, with generated Angular component metadata; consumers do not need to compile the wrapper's TypeScript source:

import { Component } from "@angular/core";
import { RichTextEditorAngularComponent } from "@richscripts/richtexteditor/angular";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [RichTextEditorAngularComponent],
  template: `
    <rich-text-editor
      [assetBasePath]="'/assets/richtexteditor'"
      [value]="documentModel"
      [valueFormat]="'json'"
      (change)="handleChange($event.value)"
    />
  `
})
export class AppComponent {
  documentModel = {
    type: "doc",
    version: 2,
    format: "richtexteditor-json",
    content: [
      {
        type: "paragraph",
        content: [{ type: "text", text: "Hello from Angular" }]
      }
    ]
  };

  handleChange(value: unknown) {
    this.documentModel = value as typeof this.documentModel;
  }
}

The Angular wrapper exposes the same JSON bridge and convenience instance methods as the React and Vue wrappers: focus(), getEditor(), getHTMLCode(), getJSON(), setHTMLCode(), setJSON(), and plugin accessors such as getAiToolkit() / getCollab() / getComments().

When asset loading fails, show the (error) message and offer an explicit retry: await editorComponent.retry(). It retries a failed initialization while keeping the current bound form value. It does not replace an editor that is already ready, start a second concurrent load, or revive a destroyed component. Fix missing asset paths/CSP permissions before retrying. Asset autoload waits for theme CSS as well as core/plugins; failed script or stylesheet requests can be retried without refreshing the whole page. Manually preloaded (autoload=false) assets remain your application's responsibility.

The repo also includes a full Angular starter in Demos/rte-ng-ts/ that uses the same wrapper pattern.

For maintainers: npm run build:angular regenerates the ESM entry point, declarations and source/output hash manifest. npm pack runs this step through prepack; the build needs the declared Angular/compiler/TypeScript/esbuild dev dependencies (or the installed Angular demo toolchain in a repository checkout). The normal test suite rejects stale generated files. Angular dependencies are optional peers and are not required by React/Vue/server-only consumers.

Angular forms

The wrapper implements ControlValueAccessor: import FormsModule for [(ngModel)]="body", or ReactiveFormsModule for [formControl]="bodyControl" and formControlName. These modules come from @angular/forms; use matching Angular core/forms versions. Angular peers are optional for React/Vue users.

import { FormControl, ReactiveFormsModule } from "@angular/forms";
// Add ReactiveFormsModule and RichTextEditorAngularComponent to your imports.
bodyControl = new FormControl("<p>Hello</p>", {
  nonNullable: true,
  updateOn: "blur"
});
<rich-text-editor [formControl]="bodyControl"
  [assetBasePath]="'/assets/richtexteditor'" />

HTML is the default; use valueFormat="json" for structured document values. External writes do not echo as user changes, null resets clear the editor, and disable() / enable() update read-only state. Pending edits are flushed before blur so updateOn: "blur" receives the final character. The default 120 ms change coalescing also applies to form changes; set [changeDelay]="0" for the shortest quiet period. Do not combine [value] with a form binding. An explicit config.readOnly: true remains read-only after enable().

Use a content-aware required validator: Validators.required on the HTML string alone accepts empty markup such as <p><br></p>. Check outstanding image uploads before saving; valid form state is not an upload-completion guarantee.

Svelte

@richscripts/richtexteditor/svelte ships a plain Svelte action — no compiler coupling, so it works on Svelte 3 / 4 / 5. It mounts the headless core on your element and hands back the controller, leaving the toolbar markup entirely to you.

<script>
  import { richTextEditor } from "@richscripts/richtexteditor/svelte";

  let editor;        // controller, once ready
  let state = {};    // active formats, refreshed as the caret moves
</script>

<button disabled={!editor} aria-pressed={!!state.bold} class:is-active={state.bold}
        on:mousedown|preventDefault={() => {}}
        on:click={() => editor.run("bold")}>Bold</button>
<button disabled={!editor || !state.canUndo}
        on:mousedown|preventDefault={() => {}}
        on:click={() => editor.run("undo")}>Undo</button>

<div use:richTextEditor={{
       value: "<p>Hello</p>",
       onReady:  (e) => (editor = e),
       onUpdate: (s) => (state = s)
     }}></div>

Prevent the pointer's default mousedown focus change to keep the caret, but run commands from click so keyboard activation works too. Changing the value you pass in pushes new content into the editor, so a store can own the document, and the action calls destroy() for you when the node unmounts. In SvelteKit this is client-only code — keep it in a component that renders in the browser.

Options: value, config, assetBasePath, signal, onReady, onUpdate, onChange, onError.

Updating the action's options replaces callbacks without remounting. Only a changed bound value replaces the document; an unrelated callback/state update does not overwrite typing. Unmounting during asset loading disposes any late controller and does not emit ready/error callbacks into a destroyed view.

For Svelte 5 runes, use reactive controller/state variables and the modern event syntax:

<script>
  import { richTextEditor } from "@richscripts/richtexteditor/svelte";
  let editor = $state(null);
  let state = $state({});
  let html = $state("<p>Hello</p>");
</script>

<button disabled={!editor} aria-pressed={!!state.bold}
        onmousedown={event => event.preventDefault()}
        onclick={() => editor.run("bold")}>Bold</button>
<div use:richTextEditor={{
  value: html,
  onReady: controller => { editor = controller; },
  onUpdate: next => { state = next; },
  onChange: function () { html = this.getHTML(); }
}}></div>
<textarea aria-label="HTML output" readonly value={html}></textarea>

onChange runs with the controller as this. On unmount it can synchronously deliver a final native edit before the core's deferred change event. Read/save the value inside that callback; do not clear your controller reference first. A programmatic bound-value update is not emitted again as a final user edit. Toolbar controls should have visible names, aria-pressed for toggle state and disabled state until ready. This is a keyboard-usable starting point, not a complete accessibility certification for a custom toolbar.

Web component

For pages that use none of the framework bindings above — Rails, Laravel, Blazor, Django, htmx, plain HTML.

<link rel="stylesheet" href="richtexteditor/rte_theme_default.min.css">
<link rel="stylesheet" href="richtexteditor/rte_mobile.min.css">
<script src="richtexteditor/rte-config.js"></script>
<script src="richtexteditor/rte.js"></script>
<!-- Optional plugins for the full toolbar. Omit unused plugins in a modular build. -->
<script src="richtexteditor/plugins/all_plugins.min.js"></script>
<script src="richtexteditor/rte-webcomponent.js"></script>

<form method="post">
  <rich-text-editor name="body" value="<p>Hello</p>"></rich-text-editor>
  <button>Save</button>
</form>

Serve the page and runtime from your local HTTP server, not file://. That form posts body=... with no value-copying glue in browsers supporting form-associated custom elements. required and form.elements.body use ElementInternals; older-browser hidden-input fallback supports submission, not full native validation. Always validate and sanitize the submitted HTML on your server.

React and Vue components commit the latest native input on editor blur, even if their normal change callback is coalesced or the core change event is still pending. This makes an ordinary editor-to-Save-button interaction safer, but it is not an upload-completion or persistence guarantee. For programmatic saves, read fresh HTML through the editor ref in the save/submit handler and wait for your image upload adapter's pending requests first. A form.submit() call skips native validation and submit listeners; use requestSubmit() when you need that workflow. React can still batch a parent state update when code forces focus and clicks Save inside one synchronous function. Use the editor ref for that same-turn programmatic snapshot; a captured value from the previous parent render is not a fresh document.

The value attribute is the default document; alternatively put initial HTML between the tags. form.reset() restores that initial document. Set the .value property for application updates, and await readiness before calling editor APIs:

const field = document.querySelector("rich-text-editor");
field.value = "<p>Application content</p>"; // also works before first connection
await field.ready;
console.log(field.value); // current HTML, not the original value attribute
const payload = new FormData(field.form); // name="body" participates automatically

disabled (including an enclosing disabled <fieldset>) disables editing and excludes the value from form submission. readonly prevents editing but keeps the submitted value. Rich-text emptiness ignores whitespace/entities and blank paragraphs; images and other media count as content. These client checks are not upload validation. Use the image-handling guide for a persistent upload backend and wait for pending uploads before saving a document.

Attributes: value, name, toolbar, height, width, readonly, disabled, required, shadow, config (JSON). Properties: .value, .editor, .ready (a Promise). Events: input, change. It renders in the light DOM by default; shadow opts into encapsulation and relies on the shadowdom plugin for styling.

Headless (bring your own UI)

@richscripts/richtexteditor/headless is the same core the Svelte action uses, available directly to any framework or to plain JavaScript.

import { createHeadlessEditor } from "@richscripts/richtexteditor/headless";

const editor = await createHeadlessEditor("#host", { value: "<p>Hello</p>" });

editor.run("bold");                  // dispatch a command (chainable)
editor.run("formatblock", "h1");     // block types go through formatblock
editor.isActive("bold");             // => true / false
editor.can("undo");                  // is the command enabled?
editor.state();                      // { bold, italic, ..., canUndo, canRedo }

editor.on("update", (s) => renderToolbar(s));

editor.getHTML();                    // publish-ready markup
editor.getJSON();                    // structured document model
editor.getMarkdown();
editor.getStatistics();              // { words, characters }

Block-level formats go through run("formatblock", "h1") rather than run("h1"). Assets load from /richtexteditor unless you pass assetBasePath; pass autoload: false if the page already loads them.

Initialization supports signal: AbortSignal. Cancel an abandoned view while assets load without cancelling other editors that share the same asset request:

const pending = new AbortController();
const initialization = createHeadlessEditor("#host", { signal: pending.signal });
// If the view closes before initialization settles: pending.abort();
const controller = await initialization;
// Once mounted, explicitly call controller.destroy() when the view closes.

Cancellation rejects with the signal's reason (AbortError by default). It does not destroy an already mounted editor. The React/Vue headless bindings and Svelte action wire their own pending-initialization cancellation on unmount. React headless bindings use the latest value and callbacks. For Vue, pass a reactive options object if the value should follow application state:

const options = reactive({ value: "<p>Initial</p>" }); // import reactive from vue
const { mountRef, editor, state } = useRichTextEditor(options);
options.value = "<p>Loaded from the application</p>";

Classic usage

<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css" />
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<!-- Production: swap the line above for the minified bundle (~56% smaller, identical behaviour):
     <script src="/richtexteditor/plugins/all_plugins.min.js"></script> -->

<div id="editor"></div>
<script>
  var editor = new RichTextEditor("#editor", {
    placeholder: "Write something...",
    placeholderShowOnlyWhenEditable: true,
    commentsOnly: false,
    restrictedEditingMode: false,
    accessibilityCheckerAutoOpen: false,
    documentOutlineAutoOpen: false,
    documentOutlineMinLevel: 1,
    documentOutlineMaxLevel: 3
  });
  editor.setHTMLCode("<p>Hello world</p>");
  console.log(editor.getJSON());
</script>

Restricted editing is meant for contract, intake, and mail-merge style templates:

<p>Hello <span data-rte-editable="true" data-rte-editable-label="Customer Name">Customer Name</span>,</p>
<p>Your case number is <span data-rte-editable="true" data-rte-editable-label="Case ID">0000</span>.</p>
editor.setHTMLCode(templateHtml);
editor.restrictedEditing.enable();
editor.restrictedEditing.goToNext();

Template authors can also mark regions interactively before switching modes:

editor.restrictedEditing.markSelection({ label: "Account manager" });
editor.restrictedEditing.markSelection({ block: true, label: "Custom notes" });
editor.restrictedEditing.enable();

In order to start using Rich Text Editor, configure or customize it further, please visit the documentation.

State inspector (developer tool)

A derived view of the document the editor is actually holding: a serializable snapshot (node tree, selection, plugin initialization order) plus structural checks for the faults that break documents downstream - zero-length text nodes, empty inline wrappers, duplicate nested formatting, control characters that make an exported .docx unreadable, unsafe URL schemes, iframe[srcdoc], orphaned list items, images with no alt text, and unprotected atomic content.

editor.stateInspector.open();        // panel with Issues / Tree / Selection / Plugins / Log
editor.stateInspector.issues();      // [{ id, severity, message, detail }]
editor.stateInspector.snapshot();    // plain JSON - attach this to a bug report

It is not in any default toolbar. Add the button with stateInspectorToolbarButton: true, open it from the API, or type /inspect. Other options: stateInspectorEnabled, stateInspectorAutoOpen, stateInspectorMaxDepth, stateInspectorLogLimit.

License

For licensing information please read Licensing Info.

Related Packages

@oix1987/yjd

Dependency-free, tree-shakeable rich text editor & WYSIWYG for the web — comment...

13,509 3
stilo-editor

A complete rich text editor. Everything included, ready in minutes. Open-source...

234 2
@lilac-wysiwyg/core

Lilac - a smooth, modern WYSIWYG text editor built with pure TypeScript. Zero de...

1,851 15
@raazkhnl/rk-editor-core

Free open-source headless rich-text Word editor for the web. Built on Tiptap & P...

1,157 8
@domternal-pro/extension-collaboration

Real-time collaboration for Domternal: Yjs binding with collaborative undo/redo

2,061

Version History

Version Released License
2.15.0 Custom
2.14.1 Custom
2.14.0 Custom
2.13.0 Custom
2.12.1 Custom