Rich Text Editor

Pro

Full-featured contenteditable editor with 9 toolbar groups — fonts, colors, alignment, lists, link/table/code, undo/redo, source view, fullscreen and word count.

</>sv-rich-text-editor editorwysiwygcontent
import { SvRichTextEditorComponent } from '@styloviz-pro/rich-editor';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Constrained with counters

Pro

Accessible labelling

Pro

Give the editor a meaningful accessible name with ariaLabel; the built-in role="textbox" + aria-multiline and aria-pressed toolbar toggles do the rest.

Reactive forms (CVA)

Pro

Implements ControlValueAccessor, so it binds with formControlName / ngModel; formControl.disable() disables the toolbar and editor.

Paste cleaning

Pro

On by default. Clipboard markup is parsed inertly and rebuilt from an allowlist before it reaches the document — the browser paste is cancelled first, so nothing from the clipboard ever touches a live node.

Paste as plain text

Pro

Mentions

Pro

The trigger scan is shared with sv-mention, so the rule that stops an email address opening a people-search is one rule. The inserted chip is atomic to the caret.

Mentions that were edited after insertion

Pro

contenteditable="false" makes a chip atomic to the caret; it does not make it immutable. A chip reading "@Priya Sharm" still carries Priya's user id.

Merged table cells

Pro

Select across cells and merge. The grid model is the HTML spec's slot map, because once a cell has a span the DOM stops telling you which column a cell is in.

The grid model, on its own

Pro

Exported, because "which column is this cell in" has no answer in DOM terms once a span exists — and any host doing its own table work needs the same map the editor uses.

Markdown mode

Pro

A round trip through Markdown is lossy — Markdown has no colour, no underline and no merged cells — so the conversion returns what it dropped instead of pretending it dropped nothing. The notice is shown on the way in, while the HTML that carried the formatting still exists.

Markdown without the mode toggle

Pro

Read and write Markdown directly. While the mode is open, markdown() returns the live textarea rather than a stale conversion of the HTML behind it — an autosave wired to the other choice saves the wrong text, and only for authors who happened to be in Markdown mode when it fired.

Clean markup outside the editor

Pro

The sanitiser is exported, so the same rules can be applied to content arriving from anywhere else — an import, a webhook, a migration.

When to use

  • Authoring formatted content: comments, descriptions, articles, email bodies.
  • You need bold/lists/links/tables without pulling in a heavy third-party editor.
  • For plain short text, a multiline Text Field is lighter.

Async states

None — this component has nothing to load, so it has no loading, empty or error state to drive.

Inputs

NameTypeDefaultDescription
valuestring''HTML content. Use `[(value)]` for two-way binding.
placeholderstring'Start typing here…'Placeholder shown when empty.
ariaLabelstring''Accessible name for the editor (announced by screen readers). Falls back to the placeholder.
heightstring'320px'Editor height (CSS length).
disabledbooleanfalseDisable editing.
readonlybooleanfalseRead-only mode — content visible but not editable.
maxLengthnumber0Max character count (0 = unlimited).
showWordCountbooleantrueShow the word counter.
showCharCountbooleantrueShow the character counter.
autoFocusbooleanfalseFocus the editor on init.
toolbarLine'wrap' | 'scroll''wrap'Toolbar overflow behaviour: wrap to multiple rows or scroll.
documentTitlestring''Title for a printed or exported document. It becomes the exported document's `<title>`, which is what a browser offers as the default filename in its print dialog — "Document Export" for every document in the product is a folder of indistinguishable PDFs.
hiddenToolsreadonly SvRichTextEditorTool[][]Toolbar groups to hide. Empty by default — nothing changes for existing callers. See SvRichTextEditorTool for why a host would want this.
showColorToolsbooleantrueShow the font-colour and highlight pickers.
htmlSanitizer((html: string) => string) | nullnullReplaces the sanitiser applied to HTML that ENTERS the editing surface from outside — `[value]`, `writeValue` (formControl) and leaving source view — and to the HTML handed to print/PDF/image export. THE DEFAULT IS ON, AND `null` MEANS "THE DEFAULT", NOT "NONE". The editor used to assign `[value]` straight to `innerHTML` on a live node, so a stored `<img src=x onerror=…>` ran in the host's origin for every reader who opened the document — the paste path was sanitised, the far more common "load the saved document" path was not. The default is `svSanitizeEditorHtml` from `@styloviz/core`, whose allowlist matches what this editor writes (tables, images, fonts, mentions) so a saved document round-trips unchanged. Pass your own function to use a different policy (e.g. DOMPurify with your configuration). Returning the input unchanged switches sanitising off; only do that for HTML you have already sanitised on the server. ⚠️ This protects the editor, not your app. Sanitise again where the value is stored and wherever else you render it.
mentionTriggersreadonly string[][]Trigger characters that open the mention panel, e.g. `['@', '#']`. Empty disables mentions entirely.
mentionSuggestionsreadonly SvMentionToken[][]Candidates for the open query. The host filters; this component displays.
mentionChipClassstring'rounded bg-brandaccent-100 px-1 font-m…Tailwind classes for the inserted chip.
cleanPastebooleantrueClean markup arriving from the clipboard. ON BY DEFAULT, AND THE DEFAULT IS THE POINT. Without it the browser drops whatever the clipboard carried straight into the contenteditable, and this component reads its value back out of `innerHTML` — so a host that persists that value and renders it to another reader has a stored-XSS path whose injection point is somebody else's clipboard. Turning this off is a decision about trusting every author of every document.
pastePlainTextbooleanfalsePaste everything as plain text, discarding all formatting.
allowPastedDataImagesbooleanfalseKeep `data:image/*` sources on pasted images. Off by default: pasting a screenshot inlines megabytes of base64 that a persisting host then puts into a database row.
savingbooleanfalseTrue while the document is being saved.
savedAtDate | nullnullWhen the last successful save completed. Drives the "Saved just now" indicator; `null` means nothing has been saved yet this session.
saveErrorSvDataError | string | nullnullFailure saving. Kept visible until the next successful save — an editor that silently stops persisting is the worst failure mode this component has, because the user keeps typing into something that is not being kept.
conflictbooleanfalseSomeone else changed the document since it was loaded. Shown as a warning so the user does not overwrite their colleague without knowing.
errorMessagestring''Client-side validation message.
serverErrorSvServerErrornullA rejection reported by the server about this value. Self-expiring.

