Frameworks

Angular

A directive, so the editor's element stays in the template where it belongs. Everything on this page is ordinary @matrajs/core · there is no Angular-specific package to install.

npm i @matrajs/core

Why there is no @matrajs/angular

Worth saying plainly rather than leaving you to wonder whether Angular is an afterthought. An Angular library has to be published through ng-packagr in Angular's partial compilation format, and that format is tied to the compiler version that produced it. Ship one and it fails in some consumers' AOT builds · a package that breaks production builds is worse than the fifteen lines below, which your compiler handles correctly because it is the one compiling them.

The directive

import { Directive, ElementRef, OnDestroy, OnInit, inject } from '@angular/core'
import { createEditor, starterKit } from '@matrajs/core'

@Directive({ selector: '[matra]', standalone: true, exportAs: 'matra' })
export class MatraDirective implements OnInit, OnDestroy {
  private host = inject(ElementRef<HTMLElement>)
  editor = createEditor({ extensions: starterKit, content: '<p>Hello</p>' })

  ngOnInit() {
    if (!this.editor.unsafe.view) this.editor.mount(this.host.nativeElement)
  }

  ngOnDestroy() {
    this.editor.destroy()
  }
}

Using it

<div matra #ed="matra"></div>

<button (click)="ed.editor.commands.toggleBold()">Bold</button>

exportAs is what makes #ed the directive rather than the element, so a toolbar anywhere in the template can reach ed.editor without a service.

A toolbar that keeps up

The editor is not an Angular signal, so a button's pressed state has to be told when to change. editor.on() returns its own unsubscribe, and the cheapest correct wiring is a signal updated from it.

import { Directive, ElementRef, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { createEditor, starterKit } from '@matrajs/core'

@Directive({ selector: '[matra]', standalone: true, exportAs: 'matra' })
export class MatraDirective implements OnInit, OnDestroy {
  private host = inject(ElementRef<HTMLElement>)
  private off: (() => void)[] = []

  editor = createEditor({ extensions: starterKit, content: '<p>Hello</p>' })
  active = signal<Record<string, boolean>>({})

  ngOnInit() {
    if (!this.editor.unsafe.view) this.editor.mount(this.host.nativeElement)
    const read = () => this.active.set({ bold: this.editor.isActive('bold') })
    this.off.push(this.editor.on('selectionChange', read), this.editor.on('change', read))
    read()
  }

  ngOnDestroy() {
    for (const off of this.off) off()
    this.editor.destroy()
  }
}

Both events, not just one. selectionChange covers moving the caret into bold text; change covers the command that made it bold without moving anything. A toolbar wired to only the first goes stale the moment someone uses a keyboard shortcut.

Zone.js

Nothing here needs NgZone.run. The editor's listeners fire from DOM events Angular already patches, so a signal set inside one is seen. If you are on provideExperimentalZonelessChangeDetection, signals are the only thing that schedules a render anyway, which is why the toolbar above uses one rather than a plain field.

Server rendering

createEditor needs no DOM, so it is safe to construct during SSR · only mount touches the document, and ngOnInit does not run on the server for a directive on an element that was never created. To render stored content without an editor at all, use renderToHTML.

Edit this page on GitHub