Installation
ProseMirror is an OPTIONAL peer: ngwr declares the eight packages and marks them optional, so nothing installs them until you do. No other entry point imports them, so an app that never reaches for ngwr/editor never pays for it.
# ProseMirror is an optional peer: installing ngwr does not bring it along.
pnpm add prosemirror-model prosemirror-state prosemirror-view prosemirror-commands \
prosemirror-keymap prosemirror-history prosemirror-schema-list prosemirror-inputrules
# or
npm install prosemirror-model prosemirror-state prosemirror-view prosemirror-commands \
prosemirror-keymap prosemirror-history prosemirror-schema-list prosemirror-inputrulesimport { WrEditor } from 'ngwr/editor';
@Component({ imports: [WrEditor] })
export class MyComponent {}@use 'ngwr/editor';Basic usage
[(value)] is the document. By default it is an HTML string, and every edit writes a fresh one back — the block under the editor shows exactly what the model holds. Until the first edit that is the string the host wrote; the editor reads it, but never writes a normalised copy back on load, which would mark a pristine form dirty.
<wr-editor ariaLabel="Release notes" placeholder="What changed?" [(value)]="notes" />
<pre>{{ notes() }}</pre>Markdown
format="markdown" reads and writes the dialect ngwr/markdown parses and <wr-markdown> renders, so a document written here displays the same way there. Markdown has no underline, so in this format the underline tool, its shortcut and the mark itself are absent — a pasted <u> cannot show on screen and then vanish on save. The serializer writes one canonical spelling, so the first edit may respell what you bound (* bullets become -) without changing the document.
<wr-editor ariaLabel="Checklist" format="markdown" [(value)]="checklist" />
<pre>{{ checklist() }}</pre>JSON
format="json" hands you the document as a WrEditorJson tree — the shape to store when the content is going to be read by code rather than by a browser. A bound tree is VALIDATED, not repaired: an unknown node or mark, an attribute outside its set, a broken content rule or a link the URL policy refuses, and the whole value is refused. The editor keeps the document it had, warns in dev mode, and stays read-only until it is given a value it can read.
<wr-editor ariaLabel="Agenda" format="json" [(value)]="agenda" />
<pre>{{ agenda() | json }}</pre> A document with nothing written in it is '' in the two string formats and null in json, so a required() rule sees an empty editor as empty — '<p></p>' would be a non-empty string. That holds for everything that only looks written: empty lines, a few spaces, or a list, quote or heading chosen with no text in it. An image or a horizontal line counts as content. Changing format at runtime re-reads value in the new format; if it cannot, the document on screen is kept and edits are written in the new format from then on. An app that stores one format everywhere sets it once:
import { provideWrConfig } from 'ngwr/config';
// Every <wr-editor> that binds no [format] of its own now reads and writes markdown.
providers: [provideWrConfig({ editor: { format: 'markdown' } })];Toolbar
toolbar is the list of tools, in order, with '|' between groups; false removes the toolbar and leaves the shortcuts. Separators are drawn only between two groups, and a tool the format cannot store is dropped. A tool the selection cannot use is disabled rather than hidden, so the toolbar does not reflow under the caret.
<wr-editor ariaLabel="Comment" placeholder="Leave a comment" [toolbar]="tools" [(value)]="comment" />import { WR_EDITOR_TOOLBAR, type WrEditorTool } from 'ngwr/editor';
// Built from the default rather than retyped — this drops the two code tools.
readonly tools: readonly WrEditorTool[] = WR_EDITOR_TOOLBAR.filter(
tool => tool !== 'code' && tool !== 'codeBlock'
);<!-- No toolbar at all: the shortcuts and the block shortcuts still work. -->
<wr-editor ariaLabel="Notes" [toolbar]="false" [(value)]="notes" />Signal Forms
[formField] writes the editor's own value model — no ControlValueAccessor in between — and a plain string field binds to it as it is. Inside a <wr-form-field> the label names the text surface through aria-labelledby (a contenteditable is not labelable, so <label for> cannot reach it), and the hint or error describes it. touch fires once focus leaves the whole control, not when it moves to the editor's own toolbar or link panel — so the error below appears when you leave the field empty, not while you pick a heading.
<wr-form-field label="Description" hint="Markdown. Required.">
<wr-editor format="markdown" placeholder="Describe the change" [formField]="ticket.description" />
</wr-form-field>
<pre>{{ ticket.description().value() | json }}</pre>Read-only and disabled
readonly refuses every edit while the text stays focusable, selectable and announced — the surface keeps its tab stop and reports aria-readonly, and the toolbar goes inert because none of its tools can apply. disabled takes the surface out of the tab order and reports aria-disabled. Under [formField] both are bound from the schema's readonly() and disabled() rules.
<wr-editor ariaLabel="Published notes" readonly [value]="notes" />
<wr-editor ariaLabel="Locked notes" disabled [value]="notes" />Keyboard
Every toolbar command but the horizontal line has a key, on the platform's primary modifier — ⌘ on Apple keyboards, Ctrl elsewhere — and the toolbar names it: in the tooltips, and in each button's aria-keyshortcuts. The block-type chords are Google Docs'; the few Docs does not cover follow Tiptap and Notion.
| Key | Does |
|---|---|
Bold, italic, underline. Underline does nothing in markdown format. | |
| Strikethrough, inline code. | |
| Paragraph, then heading 1 to 3. Pressing an active heading's chord turns it back into a paragraph. | |
| Bulleted list, numbered list — into one, out of it, or over to the other kind. | |
| Quote, code block. | |
Indent or outdent a list item. Not Tab: indenting with it would trap keyboard focus inside the text, so Tab leaves the editor as it leaves any field. On a layout that types a bracket with AltGr or Option — German, French, Nordic, Polish — the chord works with that key held as well. | |
| Open the link panel. The chord stops at the editor, so a command palette bound to the same keys does not open as well. | |
Undo, redo. Ctrl + Y is the Windows redo and is not bound on Apple keyboards. | |
| A line break inside the paragraph; in a code block, a new paragraph after it. | |
| Out of a code block or a table onto a new paragraph after it. The arrows do the same at the edge of a table with nothing after or before it — the last line of its last row, the first line of its first — since there is no other way to put the caret beyond it. | |
#, ##, ###, >, -, 1. then ``` | At the start of a line: a heading, a quote, a bulleted or numbered list, a code block. These are the only input rules — no smart quotes, dashes or ellipses, which would rewrite a name or a code someone typed. |
The toolbar is one tab stop. dir="rtl"),
On Windows, Ctrl + Alt is AltGr, and on the layouts that type characters with it the Mod + Alt chords type those instead — AltGr + 0 is } on a German keyboard and AltGr + C is ć on a Polish one. That is deliberate: taking the chord by physical key would make those characters impossible to type in the editor. The toolbar sets every block type there.
Untrusted input
Every value is treated as untrusted, in all three formats, and so is a paste. HTML is parsed without executing anything — into an inert template, never through innerHTML on a live node — and only what the schema knows is rebuilt from it: no script, style, class, event handler, iframe or unknown tag survives, while the text inside an unknown tag is kept. Links and images go through the same safeMarkdownUrl policy <wr-markdown> uses, so a javascript: or data: address never becomes a live link: in HTML and markdown the link is dropped and its text kept, and in JSON the whole value is refused. A fourth- to sixth-level heading becomes a third-level one.
<wr-editor ariaLabel="Imported content" [(value)]="imported" />
<pre>{{ imported() }}</pre> The block above keeps showing the hostile string you bound, and that is deliberate: the editor draws the cleaned document but writes nothing back until someone edits it, at which point the model holds the cleaned HTML. A value the editor cannot read at all — JSON that breaks the schema, say — is refused outright: the document already on screen stays, a dev-mode warning names the reason, and nothing is written back, the same rule wr-input-number and wr-date-picker follow for input they cannot parse. Until a value it can read arrives, the editor is read-only and carries wr-editor--refused: the model still holds the refused value, and a keystroke would otherwise write the document on screen over it.
On a page that enforces Trusted Types, an HTML value is parsed through the page's default policy when there is one, and otherwise through a policy named ngwr-editor; paste goes through ProseMirror's own ProseMirrorClipboard. A trusted-types allowlist with no default policy names both. Where neither can be created, HTML values are refused as unreadable.
Accessibility
The text surface is a role="textbox" with aria-multiline="true", named by ariaLabel, else the surrounding field's label, else the editor.label catalog entry. aria-placeholder is present only while the document is empty, the moment the placeholder is drawn. The toolbar follows the APG toolbar pattern, with aria-pressed on every toggle and a visible cue besides colour for a pressed tool. The link panel is a labelled dialog whose address field announces a refused URL through aria-invalid and a role="alert" message. A task item's box is presentational, with its state as screen-reader text beside it, as in <wr-markdown>. Every label comes from the editor.* catalog, in all twenty-two languages.
<!-- What the editor exposes around its text, inside a <wr-form-field label="Description">. -->
<div role="toolbar" aria-label="Formatting" aria-controls="wr-editor-0">
<button aria-label="Bold" aria-pressed="false" aria-keyshortcuts="Control+B" tabindex="0">…</button>
<button aria-label="Italic" aria-pressed="false" aria-keyshortcuts="Control+I" tabindex="-1">…</button>
<!-- … -->
<button aria-label="Insert link" aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="Control+K" tabindex="-1">…</button>
</div>
<div
id="wr-editor-0"
role="textbox"
aria-multiline="true"
aria-labelledby="…the field's label…"
aria-describedby="…the field's hint or error…"
aria-placeholder="Describe the change"
contenteditable="true"
>…</div>Bundle cost
The eight ProseMirror packages come to about 67 kB gzipped (221 kB minified) with every export kept, before tree-shaking. They land in the chunk that imports ngwr/editor and nowhere else, so a lazy route already keeps them off every other page; @defer keeps them off the first paint of the page that holds the editor as well.
<!-- ProseMirror lands in the chunk that imports ngwr/editor, and in no other.
@defer moves that chunk off the first load as well. -->
@defer (on viewport) {
<wr-editor format="markdown" [formField]="form.body" />
} @placeholder {
<div class="editor-placeholder" aria-hidden="true"></div>
}The server side needs no deferral. Under SSR and prerendering ProseMirror never starts: the editor renders a static preview of the document from the same sanitised tree, with the same metrics as the live surface so the swap does not move a line, and replaces it with the editable surface once the page is running in a browser.
What it does not do
Tables survive every format and their text is editable, but there is no way to insert a table, a row or a column, and no merging. Images survive too, with no way to insert or upload one. A task item's box shows its state and cannot be toggled. There are no colours, fonts, sizes or alignment, no headings past level three, no mentions, no collaborative editing, and no way to extend the schema or add a tool of your own.
API
| Name | Description | Type | Default |
|---|---|---|---|
value | The document. A string in html and markdown format, a {@link WrEditorJson} tree in json; '' / null when nothing is written. Bound by [formField], or two-way via [(value)]. | WrEditorValue | null |
(touch) | Emitted on blur so a bound field can mark itself touched. | void | — |
disabled | Disable the editor and its toolbar. Bound automatically from the field's disabled state when used with [formField]. | boolean | false |
readonly | Refuse edits while the text stays focusable, selectable and announced. Bound automatically from the field's readonly state when used with [formField]. The surface keeps its tab stop and reports aria-readonly; typing, paste, drop, the shortcuts and every toolbar command are refused. The toolbar goes inert, since none of its tools can apply. | boolean | false |
placeholder | Hint shown while the document is empty, and exposed as aria-placeholder. | string | '' |
ariaLabel | Accessible name of the text surface. Falls back to the surrounding <wr-form-field>'s label, then to editor.label. | string | null | null |
format | What value holds — see {@link WrEditorFormat}. Unset, it falls back to provideWrConfig({ editor: { format } }). Changing it re-reads value in the new format. | WrEditorFormat | null | 'html' |
toolbar | The toolbar's tools, in order, with '|' between groups; false hides it. underline is left out in markdown format, which cannot store it. | readonly WrEditorTool[] | false | WR_EDITOR_TOOLBAR |
Types
The value shapes and the toolbar vocabulary.
| Name | Description | Type | Default |
|---|---|---|---|
WrEditorFormat | What value holds. html — an HTML string, rebuilt from the schema on the way out. markdown — the dialect ngwr/markdown reads, written back by serializeMarkdown; it has no underline. json — the document as a WrEditorJson tree. | 'html' | 'markdown' | 'json' | — |
WrEditorValue | The value model. An empty document is '' in the two string formats and null in json, so required() reports an empty editor as empty. null and '' both read as an empty document in every format. | string | WrEditorJson | null | — |
WrEditorJson | The document as plain data — the shape ProseMirror writes, declared so no ProseMirror type reaches the public API. A bound tree is validated, not trusted: anything the schema does not allow refuses the whole value. | interface | — |
typerequired | doc, 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 | — |
attrs | heading.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>> | — |
content | The child nodes. | readonly WrEditorJson[] | — |
marks | The marks on a text node. | readonly WrEditorMarkJson[] | — |
text | A text node’s text. | string | — |
WrEditorMarkJson | One mark: strong, em, underline, strike, code, or link with { href, title }. | interface | — |
typerequired | The mark’s name. | string | — |
attrs | A link’s href and title. | Readonly<Record<string, unknown>> | — |
WrEditorTool | One entry of the toolbar input. underline is dropped in markdown format, and '|' 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_TOOLBAR | The toolbar drawn when toolbar is not bound — every tool, in five groups. Filter it to build your own. | readonly WrEditorTool[] | — |
CSS variables
Custom properties ngwr/editor publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.
| Variable | Default | Declared on |
|---|---|---|
--wr-editor-bg | var(--wr-color-surface) | .wr-editor +1 variant override |
--wr-editor-border | var(--wr-color-outline) | .wr-editor +1 variant override |
--wr-editor-color | var(--wr-color-on-surface) | .wr-editor +1 variant override |
--wr-editor-font-size | var(--wr-text-sm) | .wr-editor |
--wr-editor-line-height | 1.6 | .wr-editor |
--wr-editor-max-height | none | .wr-editor |
--wr-editor-min-height | 8rem | .wr-editor |
--wr-editor-padding-x | var(--wr-control-padding-x-md) | .wr-editor |
--wr-editor-padding-y | 0.5rem | .wr-editor |
--wr-editor-placeholder | var(--wr-color-placeholder) | .wr-editor |
--wr-editor-radius | var(--wr-control-radius-md) | .wr-editor |
--wr-editor-ring | transparent | .wr-editor +1 variant override |
--wr-editor-tool-active-bg | var(--wr-color-primary-soft) | .wr-editor |
--wr-editor-tool-active-color | var(--wr-color-primary-ink) | .wr-editor |
--wr-editor-tool-mark | 2px only under .wr-editor__tool--active.wr-btn — unset elsewhere | .wr-editor__tool--active.wr-btn |
--wr-editor-toolbar-bg | var(--wr-color-fill-subtle) | .wr-editor |
--wr-editor-toolbar-border | var(--wr-color-outline) | .wr-editor |
--wr-editor-toolbar-gap | 0.125rem | .wr-editor |