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

Block format & import

Export structured content from another system directly into Content Editor’s native version-1 JSON document. This reference describes every built-in block, its saved fields and its HTML import mapping.

Document envelope and common fields

type Document = { version: 1; blocks: Block[] };
type Block<T = unknown> = { id: string; type: string; content: T;
  attrs?: { settings?: Record<string, unknown>; props?: Property[];
    [key: string]: unknown } };
type Property = { name: string; value: string };
type TextRun = { type: 'text'; text: string; marks: Mark[] };
type Mark = { type: string;
  attrs?: Record<string, string | number | boolean | null> };
  • version must be 1. blocks is ordered and may be empty. Only these two document envelope fields are part of the format.
  • Every block requires a non-empty id, type and content. Block IDs are globally unique across the root, toggles and grid descendants.
  • attrs.settings stores block settings; attrs.props stores properties. Alignment uses {name: 'text-alignment', value: 'left' | 'center' | 'right' | 'justify'}.
  • All text fields use text runs with an explicit marks array. Empty text is an empty array or a single empty run. Newlines are literal text; HTML is never stored inside text runs.
  • JSON import retains extra JSON fields inside block content/attrs and preserves valid IDs. Duplicate IDs, malformed fields, unsafe resource URLs, cycles and non-JSON values produce a structured error.
  • Do not serialize component instances, signals, runtime upload callbacks, isEmpty or builder view/cell objects. The builder may normalize empty root documents, text runs and table rows for editing.

Text marks

bold / italic / strike / underline / code / superscript / subscript

No attrs required.

HTML: strong/b, em/i, s/strike, u, code, sup, sub

link

attrs.href: non-empty URL; attrs.target?: _blank | _self.

HTML: a[href]; unsafe links keep their visible text without a link mark.

textColor / backgroundColor

attrs.color: supported CSS color string.

HTML: span style color/background-color; both can be applied together.

Custom mark

Any non-empty type and scalar attrs; register matching Headless Editor mark definitions for UI rendering.

HTML: Use config.importMarks with type/tagName/parseTags/readAttributes; export customization uses config.marks.

Color marks accept supported CSS color strings, including hexadecimal, named colors, rgb/hsl expressions and safe CSS variable references. Links allow web, email, telephone and relative URLs. Media allows appropriate web/relative/data/blob resources; iframe embeds allow web/relative URLs only. The importer performs these semantic checks in addition to JSON Schema validation.

All built-in blocks

Paragraph paragraph

content: TextRun[]. attrs.props?: Property[].

Text runs; use an empty array or an empty text run for an empty paragraph. Alignment is a text-alignment property, not a setting.

HTML import: p and unwrapped inline text.

{
  "id": "paragraph",
  "type": "paragraph",
  "content": [
    {
      "type": "text",
      "text": "Hello ",
      "marks": [
        {
          "type": "bold"
        }
      ]
    },
    {
      "type": "text",
      "text": "world",
      "marks": [
        {
          "type": "link",
          "attrs": {
            "href": "https://example.com",
            "target": "_blank"
          }
        }
      ]
    }
  ],
  "attrs": {
    "props": [
      {
        "name": "text-alignment",
        "value": "left"
      }
    ]
  }
}

Heading heading

content: TextRun[]. attrs.settings.level?: 1 | 2 | 3 | 4 | 5 | 6.

Level defaults to 2 when omitted. The native type is heading; the level belongs to attrs.settings.

HTML import: h1–h6 preserve the heading level.

{
  "id": "heading",
  "type": "heading",
  "content": [
    {
      "type": "text",
      "text": "Section",
      "marks": []
    }
  ],
  "attrs": {
    "settings": {
      "level": 2
    }
  }
}

Code code

content: TextRun[]. attrs.settings.language?: string.

language defaults to none. Text is literal source code, including newline characters. HTML export escapes it and ignores text marks.

HTML import: pre > code; data-language or language-* class supplies the language.

{
  "id": "code",
  "type": "code",
  "content": [
    {
      "type": "text",
      "text": "const answer = 42;\nconsole.log(answer);",
      "marks": []
    }
  ],
  "attrs": {
    "settings": {
      "language": "javascript"
    }
  }
}

Divider divider

content: null.

No content fields or block-specific settings.

HTML import: hr.

{
  "id": "divider",
  "type": "divider",
  "content": null
}

Bullet list bulletList

content: ListItem[]. ListItem = { content: TextRun[], children: ListItem[], props?: Property[] }. attrs.settings.listStyle?: "bullet" | "ordered".

