ComponentSignal FormsSSR-safeAdded in v14.9Updated in v15

Editor

Rich-text editor whose value is the document as a JSON tree.

Installation

Import the class, and @use the entry point in your global styles. Nothing warns you when the @use is missing.

import { Component } from '@angular/core';
import { WrEditor } from 'ngwr/editor';

@Component({ imports: [WrEditor] })
export class MyComponent {}

Peer dependencies

Install ProseMirror to use the editor.

pnpm add prosemirror-model prosemirror-state prosemirror-view prosemirror-commands prosemirror-keymap prosemirror-history prosemirror-schema-list prosemirror-inputrules

Basic usage

[(value)] is the document as a WrEditorJson tree, or null while nothing is written.

Agenda

  • Done:

    Budget review

  • To do:

    Hiring plan

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {
        "level": 3
      },
      "content": [
        {
          "type": "text",
          "text": "Agenda"
        }
      ]
    },
    {
      "type": "bullet_list",
      "attrs": {
        "tight": true
      },
      "content": [
        {
          "type": "list_item",
          "attrs": {
            "checked": true
          },
          "content": [
            {
              "type": "paragraph",
              "content": [
                {
                  "type": "text",
                  "text": "Budget",
                  "marks": [
                    {
                      "type": "strong"
                    }
                  ]
                },
                {
                  "type": "text",
                  "text": " review"
                }
              ]
            }
          ]
        },
        {
          "type": "list_item",
          "attrs": {
            "checked": false
          },
          "content": [
            {
              "type": "paragraph",
              "content": [
                {
                  "type": "text",
                  "text": "Hiring plan"
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
<wr-editor ariaLabel="Agenda" placeholder="What is on the agenda?" [(value)]="agenda" />
<pre>{{ agenda() | json }}</pre>
// "Hello, **world**" — one paragraph, two text nodes, one mark.
const value: WrEditorValue = {
  type: 'doc',
  content: [
    {
      type: 'paragraph',
      content: [
        { type: 'text', text: 'Hello, ' },
        { type: 'text', text: 'world', marks: [{ type: 'strong' }] },
      ],
    },
  ],
};

Storing HTML or markdown

Convert the JSON yourself: to HTML with prosemirror-model and your own Schema, to markdown with any serializer.

import { DOMSerializer, Node, type Schema } from 'prosemirror-model';

import type { WrEditorJson } from 'ngwr/editor';

// `schema` is your own, declaring the nodes and marks WrEditorJson lists.
export function toHtml(value: WrEditorJson, schema: Schema, document: Document): string {
  const doc = Node.fromJSON(schema, value);
  const host = document.createElement('div');
  host.append(DOMSerializer.fromSchema(schema).serializeFragment(doc.content, { document }));
  return host.innerHTML;
}

Toolbar

toolbar lists the tools in order, with '|' between groups; false hides it and keeps the shortcuts.

<wr-editor ariaLabel="Comment" placeholder="Leave a comment" [toolbar]="tools" [(value)]="comment" />
import { WR_EDITOR_TOOLBAR, type WrEditorTool } from 'ngwr/editor';

// The default toolbar without the two code tools.
readonly tools: readonly WrEditorTool[] = WR_EDITOR_TOOLBAR.filter(
  tool => tool !== 'code' && tool !== 'codeBlock'
);
<!-- No toolbar; the shortcuts still work. -->
<wr-editor ariaLabel="Notes" [toolbar]="false" [(value)]="notes" />

Signal Forms

[formField] binds the value; required() treats an empty editor as empty.

Required.
null
<wr-form-field label="Description" hint="Required.">
  <wr-editor placeholder="Describe the change" [formField]="ticket.description" />
</wr-form-field>
<pre>{{ ticket.description().value() | json }}</pre>

Read-only and disabled

readonly shows the document without starting ProseMirror; disabled keeps the editor and takes it out of the tab order.

Agenda

  • Done:

    Budget review

  • To do:

    Hiring plan

Agenda

  • Done:

    Budget review

  • To do:

    Hiring plan

<!-- A viewer: no toolbar, no ProseMirror. -->
<wr-editor ariaLabel="Published notes" readonly [toolbar]="false" [value]="notes" />

<wr-editor ariaLabel="Locked notes" disabled [value]="notes" />

Bound to a signal, readonly mounts the editor the first time it turns false.

Keyboard

The toolbar is one tab stop; the arrow keys move between its tools.

Focus is in the text. Mod is ⌘ on Apple keyboards and Ctrl elsewhere.
KeyDoes
Mod + B · Mod + I · Mod + UBold, italic, underline.
Mod + Shift + S · Mod + EStrikethrough, inline code.
Mod + Alt + 0 … Mod + Alt + 3Paragraph, heading 1 to 3.
Mod + Shift + 8 · Mod + Shift + 7Bulleted list, numbered list.
Mod + Shift + B · Mod + Alt + CQuote, code block.
Mod + ] · Mod + [Indent, outdent a list item.
Mod + KOpen the link panel.
Mod + Z · Mod + Shift + Z · Ctrl + YUndo, redo. Ctrl + Y is not bound on Apple keyboards.
Shift + EnterLine break.
Mod + Enter · ArrowDown · ArrowUpLeave a code block or a table onto a new paragraph.
#, ##, ###, >, -, 1. then Space; ``` At the start of a line: heading, quote, list, code block. Backspace right after undoes it.

Untrusted input

A bound value with an unknown node or mark, or a link or image the URL check refuses, is refused whole.

// Refused whole: the URL check never lets a javascript: link through.
protected readonly imported = signal<WrEditorValue>({
  type: 'doc',
  content: [
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'a script link',
          marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)', title: null } }],
        },
      ],
    },
  ],
});
  • A refused value leaves the current document on screen, makes the editor read-only (wr-editor--refused) and warns in dev mode.
  • Pasted content is cleaned against the same schema.

Gotchas

What happensWhat to do
With no name, every editor is announced as "Rich text".Set ariaLabel, or put it in a <wr-form-field label>.
Tab leaves the editor; it does not indent.Indent list items with Mod + ] and Mod + [.
On Windows layouts with AltGr, the Mod + Alt chords type characters instead. Set block types from the toolbar.
A CSP trusted-types allowlist breaks paste.Add ProseMirrorClipboard to the allowlist.

Bundle cost

Wrap the editor in @defer to keep ProseMirror off the first paint.

@defer (on viewport) {
  <wr-editor [formField]="form.body" />
} @placeholder {
  <div class="editor-placeholder" aria-hidden="true"></div>
}

What it does not do

  • Insert tables or images. Bound ones are kept, and table text stays editable.
  • Toggle a task item's box.
  • Colours, fonts, sizes, alignment, or headings past level three.
  • Mentions, collaborative editing, custom nodes or custom tools.

API

NameDescriptionTypeDefault
valueThe document as a {@link WrEditorJson} tree; null when nothing is written. Bound by [formField], or two-way via [(value)].WrEditorValuenull
(touch)Emitted on blur so a bound field can mark itself touched.void—
disabledDisable the editor and its toolbar. Bound automatically from the field's disabled state when used with [formField].booleanfalse
readonlyRefuse edits while the text stays reachable, selectable and announced. Bound automatically from the field's readonly state when used with [formField]. Set before the editor mounts, it means ProseMirror is never started: the document is drawn as ordinary markup in a focusable role="group" that keeps the tab stop and carries the editor's name, so a viewer costs no editor and the structure of what it shows is still announced. aria-readonly is deliberately absent there — ARIA does not allow it on group, and aria-disabled, the only attribute that would apply, means something else — so the cue is the wr-editor--readonly class. The toolbar goes inert, since none of its tools can apply; a pure viewer binds [toolbar]="false". A view that is already mounted stays mounted, refuses typing, paste, drop, the shortcuts and every toolbar command, and reports aria-readonly on its surface as before.booleanfalse
placeholderHint shown while the document is empty, and exposed as aria-placeholder.string''
ariaLabelAccessible name of the text — of the surface, or of the document a read-only editor draws in its place. Falls back to the surrounding <wr-form-field>'s label, then to editor.label.string | nullnull
toolbarThe toolbar's tools, in order, with '|' between groups; false hides it.readonly WrEditorTool[] | falseWR_EDITOR_TOOLBAR

Types

The value shape and the toolbar vocabulary.

NameDescriptionTypeDefault
WrEditorValueThe value model: a WrEditorJson tree, or null when empty.WrEditorJson | null—
WrEditorJsonThe document as plain data, in the shape ProseMirror writes.interface—
typerequireddoc, paragraph, heading, blockquote, code_block, horizontal_rule, bullet_list, ordered_list, list_item, hard_break, image, table, table_row, table_header, table_cell or text.string—
attrsheading.level 1–3, code_block.language, ordered_list.order, the lists’ tight, list_item.checked (true, false or null), image.src / alt / title, a cell’s align.Readonly<Record<string, unknown>>—
contentThe child nodes.readonly WrEditorJson[]—
marksThe marks on a text node.readonly WrEditorMarkJson[]—
textA text node’s text.string—
WrEditorMarkJsonOne mark: strong, em, underline, strike, code, or link with { href, title }.interface—
typerequiredThe mark’s name.string—
attrsA link’s href and title.Readonly<Record<string, unknown>>—
WrEditorToolOne entry of the toolbar input. '|' draws a separator between two groups.'bold' | 'italic' | 'underline' | 'strike' | 'code' | 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'link' | 'horizontalRule' | 'undo' | 'redo' | '|'—
WR_EDITOR_TOOLBARThe toolbar drawn when toolbar is not bound — every tool, in five groups. Filter it to build your own.readonly WrEditorTool[]—

CSS variables

Set these on the element itself, with a rule more specific than its Declared on selector. In a global stylesheet, put a class of yours on an ancestor it really has: <body> for an overlay panel, which renders outside your markup. In a component stylesheet the selector alone works, but only for an element in that component's own template. A value on a wrapper or :root is shadowed, because ngwr/editor declares each default on its own element.

VariableDefaultDeclared on
--wr-editor-bgvar(--wr-color-surface).wr-editor +1 variant override
--wr-editor-bordervar(--wr-border-base).wr-editor +1 variant override
--wr-editor-colorvar(--wr-color-on-surface).wr-editor +1 variant override
--wr-editor-content-cell-padding-xvar(--wr-space-md).wr-editor
--wr-editor-content-cell-padding-yvar(--wr-space-sm).wr-editor
--wr-editor-content-code-font-familyvar(--wr-font-family-mono, ui-monospace, monospace).wr-editor
--wr-editor-content-code-radiusvar(--wr-border-radius-sm).wr-editor
--wr-editor-content-h1-font-sizevar(--wr-text-2xl).wr-editor
--wr-editor-content-h2-font-sizevar(--wr-text-xl).wr-editor
--wr-editor-content-h3-font-sizevar(--wr-text-lg).wr-editor
--wr-editor-content-heading-font-weightvar(--wr-font-weight-semibold).wr-editor
--wr-editor-content-heading-line-heightvar(--wr-leading-tight).wr-editor
--wr-editor-content-image-radiusvar(--wr-border-radius-sm).wr-editor
--wr-editor-content-pre-paddingvar(--wr-space-md).wr-editor
--wr-editor-content-pre-radiusvar(--wr-border-radius-base).wr-editor
--wr-editor-content-strong-font-weightvar(--wr-font-weight-semibold).wr-editor
--wr-editor-content-table-font-sizevar(--wr-text-sm).wr-editor
--wr-editor-content-th-font-weightvar(--wr-font-weight-semibold).wr-editor
--wr-editor-font-familyvar(--wr-font-family-base).wr-editor
--wr-editor-font-sizevar(--wr-text-sm).wr-editor
--wr-editor-line-heightvar(--wr-leading-relaxed).wr-editor
--wr-editor-link-actions-gapvar(--wr-space-sm).wr-editor-link
--wr-editor-link-body-gapvar(--wr-space-sm).wr-editor-link
--wr-editor-link-body-paddingvar(--wr-space-md).wr-editor-link
--wr-editor-link-error-font-sizevar(--wr-text-xs).wr-editor-link
--wr-editor-link-label-font-sizevar(--wr-text-sm).wr-editor-link
--wr-editor-link-label-font-weightvar(--wr-font-weight-medium).wr-editor-link
--wr-editor-max-heightnone.wr-editor
--wr-editor-min-height8rem.wr-editor
--wr-editor-padding-xvar(--wr-control-padding-x-md).wr-editor
--wr-editor-padding-yvar(--wr-space-sm).wr-editor
--wr-editor-placeholdervar(--wr-color-placeholder).wr-editor
--wr-editor-radiusvar(--wr-control-radius-md).wr-editor
--wr-editor-ringtransparent.wr-editor +1 variant override
--wr-editor-separator-marginvar(--wr-space-xs).wr-editor
--wr-editor-task-radiusvar(--wr-border-radius-sm).wr-editor
--wr-editor-tool-active-bgvar(--wr-color-primary-soft).wr-editor
--wr-editor-tool-active-colorvar(--wr-color-primary-ink).wr-editor
--wr-editor-tool-mark2px only under .wr-editor__tool--active.wr-btn — unset elsewhere.wr-editor__tool--active.wr-btn
--wr-editor-toolbar-bgvar(--wr-color-fill-subtle).wr-editor
--wr-editor-toolbar-bordervar(--wr-border-base).wr-editor
--wr-editor-toolbar-gapvar(--wr-space-xs).wr-editor
--wr-editor-toolbar-paddingvar(--wr-space-xs).wr-editor

Dependencies

ProseMirrorprosemirror-*(8 packages, optional peers)
Built onButton, Form, Input, Popover

See also