# Editor

> Rich-text editor on ProseMirror, with a formatting toolbar, a link panel, keyboard shortcuts and markdown-style block shortcuts. The value is an HTML string, a markdown string or the document as JSON, and whatever arrives is treated as untrusted and rebuilt from the editor's own schema. A signal-forms native control; ProseMirror is an optional peer dependency that only an app importing `ngwr/editor` installs.

Source: https://ngwr.dev/reference/components/editor  
Kind: Component, Standalone, Signal Forms, SSR-safe  
Added in v14.9

## 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.

```bash
# 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-inputrules
```

```angular-ts
import { WrEditor } from 'ngwr/editor';

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

```scss
@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.

_Also shown on the page: TS._

```angular-html
<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.

_Also shown on the page: TS._

```angular-html
<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.

_Also shown on the page: TS._

```angular-html
<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:

```typescript
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.

_Also shown on the page: TS._

```angular-html
<wr-editor ariaLabel="Comment" placeholder="Leave a comment" [toolbar]="tools" [(value)]="comment" />
```

```angular-ts
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'
);
```

```angular-html
<!-- 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.

_Also shown on the page: TS._

```angular-html
<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.

```angular-html
<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 |
| --- | --- |
| Mod + B · Mod + I · Mod + U | Bold, italic, underline. Underline does nothing in `markdown` format. |
| Mod + Shift + S · Mod + E | Strikethrough, inline code. |
| Mod + Alt + 0 … Mod + Alt + 3 | Paragraph, then heading 1 to 3. Pressing an active heading's chord turns it back into a paragraph. |
| Mod + Shift + 8 · Mod + Shift + 7 | Bulleted list, numbered list — into one, out of it, or over to the other kind. |
| Mod + Shift + B · Mod + Alt + C | Quote, code block. |
| Mod + ] · Mod + [ | 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. |
| Mod + K | Open the link panel. The chord stops at the editor, so a command palette bound to the same keys does not open as well. |
| Mod + Z · Mod + Shift + Z · Ctrl + Y | Undo, redo. `Ctrl + Y` is the Windows redo and is not bound on Apple keyboards. |
| Shift + Enter | A line break inside the paragraph; in a code block, a new paragraph after it. |
| Mod + Enter · ArrowDown · ArrowUp | 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 Space; ``````````` | 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. Backspace straight after one undoes it. |

The toolbar is one tab stop. Tab reaches it once, the arrow keys move between tools (mirrored under `dir="rtl"`), Home and End jump to either end, and a tool that cannot apply is skipped. After a tool runs from the keyboard, focus stays on it — unless the tool just disabled itself, when the text takes it. Escape is not bound, so an editor inside a dialog still lets the dialog close.

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.

_Also shown on the page: TS._

```angular-html
<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.

```html
<!-- 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.

```angular-html
<!-- 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` | — |
| `type`required | `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` | — |
| `type`required | 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` |