children is required, including [] for leaf items. Each nested item can have its own alignment. Item IDs are not used. The block type determines the exported list tag.

HTML import: ul > li; nested lists become children. Mixed nested list styles use the parent block style.

{
  "id": "bullet-list",
  "type": "bulletList",
  "content": [
    {
      "content": [
        {
          "type": "text",
          "text": "Parent",
          "marks": []
        }
      ],
      "children": [
        {
          "content": [
            {
              "type": "text",
              "text": "Child",
              "marks": []
            }
          ],
          "children": []
        }
      ]
    }
  ],
  "attrs": {
    "settings": {
      "listStyle": "bullet"
    }
  }
}

Ordered list orderedList

content: ListItem[]. attrs.settings.listStyle?: "bullet" | "ordered".

Uses the same recursive item shape as bulletList. The editor creates listStyle: ordered. Numbering/start counters are not part of this format.

HTML import: ol > li, including nested items.

{
  "id": "ordered-list",
  "type": "orderedList",
  "content": [
    {
      "content": [
        {
          "type": "text",
          "text": "First item",
          "marks": []
        }
      ],
      "children": []
    }
  ],
  "attrs": {
    "settings": {
      "listStyle": "ordered"
    }
  }
}

Quote quote

content: { cite: { content: TextRun[], props?: Property[] }, caption: { content: TextRun[], props?: Property[] } }.

Both cite and caption are required. Use caption.content: [] when no attribution is present. Alignment can differ between quote and caption.

HTML import: blockquote, or figure > blockquote plus figcaption.

{
  "id": "quote",
  "type": "quote",
  "content": {
    "cite": {
      "content": [
        {
          "type": "text",
          "text": "A quotation",
          "marks": []
        }
      ],
      "props": []
    },
    "caption": {
      "content": [
        {
          "type": "text",
          "text": "Author",
          "marks": []
        }
      ],
      "props": []
    }
  }
}

Table table

content: null. attrs.rows: TextRun[][][]. attrs.header?: boolean. attrs.cellMetadata?: CellMetadata[][]. CellMetadata = { props?: Property[], options?: { colspan?: positive integer, rowspan?: positive integer, width?: positive number | "auto" | null }, styles?: object }.

Index rows[row][column] to get the text runs of one cell; metadata uses the same row/column indexes. header defaults to false. Spans default to 1. At most 500 rows and 50 columns; keep a consistent logical row width. Headless Editor pads ragged/empty rows on rendering. styles and other JSON metadata are retained, but only alignment/spans/width are used by built-in export. Never put builder UI cell objects in content.

HTML import: table rows, th/td, colspan/rowspan/width and text-align. A th in the first row enables the header.

{
  "id": "table",
  "type": "table",
  "content": null,
  "attrs": {
    "rows": [
      [
        [
          {
            "type": "text",
            "text": "Name",
            "marks": []
          }
        ],
        [
          {
            "type": "text",
            "text": "Value",
            "marks": []
          }
        ]
      ],
      [
        [
          {
            "type": "text",
            "text": "Answer",
            "marks": []
          }
        ],
        [
          {
            "type": "text",
            "text": "42",
            "marks": []
          }
        ]
      ]
    ],
    "header": true,
    "cellMetadata": [
      [
        {
          "props": [],
          "options": {
            "colspan": 1,
            "rowspan": 1,
            "width": 180
          }
        },
        {
          "props": [],
          "options": {
            "colspan": 1,
            "rowspan": 1
          }
        }
      ],
      [
        {
          "props": [],
          "options": {
            "colspan": 1,
            "rowspan": 1
          }
        },
        {
          "props": [],
          "options": {
            "colspan": 1,
            "rowspan": 1
          }
        }
      ]
    ]
  }
}

Image image

content: { src: string, alt: string }. attrs.settings?: { width?, height?, actualWidth?, actualHeight? }.

Dimensions are positive pixel numbers, auto or null; actualWidth/actualHeight are optional source metadata. src and alt may be empty for an unfilled block. The built-in renderer also displays alt as its caption; there is no separate image caption field.

HTML import: img, optionally inside figure. src, alt, width and height are imported; arbitrary figure caption text is not a separate image field.

{
  "id": "image",
  "type": "image",
  "content": {
    "src": "/assets/chairs.jpg",
    "alt": "A meeting room"
  },
  "attrs": {
    "settings": {
      "width": 640,
      "height": "auto"
    }
  }
}