Outputs

EventPayload typeDescription
mentionQuerySvTriggerMatchEmitted whenever the open query changes, so the host can look candidates up.
mentionInsertedSvMentionTokenEmitted when a mention is inserted.
pasteCleanedSvPasteResultEmitted when a paste was cleaned, with what was removed. A sanitiser that eats content silently is indistinguishable from a broken paste, and the author's next move is to paste through Notepad instead.
valueChangestringEmits the editor's HTML after every edit, formatting command, undo or redo — the output half of `[(value)]`.
contentChangestringEmits the same HTML as `valueChange`, on the same edits — for hosts that listen rather than bind `[(value)]`.
editorFocusvoidFires when the editable area gains focus.
editorBlurvoidFires when the editable area loses focus; the same blur marks the form control touched and requests a save (`saveRequest`).
saveRequeststringPersist the current content. Emitted on blur and whenever the host asks via `requestSave()`; the component never saves on a timer of its own, because only the host knows what a save costs.
exportFailed{ format: RteExportFormat; error: unknown }Emitted when an export could not be produced, with the underlying error.
markdownChangedstringFires with the Markdown text each time the author leaves the mode.

Customization & theming

  • Set `height`, `placeholder`, `ariaLabel` and the `toolbarLine` overflow mode (wrap or scroll).
  • Switch between rich and source (HTML) views, fullscreen, and toggle the word / character counters.
  • Two-way bind with `[(value)]` or bind a reactive control (ControlValueAccessor); react to `contentChange`, `editorFocus` and `editorBlur`.
  • Paste cleaning via `cleanPaste` (on by default), `pastePlainText` and `allowPastedDataImages`, with a `pasteCleaned` output naming what was removed. `svCleanPastedHtml`, `svPlainTextFromHtml` and `svIsSafeUrl` are exported for use outside the editor.
  • Merged table cells via `mergeTableCells()`, `splitTableCell()`, `tableMergeRefusal()` and `caretInMergedCell()`. The slot map (`svTableGrid`, `svSelectionRect`, `svMergePlan`, `svSplitCell`) is exported for hosts doing their own table work.
  • Markdown mode via `toggleMarkdownMode()`, `markdown()`, `setMarkdown()`, `markdownNotice()` and the `markdownChanged` output. `svHtmlToMarkdown` returns `{ markdown, lost }`; `svMarkdownToHtml`, `svEscapeMarkdown` and `svMarkdownLossLabel` are exported too.
  • Mentions via `mentionTriggers` (any number of characters — `@` people, `#` topics, `/` commands), `mentionSuggestions`, `mentionQuery` and `mentionInserted`. `mentions()`, `mentionRepairs()` and `plainText()` read the document back. The scan is `svScanTrigger` from `@styloviz/core`, shared with `sv-mention`.

