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+
Installation
Section titled “Installation”-
Install both packages
Terminal window npm install @notectl/core @notectl/angular@notectl/corecontains the editor engine and plugins.@notectl/angularprovides the Angular bindings. -
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(),],};
Basic Usage
Section titled “Basic Usage”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); }}Component Inputs & Outputs
Section titled “Component Inputs & Outputs”The <ntl-editor> component uses Angular signals for all inputs and outputs:
Inputs
Section titled “Inputs”| 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 |
Outputs
Section titled “Outputs”| 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 |
Two-Way Content Binding
Section titled “Two-Way Content Binding”Use [(content)] to bind the document model in both directions:
<ntl-editor [(content)]="myDocument" [toolbar]="toolbar" />protected readonly myDocument = signal<Document | undefined>(undefined);Reactive Forms
Section titled “Reactive Forms”NotectlEditorComponent implements ControlValueAccessor directly — no additional directive needed for Angular forms.
With FormControl
Section titled “With FormControl”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); }}With ngModel
Section titled “With ngModel”@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()], ];}Content Format
Section titled “Content Format”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.
Signal Forms (Angular 22)
Section titled “Signal Forms (Angular 22)”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.
Theming
Section titled “Theming”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>Editor Service
Section titled “Editor Service”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'); }}Reading & Writing Content
Section titled “Reading & Writing Content”// Read content in different formatsconst json: Document = editor.getJSON();const html: string = await editor.getContentHTML();const text: string = editor.getText();
// Write contenteditor.setJSON(doc);await editor.setContentHTML('<h1>Hello</h1><p>World</p>');
// Check if emptyif (editor.isEmpty()) { console.log('No content');}Programmatic Commands
Section titled “Programmatic Commands”// Toggle formattingeditor.commands.toggleBold();editor.commands.toggleItalic();editor.commands.undo();editor.commands.redo();
// Execute any registered command by nameeditor.executeCommand('toggleStrikethrough');editor.executeCommand('insertHorizontalRule');
// Check command availabilityif (editor.can().toggleBold()) { editor.commands.toggleBold();}Full-Featured Example
Section titled “Full-Featured Example”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 }), ], ];}Default Configuration
Section titled “Default Configuration”Use provideNotectl() to set app-wide defaults. Component-level inputs override these:
provideNotectl({ config: { theme: ThemePreset.Dark, placeholder: 'Write something...', readonly: false, }, contentFormat: 'json',})Testing
Section titled “Testing”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 });});