Video video

content: { src: string, caption: string, orientation?: "portrait" | "landscape" }. attrs.settings?: { width?, height?, actualWidth?, actualHeight? }.

caption is a plain string, not text runs. src/caption may be empty. Dimensions use the same types as image.

HTML import: video (or its source child), optionally inside figure with figcaption. Orientation defaults to landscape because ordinary HTML has no orientation metadata.

{
  "id": "video",
  "type": "video",
  "content": {
    "src": "https://example.com/movie.mp4",
    "caption": "Product demo",
    "orientation": "landscape"
  },
  "attrs": {
    "settings": {
      "width": 640
    }
  }
}

Embed embed

content: { url: string, type: string }. attrs.settings?: { width?, height? }.

Save the embed URL, not the share-page URL. Built-in providers include pdf, youtube, vimeo, spotify, google-maps, google-docs, google-drive, codepen, figma, loom, twitter, instagram, tiktok, soundcloud, stackblitz, gist and website. The editor resolves provider URLs when entered. width/height accept positive numbers, auto or null; HTML export uses 700 × 400 if omitted.

HTML import: iframe src/width/height. HTML title becomes type; provider metadata cannot be reliably recovered from arbitrary markup. A custom fromHtml handler can resolve provider-specific types.

{
  "id": "embed",
  "type": "embed",
  "content": {
    "url": "https://www.youtube.com/embed/example",
    "type": "youtube"
  },
  "attrs": {
    "settings": {
      "width": 700,
      "height": 400
    }
  }
}

Callout callout

content: TextRun[]. attrs.settings.variant?: "note" | "tip" | "warning" | "danger".

Variant defaults to note. Block alignment uses attrs.props.

HTML import: aside[data-callout] or .ngs-content-editor-html-callout.

{
  "id": "callout",
  "type": "callout",
  "content": [
    {
      "type": "text",
      "text": "Remember this",
      "marks": []
    }
  ],
  "attrs": {
    "settings": {
      "variant": "tip"
    }
  }
}

Toggle toggle

content: { title: TextRun[], blocks: Block[] }. attrs.settings.expanded?: boolean.

title and blocks are required; nested blocks follow the exact same block contract. Missing expanded means closed in the reader. New editor blocks are created expanded.

HTML import: details > summary and nested content; open sets expanded.

{
  "id": "toggle",
  "type": "toggle",
  "content": {
    "title": [
      {
        "type": "text",
        "text": "Details",
        "marks": []
      }
    ],
    "blocks": [
      {
        "id": "toggle-paragraph",
        "type": "paragraph",
        "content": [
          {
            "type": "text",
            "text": "Nested content",
            "marks": []
          }
        ]
      }
    ]
  },
  "attrs": {
    "settings": {
      "expanded": true
    }
  }
}

Attachment attachment

content: { url: string, name: string, size: non-negative integer, mimeType: string }.

size is the file size in bytes, not a formatted label. URL/name/MIME may be empty for an unfilled block. Upload callbacks are runtime configuration and never part of saved JSON.

HTML import: a[download]. Name comes from download or link text. HTML does not contain reliable file size/MIME metadata, so size is 0 and mimeType is an empty string.

{
  "id": "attachment",
  "type": "attachment",
  "content": {
    "url": "https://example.com/guide.pdf",
    "name": "guide.pdf",
    "size": 2048,
    "mimeType": "application/pdf"
  }
}

Grid grid

content: { cells: { id: string, blocks: Block[] }[] }. attrs.settings?: { columns?: 2 | 3 | 4, gap?: "small" | "medium" | "large", stackOnMobile?: boolean }.

Defaults: columns 2, gap medium (24 px), stackOnMobile true. Small/large gaps are 12/36 px. Cells have IDs unique inside their grid; all nested block IDs remain globally unique. Order is the cells array, row-major. Empty cells/collections are valid. The editor may show a normal empty paragraph for editing. There is no columns block or migration format.

HTML import: .ngs-content-editor-html-grid > .ngs-content-editor-html-grid-cells > .ngs-content-editor-html-grid-cell. CSS variables --ngs-html-grid-columns/--ngs-html-grid-gap and the -stack class recover exported layout settings.