Best practices

  • Do: Sanitize the emitted HTML on the server too. `cleanPaste` closes the clipboard path, and it is a client-side control: anything that can POST to your API can send markup that never went through it.
  • Do: Allow `style-src 'unsafe-inline'` in your CSP, or turn the controls that write inline styles off — `[showColorTools]="false"` and `hiddenTools` with `'alignment'`, `'fontFamily'` and `'fontSize'`. `execCommand('foreColor')` writes inline `style` attributes rather than classes, and the paste allowlist deliberately keeps them — so under `style-src 'self'` the text survives and the colour, highlight and alignment silently do not, in the editor and everywhere the value is later rendered. Nothing throws. A hash or nonce does not help: those apply to `<style>` elements, not to `style` attributes.
  • Do: Add `data:` to `img-src` if you set `allowPastedDataImages`, and decide what `img-src` should say about the third-party hosts your authors paste from. Pasted `http(s)` image sources are kept as-is — those are requests from your page to somebody else's server. Upload and rewrite them at the boundary if they need to be same-origin.
  • Don't: Don't treat the client sanitiser as the security boundary. This component's value is a string and your API accepts that string over HTTP, so anything that can make a request can send one that never passed through `cleanPaste` — curl, a replayed request, an importer, a migration. Full guidance in `docs/html-value-security.md`.
  • Do: Handle `pasteCleaned` and show the summary. A sanitiser that eats content silently is indistinguishable from a broken paste, and the author's next move is to paste through Notepad.
  • Do: Read mentions with `mentions()` rather than tracking them as you insert them. A list kept beside the document drifts the moment somebody deletes a chip or undoes, and the drift is invisible until a notification goes to the wrong person.
  • Do: Check `mentionRepairs()` before acting on mentions. A chip can be edited after insertion — it stays atomic to the caret but is not immutable — and an edited chip carries a real user id next to a name that is not theirs.
  • Do: Disable your merge button on `tableMergeRefusal()` rather than letting the merge fail — the refusal names which of the three reasons applies, and a merge that went ahead on a region that is not exactly covered leaves rows of different widths.
  • Do: Show `markdownNotice()` when Markdown mode is open, and leave it up. It is computed on the way IN, while the HTML that carried the colour and the merged cell still exists — after the switch there is nothing left to inspect, and `execCommand`'s undo history does not span a mode change.
  • Don't: Don't treat a Markdown round trip as a save format for documents that use colour, alignment, underline or merged cells. It is a text view, not a lossless one, which is why the conversion returns a loss list rather than a string.
  • Do: Set a `maxLength` and surface counters for user-generated content.
  • Do: Pass `ariaLabel` so the editor has a clear accessible name beyond the placeholder.
  • Don't: Don't set `cleanPaste="false"` unless you have decided you trust every author of every document. The editor reads its value back out of `innerHTML`, so whatever the clipboard carried is what gets persisted and rendered to the next reader.

Accessibility

  • The editing surface is `role="textbox"` with `aria-multiline`; `ariaLabel` sets a meaningful accessible name (falling back to the placeholder).
  • The `role="toolbar"` holds labelled buttons, and formatting toggles (bold, italic, underline, strikethrough, super/subscript, ordered/unordered list, source, fullscreen) expose `aria-pressed`.
  • The word / character count is an `aria-live="polite"` status region; common shortcuts (Ctrl/Cmd+B/I/U, undo/redo) are supported.
  • Merging keeps the content of every absorbed cell rather than only the top-left one — silently destroying three of four values is not something an author can see happen, and undo is the only way back.
  • The mention panel is a `role="listbox"` that never takes focus: the editor keeps it and announces the active option through `aria-activedescendant`, because moving focus out of the contenteditable loses the caret the insertion needs. Arrows move, Enter and Tab insert, Escape closes — and those keys are only claimed while the panel is open, so Enter still makes a paragraph the rest of the time.
  • The Markdown loss notice is `role="status"`, not an alert: it reports a consequence of the button the author just pressed, and an alert would interrupt a screen reader mid-sentence to announce something they asked for. The Markdown textarea carries its own label, and the rich surface goes `aria-readonly` while the mode is open so it is not announced as two editable regions.
  • Pasting is cleaned rather than blocked: the content the author meant survives (an unknown wrapper is unwrapped, not deleted), and what was removed is reported instead of vanishing — a silent sanitiser is indistinguishable from a broken paste for somebody who cannot see the result.

Feedback on Rich Text Editor

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.