Skip to content

Angular Integration

The @notectl/angular package provides a native Angular wrapper around the notectl editor. It ships a standalone component, a ControlValueAccessor for reactive and template-driven forms, and an injectable service for programmatic access.

Requirements: Angular 21+, Node.js 20+

  1. Install both packages

    Terminal window
    npm install @notectl/core @notectl/angular

    @notectl/core contains the editor engine and plugins. @notectl/angular provides the Angular bindings.

  2. Register the provider

    Add provideNotectl() to your application providers. This is optional but recommended — it enables app-wide default configuration.

    app.config.ts
    import { type ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
    import { provideNotectl } from '@notectl/angular';
    export const appConfig: ApplicationConfig = {
    providers: [
    provideZonelessChangeDetection(),
    provideNotectl(),
    ],
    };

Import NotectlEditorComponent and configure the plugins you need:

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
NotectlEditorComponent,
TextFormattingPlugin,
HeadingPlugin,
ListPlugin,
LinkPlugin,
ThemePreset,
} from '@notectl/angular';
import type { Plugin, StateChangeEvent } from '@notectl/angular';
@Component({
selector: 'app-editor',
imports: [NotectlEditorComponent],
template: `
<ntl-editor
[toolbar]="toolbar"
[plugins]="plugins"
[theme]="theme()"
[autofocus]="true"
(stateChange)="onStateChange($event)"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditorComponent {
protected readonly theme = signal<ThemePreset>(ThemePreset.Light);
protected readonly toolbar: ReadonlyArray<ReadonlyArray<Plugin>> = [
[new TextFormattingPlugin({ bold: true, italic: true, underline: true })],
[new HeadingPlugin()],
[new ListPlugin()],
[new LinkPlugin()],
];
protected readonly plugins: Plugin[] = [];
protected onStateChange(event: StateChangeEvent): void {
console.log('New state:', event.newState);
}
}

The <ntl-editor> component uses Angular signals for all inputs and outputs:

| Input | Type | Default | Description | |-------|------|---------|-------------| | toolbar | Plugin[][] | — | Toolbar layout (groups of plugins) | | plugins | Plugin[] | [] | Plugins without toolbar buttons | | features | Partial<TextFormattingConfig> | — | Feature toggles for text formatting | | placeholder | string | 'Start typing...' | Placeholder text | | readonlyMode | boolean | false | Readonly mode | | autofocus | boolean | false | Auto-focus on mount | | maxHistoryDepth | number | — | Undo/redo history limit | | theme | ThemePreset \| Theme | ThemePreset.Light | Editor theme | | paperSize | PaperSize | — | Paper layout size (e.g., DINA4, USLetter) | | dir | 'ltr' \| 'rtl' | — | Text direction override | | locale | Locale | — | Locale for plugin i18n | | styleNonce | string | — | CSP nonce for injected styles | | disabled | boolean | false | Disabled state, bound automatically by a Signal Forms field; folds into readonly |

| Output | Payload | Description | |--------|---------|-------------| | stateChange | StateChangeEvent | Fires on every content change | | selectionChange | SelectionChangeEvent | Fires when cursor/selection moves | | editorFocus | void | Editor gained focus | | editorBlur | void | Editor lost focus | | ready | void | Editor fully initialized | | touch | void | Editor blurred; marks a bound Signal Forms field touched |

Use [(content)] to bind the document model in both directions:

<ntl-editor [(content)]="myDocument" [toolbar]="toolbar" />
protected readonly myDocument = signal<Document | undefined>(undefined);

NotectlEditorComponent implements ControlValueAccessor directly — no additional directive needed for Angular forms.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
NotectlEditorComponent,
TextFormattingPlugin,
} from '@notectl/angular';
import type { Document, Plugin } from '@notectl/angular';
@Component({
selector: 'app-editor-form',
imports: [ReactiveFormsModule, NotectlEditorComponent],
template: `
<ntl-editor [formControl]="editorControl" [toolbar]="toolbar" />
<button (click)="save()">Save</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditorFormComponent {
protected readonly editorControl = new FormControl<Document | null>(null);
protected readonly toolbar: ReadonlyArray<ReadonlyArray<Plugin>> = [
[new TextFormattingPlugin({ bold: true, italic: true, underline: true })],
];
protected save(): void {
const value: Document | null = this.editorControl.value;
console.log('Form value:', value);
}
}
@Component({
selector: 'app-editor-ngmodel',
imports: [FormsModule, NotectlEditorComponent],
template: `<ntl-editor [(ngModel)]="content" [toolbar]="toolbar" />`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditorNgModelComponent {
protected content: Document | null = null;
protected readonly toolbar: ReadonlyArray<ReadonlyArray<Plugin>> = [
[new TextFormattingPlugin()],
];
}

By default, forms use JSON (Document objects). You can switch to HTML or plain text via the provider:

provideNotectl({
contentFormat: 'html', // 'json' | 'html' | 'text'
})

| Format | Form Value Type | Description | |--------|----------------|-------------| | 'json' | Document | Structured document model (default) | | 'html' | string | Sanitized HTML | | 'text' | string | Plain text |

All three formats are cursor-stable with reactive forms (including signal forms): when Angular writes the same value back to the editor on every keystroke — as [(ngModel)], formControl, and signal forms all do — the caret stays in place. This is enforced by the round-trip identity contract of the underlying core API.

Angular 22 stabilized Signal Forms. <ntl-editor> is a first-class Signal Forms custom control: it implements the FormValueControl<Document> contract through a value model, so you bind it to a field with [formField]. The classic ControlValueAccessor integration above stays fully supported, so pick whichever forms system your app uses; an individual editor instance uses one binding at a time.

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
import { NotectlEditorComponent } from '@notectl/angular';
import type { Document } from '@notectl/angular';
@Component({
selector: 'app-signal-form-editor',
imports: [NotectlEditorComponent, FormField],
template: `<ntl-editor [formField]="doc" />`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SignalFormEditorComponent {
// The form model is the editor document; `form()` derives a typed FieldTree from it.
protected readonly model = signal<Document>({ children: [] });
protected readonly doc = form(this.model);
}

The binding is two-way and strongly typed: editor edits flow into model(), and writing model.set(...) renders in the editor. The value model and the [(content)] two-way binding are independent views onto editor state, so you can use either, and the optional FormUiControl surface is wired for you: a field's disabled state binds to the disabled input (folding into the editor's readonly state), and blurring the editor marks the field touched via the touch output.

Switch themes at runtime using a signal:

protected readonly theme = signal<ThemePreset>(ThemePreset.Light);
toggleTheme(): void {
const next = this.theme() === ThemePreset.Light
? ThemePreset.Dark
: ThemePreset.Light;
this.theme.set(next);
}
<ntl-editor [theme]="theme()" [toolbar]="toolbar" />
<button (click)="toggleTheme()">Toggle Theme</button>

Use NotectlEditorService when a sibling component (e.g. a custom toolbar) needs to interact with the editor without a template reference:

import {
ChangeDetectionStrategy,
Component,
afterNextRender,
inject,
viewChild,
} from '@angular/core';
import {
NotectlEditorComponent,
NotectlEditorService,
} from '@notectl/angular';
import type { Plugin } from '@notectl/angular';
@Component({
selector: 'app-editor-page',
providers: [NotectlEditorService],
imports: [NotectlEditorComponent, ToolbarComponent],
template: `
<app-toolbar />
<ntl-editor #editor [toolbar]="toolbar" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditorPageComponent {
private readonly editorRef = viewChild.required<NotectlEditorComponent>('editor');
private readonly service = inject(NotectlEditorService);
protected readonly toolbar: ReadonlyArray<ReadonlyArray<Plugin>> = [/* ... */];
constructor() {
afterNextRender(() => {
this.service.register(this.editorRef());
});
}
}

Then in the toolbar component:

@Component({
selector: 'app-toolbar',
template: `
<button (click)="bold()">Bold</button>
<button (click)="undo()">Undo</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToolbarComponent {
private readonly editorService = inject(NotectlEditorService);
protected bold(): void {
this.editorService.executeCommand('toggleBold');
}
protected undo(): void {
this.editorService.executeCommand('undo');
}
}
// Read content in different formats
const json: Document = editor.getJSON();
const html: string = await editor.getContentHTML();
const text: string = editor.getText();
// Write content
editor.setJSON(doc);
await editor.setContentHTML('<h1>Hello</h1><p>World</p>');
// Check if empty
if (editor.isEmpty()) {
console.log('No content');
}

The Angular getContentHTML() accepts the same options as the core editor's getContentHTML(options?). Most importantly, pass { includeBlockIds: false } for clean export HTML with no data-block-id attributes (database storage, server-side validation, handoff to another system):

import type { ContentCSSResult } from '@notectl/angular';
// Clean export HTML, no data-block-id
const clean: string = await editor.getContentHTML({ includeBlockIds: false });
// Pretty-printed
const pretty: string = await editor.getContentHTML({ pretty: true });
// Class-based CSS extraction
const { html, css }: ContentCSSResult = await editor.getContentHTML({ cssMode: 'classes' });

The default keeps data-block-id so the [formControl] / [formField] value sync round-trips preserve block identity and the caret. Use includeBlockIds: false only for the final exported artifact, not for content you intend to load back into the editor.

// Toggle formatting
editor.commands.toggleBold();
editor.commands.toggleItalic();
editor.commands.undo();
editor.commands.redo();
// Execute any registered command by name
editor.executeCommand('toggleStrikethrough');
editor.executeCommand('insertHorizontalRule');
// Check command availability
if (editor.can().toggleBold()) {
editor.commands.toggleBold();
}

A production-ready editor with all plugins:

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
NotectlEditorComponent,
TextFormattingPlugin,
HeadingPlugin,
ListPlugin,
LinkPlugin,
BlockquotePlugin,
CodeBlockPlugin,
TablePlugin,
HorizontalRulePlugin,
StrikethroughPlugin,
HighlightPlugin,
TextColorPlugin,
FontPlugin,
FontSizePlugin,
AlignmentPlugin,
SuperSubPlugin,
STARTER_FONTS,
ThemePreset,
} from '@notectl/angular';
import type { Plugin } from '@notectl/angular';
@Component({
selector: 'app-full-editor',
imports: [NotectlEditorComponent],
template: `
<ntl-editor
[toolbar]="toolbar"
[theme]="theme()"
[autofocus]="true"
placeholder="Start writing..."
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FullEditorComponent {
protected readonly theme = signal<ThemePreset>(ThemePreset.Light);
protected readonly toolbar: ReadonlyArray<ReadonlyArray<Plugin>> = [
[new TextFormattingPlugin({ bold: true, italic: true, underline: true })],
[new HeadingPlugin()],
[new BlockquotePlugin(), new LinkPlugin()],
[new ListPlugin()],
[new TablePlugin()],
[new HorizontalRulePlugin(), new StrikethroughPlugin(), new TextColorPlugin()],
[new HighlightPlugin(), new SuperSubPlugin()],
[new CodeBlockPlugin()],
[
new AlignmentPlugin(),
new FontPlugin({ fonts: [...STARTER_FONTS] }),
new FontSizePlugin({ sizes: [12, 16, 24, 32, 48], defaultSize: 16 }),
],
];
}

Use provideNotectl() to set app-wide defaults. Component-level inputs override these:

provideNotectl({
config: {
theme: ThemePreset.Dark,
placeholder: 'Write something...',
readonly: false,
},
contentFormat: 'json',
})

The @notectl/angular/testing package provides a test harness:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotectlEditorComponent } from '@notectl/angular';
import { NotectlTestHarness } from '@notectl/angular/testing';
describe('MyEditorComponent', () => {
let fixture: ComponentFixture<MyEditorComponent>;
let harness: NotectlTestHarness;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyEditorComponent],
}).compileComponents();
fixture = TestBed.createComponent(MyEditorComponent);
fixture.detectChanges();
// Wrap the editor component in the test harness
const editorComponent = fixture.debugElement
.query(By.directive(NotectlEditorComponent))
.componentInstance;
harness = new NotectlTestHarness(fixture);
await harness.whenReady();
});
it('should render content', () => {
harness.setContentHTML('<p>Hello</p>');
expect(harness.getText()).toBe('Hello');
});
it('should execute commands', () => {
harness.executeCommand('toggleBold');
// assert formatting applied
});
});