{
  "id": "grid",
  "type": "grid",
  "content": {
    "cells": [
      {
        "id": "cell-one",
        "blocks": [
          {
            "id": "grid-heading",
            "type": "heading",
            "content": [
              {
                "type": "text",
                "text": "Overview",
                "marks": []
              }
            ],
            "attrs": {
              "settings": {
                "level": 3
              }
            }
          }
        ]
      },
      {
        "id": "cell-two",
        "blocks": [
          {
            "id": "grid-paragraph",
            "type": "paragraph",
            "content": [
              {
                "type": "text",
                "text": "Grid content",
                "marks": []
              }
            ]
          }
        ]
      }
    ]
  },
  "attrs": {
    "settings": {
      "columns": 2,
      "gap": "medium",
      "stackOnMobile": true
    }
  }
}

Import API

import { ContentEditorImporter, contentEditorFromJson, contentEditorFromHtml,
  ContentEditorImportError } from '@ngstarter-ui/components/content-editor';

// Pure functions: only the explicitly supplied configuration is used.
const fromJson = contentEditorFromJson(jsonStringOrObject);
const fromHtml = contentEditorFromHtml('<h2>Hello</h2><p><strong>World</strong></p>');

// Injected service: environment defaults are inherited.
readonly importer = inject(ContentEditorImporter);
const parsed = this.importer.fromJson(source);

// Builder imports are one undoable edit; replacement is the default.
builder.importJson(jsonStringOrObject);
builder.importHtml(html);
const target = builder.getInsertionTarget();
builder.importHtml(html, {}, { mode: 'insert', target });
builder.convertBlock('paragraph-id', 'callout');
const nativeDocument = builder.getData();
const htmlExport = builder.toHtml();

try { builder.importJson(source); }
catch (error) {
  if (error instanceof ContentEditorImportError) {
    console.log(error.path, error.message);
  }
}
  • contentEditorFromJson(source, config?, options?) accepts JSON text or a native object, validates the whole nested document, and returns a detached ContentEditorDocument.
  • contentEditorFromHtml(html, config?, options?) imports HTML text. Browser DOM is required; on the server supply a DOM document through options.document. It must support implementation.createHTMLDocument().
  • ContentEditorImporter.fromJson/fromHtml also reads the environment configuration and injected DOCUMENT. Standalone functions use only the explicitly supplied config.
  • builder.importJson/importHtml use the same source/config/options arguments. They replace the initialized builder’s document by default, or insert with mode: "insert" and a target. Both are one undoable edit, clear stale block selection and emit contentChanged. They return the live document after normalization. Failed import does not alter content, history, selection or drafts.
  • Options default to maxDepth: 64, maxBlocks: 10000, allowUnknownBlocks: false. Limits must be positive integers and include descendants. The JSON Schema describes built-ins; use explicit custom config or allowUnknownBlocks to transport custom types.
  • Errors expose ContentEditorImportError.path and message, pointing at the first invalid field, for example $.blocks[2].content.cells[0].blocks[1].id.

HTML fidelity

Semantic blocks, supported marks, alignment, lists, table metadata, callouts, details and exported gallery/grid structures are converted into native blocks. Scripts, style sheets, executable resources, event handlers and arbitrary HTML/CSS are not stored. Unsafe links keep their visible text. Unsupported wrappers keep supported text/descendants; unsupported or unsafe media is omitted.

HTML cannot recover original IDs, upload metadata, custom attrs, video orientation, or attachment size/MIME. It generates fresh IDs and defaults. Mixed nested list styles map to the parent list type. Use native JSON for lossless transfer. No legacy-format migration is performed.

Design and theme tokens

Editing and preview share body and heading typography. Placeholder, selection, field borders and floating toolbar use semantic theme roles. Use --ngs-content-editor-* tokens for scoped customization; detached toolbar/menu overlays inherit those values and follow theme changes.

import { provideNgsTheme } from '@ngstarter-ui/components/core';

export const appConfig = {
  providers: [provideNgsTheme({ theme: 'default', colorScheme: 'auto' })]
};
// Component/scope SCSS:
.article-editor {
  --ngs-content-editor-content-width: 760px;
  --ngs-content-editor-font-size: var(--ngs-font-size-md);
  --ngs-content-editor-line-height: 1.6;
  --ngs-content-editor-heading-weight: 600;
  --ngs-content-editor-block-radius: var(--ngs-radius-md);
}
// Floating toolbar and menus inherit editor tokens, including scoped overrides.
// Shared surface/on-surface roles also follow Default/Chalk light/dark themes.

Unified custom blocks

