

ngsHeadlessEditorSurface connects an editor to a contenteditable element. It renders the document, maps native selection to model points, and turns browser input into editor operations. You own everything around it: container, toolbars, menus, and styles.
| Input | Default | Description |
|---|---|---|
placeholder | Write something… | Text written to the placeholder attribute of the first empty block while the document is empty. |
ariaLabel | Rich text editor | Accessible name of the textbox. |
disabled | false | Makes the surface non-editable and ignores input. The editor itself stays writable through its API; use editor.setReadOnly() to lock the document. |
spellcheck | true | Native spellcheck attribute. |
The surface element exposes its state as attributes you can style and test against.
| Attribute | Description |
|---|---|
class="ngs-headless-editor-surface" | Stable class hook. No styles are attached to it. |
role="textbox", aria-multiline | Accessibility role of the editing area. |
contenteditable | false while the surface is disabled or the editor is read-only. |
aria-disabled | Mirrors the effective disabled state. |
data-empty | Present while the document has no content. |
data-placeholder | The placeholder text, for host-level placeholder styles. |
Rendered content carries attributes that connect the DOM to the model:
| Attribute | Description |
|---|---|
data-ngs-headless-editor-block-id | Block id on every block element. |
data-ngs-headless-editor-block-type | Block type on every block element. |
data-ngs-headless-editor-placeholder | Placeholder text on the first empty text block of an empty document. |
data-ngs-headless-editor-mark | Mark type on every mark wrapper element. |
Blocks and marks are created by the directive, outside your template, so emulated component styles do not reach them. Scope styles by the surface class and use ::ng-deep, put them in a global stylesheet, or use ViewEncapsulation.None on the host component.
.surface {
display: block;
min-height: 8rem;
padding: 0.75rem 1rem;
outline: none;
&[data-empty] { background: var(--ngs-color-surface-container-lowest); }
&[aria-disabled='true'] { color: var(--ngs-color-on-surface-variant); }
::ng-deep {
> * { margin: 0 0 0.5rem; }
strong { font-weight: 700; }
code { font-family: ui-monospace, monospace; }
[data-ngs-headless-editor-placeholder] {
position: relative;
&::before {
content: attr(data-ngs-headless-editor-placeholder);
position: absolute;
inset: 0 auto auto 0;
color: var(--ngs-color-neutral-500);
pointer-events: none;
}
}
}
} The surface handles beforeinput and applies the matching editor operation instead of letting the browser edit the DOM. The resulting change is rendered from the model.
| Input type | Operation |
|---|---|
insertText | insertText() at the selection, with stored marks. |
insertReplacementText | Replaces the range reported by the browser (spellcheck and autocorrect). |
insertParagraph, insertLineBreak | splitBlock(). |
deleteContentBackward, deleteContentForward | Deletes one user-perceived character or merges blocks at a boundary. |
deleteWord*, deleteSoftLine*, deleteHardLine* | Deletes the target range reported by the browser (for example Ctrl+Backspace or Cmd+Backspace). |
deleteByCut | Deletes the selection after the browser copied it. |
historyUndo, historyRedo | undo(), redo(). |
formatBold, formatItalic, formatStrikeThrough | Toggles the corresponding mark. |
other format*, list, rule, and link commands | Blocked: the model only contains what plugins define. |
| composition, drop, and unknown insertions | The browser edits the DOM; the touched blocks are read back into the model and re-rendered. |
On keydown the surface asks the editor to resolve plugin key bindings. When no binding matches, Mod-z undoes and Mod-Shift-z or Mod-y redoes, because the browser has no native undo stack for model-driven edits. Mod is Ctrl or Cmd. See key bindings.
handlePaste run in registration order; the first that returns true wins.HTML from the clipboard is never inserted as-is.
While an input method composes text (Chinese, Japanese, Korean, and others), the browser owns the DOM and the surface does not re-render. On compositionend the touched blocks are read back into the model as one change.
Rendering is keyed by block id. Blocks whose object did not change keep their DOM elements, so typing in one paragraph does not touch the others, and spellcheck marks and component blocks survive edits elsewhere. Browser-made DOM changes are tracked with a MutationObserver, read back only for the touched blocks, and replaced by the canonical rendering, which also removes markup the browser may have inserted.
Do not change the surface DOM yourself. Change the document through the editor; the DOM follows.
Get the directive with viewChild(NgsHeadlessEditorSurface) or a template reference with #surface="ngsHeadlessEditorSurface".
| Method | Description |
|---|---|
focus() | Focuses the surface and applies the model selection to the DOM. Call it after changing the selection programmatically. |
getSelectionRect() | Bounding rectangle of a non-collapsed selection inside the surface, or null. Use it to position bubble menus. |
getBlockElement(blockId) | The element currently rendering a block, for overlays such as drag handles or comments. |
readonly surface = viewChild.required(NgsHeadlessEditorSurface);
readonly position = signal<{ top: number; left: number } | null>(null);
constructor() {
effect(() => {
this.editor.selection();
this.editor.revision();
// Measure after the surface has rendered the change.
requestAnimationFrame(() => {
const rect = this.surface().getSelectionRect();
this.position.set(rect ? { top: rect.top, left: rect.left + rect.width / 2 } : null);
});
});
} The surface starts rendering and listening to the document in afterNextRender(), so it does nothing on the server. Wrap heavy editors in @defer if they are below the fold.