Getting Started
Overview
Installation
Theme
Forms
Basic Inputs
Custom Inputs
Select
Components
Components
78
Micro Charts
Navigation
Libraries
Content Editor
Data View
Form Builder
Headless Editor
Image Designer
Kanban Board
PDF Builder
PDF Signer
PDF Viewer
Video Player
GitHub repositoryGitHub starsGitHub forksNgStarter UI npm versionNgStarter UI npm downloads per month

Surface and Input

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.

Loading example…

Inputs

InputDefaultDescription
placeholderWrite something…Text written to the placeholder attribute of the first empty block while the document is empty.
ariaLabelRich text editorAccessible name of the textbox.
disabledfalseMakes the surface non-editable and ignores input. The editor itself stays writable through its API; use editor.setReadOnly() to lock the document.
spellchecktrueNative spellcheck attribute.

Host attributes

The surface element exposes its state as attributes you can style and test against.

AttributeDescription
class="ngs-headless-editor-surface"Stable class hook. No styles are attached to it.
role="textbox", aria-multilineAccessibility role of the editing area.
contenteditablefalse while the surface is disabled or the editor is read-only.
aria-disabledMirrors the effective disabled state.
data-emptyPresent while the document has no content.
data-placeholderThe placeholder text, for host-level placeholder styles.

Rendered content carries attributes that connect the DOM to the model:

AttributeDescription
data-ngs-headless-editor-block-idBlock id on every block element.
data-ngs-headless-editor-block-typeBlock type on every block element.
data-ngs-headless-editor-placeholderPlaceholder text on the first empty text block of an empty document.
data-ngs-headless-editor-markMark type on every mark wrapper element.

Styling

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;
      }
    }
  }
}

Browser input

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 typeOperation
insertTextinsertText() at the selection, with stored marks.
insertReplacementTextReplaces the range reported by the browser (spellcheck and autocorrect).
insertParagraph, insertLineBreaksplitBlock().
deleteContentBackward, deleteContentForwardDeletes 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).
deleteByCutDeletes the selection after the browser copied it.
historyUndo, historyRedoundo(), redo().
formatBold, formatItalic, formatStrikeThroughToggles the corresponding mark.
other format*, list, rule, and link commandsBlocked: the model only contains what plugins define.
composition, drop, and unknown insertionsThe browser edits the DOM; the touched blocks are read back into the model and re-rendered.

Keyboard

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.

Paste

  1. Plugins with handlePaste run in registration order; the first that returns true wins.
  2. Otherwise the plain-text clipboard content is inserted. Line breaks create new paragraphs.

HTML from the clipboard is never inserted as-is.

IME composition

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

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.

Methods

Get the directive with viewChild(NgsHeadlessEditorSurface) or a template reference with #surface="ngsHeadlessEditorSurface".

MethodDescription
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);
    });
  });
}

Server-side rendering

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.