Register an editor, preview renderer, initial native content, menu item, validation and HTML import/export in a single block entry. Use loadEditor instead of editor for lazy components. Nested Grid/Toggle previews inherit the instance configuration. The editor demo includes a Bookmark block using this API.

import { ContentEditorBlockBase, ContentEditorRendererBase,
  ContentEditorConfig, provideContentEditorConfig } from '@ngstarter-ui/components/content-editor';

const config: ContentEditorConfig = { blocks: { product: {
  editor: ProductEditor, renderer: ProductPreview,
  create: () => ({ content: { productId: '' } }),
  menu: { title: 'Product', description: 'Application-defined product card' },
  validateImport: block => typeof (block.content as any)?.productId === 'string'
    ? undefined : 'productId must be a string',
  fromHtml: { selector: '[data-product-id]', parse: (element, ctx) =>
    ctx.createBlock('product', { productId: element.getAttribute('data-product-id') }) },
  toHtml: (block, ctx) => '<div data-product-id="' + ctx.escape((block.content as any).productId) + '"></div>'
} } };
// Global: providers: [provideContentEditorConfig(config)]
// Instance: <ngs-content-editor-builder [config]="config" ... />
// Preview: <ngs-content-editor-renderer [config]="config" ... />
// Extend ContentEditorBlockBase and call save(nextContent) to use root history.
// Extend ContentEditorRendererBase for the standard preview inputs.

Drafts and media uploads

Drafts save after a delay, validate before restoration and expose saving/error status. Set a stable, distinct documentId for each document. Media shares cancellation, progress, visible errors and retry; carousel retry sends only failed files.

<ngs-content-editor-builder #editor="ngsContentEditorBuilder"
  documentId="article-42" [content]="document" [persistDraft]="true" [draftDebounceMs]="500" />
// editor.draftStatus(): disabled | idle | saving | saved | error
// editor.draftError(), editor.draftSavedAt(), editor.saveDraftNow(), editor.clearDraft()
// Config defaults: draft: { debounceMs: 500, storage: yourSynchronousStorageAdapter }
// uploadFn(file, dataUrl, { signal, onProgress }) -> Promise<string>
// Pass signal to fetch/XHR; report transport progress from 0 to 100 when supported.
// blocks.image.options: { uploadFn, maxFileSize: 10 * 1024 * 1024 }

Custom import configuration

Use provideContentEditorConfig in application or route environment providers. Child environments inherit parent defaults. Import priority is built-ins, environment config, builder instance config, then per-call config.

provideContentEditorConfig({
  blocks: {
    heading: {
      fromHtml: {
        selector: 'h1, h2, h3, h4, h5, h6',
        parse: (element, ctx) => ctx.defaultFromHtml(element)
      }
    },
    product: {
      fromHtml: {
        selector: '[data-product-id]',
        parse: (element, ctx) => ctx.createBlock('product', {
          productId: element.getAttribute('data-product-id')
        })
      },
      validateImport: block => typeof (block.content as { productId?: unknown })?.productId === 'string'
        ? undefined : 'productId must be a string',
      toHtml: (block, ctx) => `<div data-product-id="${ctx.escape((block.content as { productId: string }).productId)}"></div>`
    }
  }
});

A block’s fromHtml has a CSS selector and parse(element, ctx). Overrides run before built-ins and may return one block, multiple blocks, or null. Context provides createBlock, createId, parseText, parseBlocks, safeUrl and defaultFromHtml. The latter calls the built-in parser without recursively invoking your override; nested parseBlocks calls keep configured handlers.

validateImport(block) returns undefined to accept or a message to reject the block. Built-in validation still runs first. Configure importMarks with extra Headless Editor mark definitions for HTML parsing; HTML export mark converters use config.marks. Custom callbacks are application-owned.

Import configuration alone does not create an editable block component. Custom types/marks need matching application renderer components or Headless Editor plugins. Refer to the renderer API for custom read-only block rendering.

Third-party exporter checklist

  1. Emit UTF-8 JSON with version: 1 and globally unique block IDs.
  2. Use text runs with explicit marks, nested list children, toggle blocks and ordered grid cells.
  3. Put table text in attrs.rows, metadata in attrs.cellMetadata, and settings/properties inside attrs.
  4. Include required media fields and byte counts/MIME; use persistent URLs for production assets. Runtime uploadFn callbacks are not saved data.
  5. Validate against the downloadable schema and call contentEditorFromJson for semantic checks before storing/importing.
  6. Use JSON for exact round trips and HTML for interoperable display/conversion.