rtf-editor
v22.0.1
Published
This is an Angular Module containing Components/Services using Material
Readme
rtf-editor
A markdown-based rich text editor for Angular: a plain-textarea editor
(RtfEditorComponent), a formatting toolbar (RtfEditorToolbarComponent),
and a markdown-to-HTML preview renderer (RtfEditorPreviewComponent) that
supports mounting live components — including externally-loaded custom
elements — at named positions inside the rendered markdown.
Installation
npm install rtf-editorPeer dependencies (must already be installed in the consuming app, same major version):
npm install @angular/core@^22 @angular/common@^22 @angular/material@^22 \
@angular/platform-browser@^22 ngx-markdown@^22ngx-markdown is a peer dependency, not a bundled one, and must be
installed by the consuming app. This is not incidental: RtfEditorModule calls
MarkdownModule.forChild(), which only works when the app has called
MarkdownModule.forRoot() on the same ngx-markdown instance. Shipping it as
an ordinary dependency allowed a second nested copy to be installed, whose
module classes and DI tokens are distinct from the app's — producing a
NullInjectorError or silently unconfigured rendering. Installing it yourself
is what guarantees there is only one.
Note that ngx-markdown carries its own peer dependencies (marked, and
optionally clipboard, emoji-toolkit, katex, mermaid, prismjs); your
package manager will report any that your app needs and is missing.
marked is not required. rtf-editor imports only its types
(import type { Tokens }), which are erased at compile time — the built bundle
contains no marked import. If a future change needs marked at runtime, it
must be declared as a peer alongside ngx-markdown, not as a dependency.
@angular/cdk is not required either — rtf-editor imports nothing from it.
(@angular/material peers an exact @angular/cdk version of its own, so one
will be present regardless.)
Import the module once, wherever you use any of these components:
import { RtfEditorModule } from 'rtf-editor';
@NgModule({
imports: [RtfEditorModule],
// ...
})
export class YourFeatureModule {}RtfEditorModule declares and exports all four components below. It's an
NgModule (not standalone) — every consuming app must be module-based, or
import it into a module that a standalone app still bootstraps for this
feature.
Components
RtfEditorComponent — <app-rtf-editor>
The editable surface (a styled <textarea>).
| Input | Type | Default | Description |
|---|---|---|---|
| content | string | a short built-in sample | Initial/controlled markdown source. |
| editable | boolean | true | Set false for a read-only surface. |
| height | string \| number | 'auto' | CSS height. |
| minHeight | string \| number | — | CSS min-height. |
| placeholder | string | — | Placeholder text when empty. |
| autofocus | boolean | false | Focus on init. |
| spellcheck | boolean | true | Native spellcheck attribute. |
| maxLength | number | — | Enforced live — lowering it at runtime truncates existing content that now exceeds it, same as a native maxlength. |
| ariaLabel | string | 'Markdown editor' | Accessible name for the surface. Defaulted so the textarea is never unnamed; override it with something specific to your form. Ignored when ariaLabelledby is set. |
| ariaLabelledby | string | — | Id of your own visible label element. Prefer this over ariaLabel when a visible label exists — it takes precedence, so the two cannot disagree. |
| ariaDescribedby | string | — | Id(s) of your own hint or error text, announced after the name. |
| inputId | string | — | Sets the textarea's id, so your own <label for="…"> can target it. |
| Output | Payload | Fires |
|---|---|---|
| contentChange | string | On every edit. |
| ready | HTMLTextAreaElement | Once, when the native element is available (e.g. to attach RtfEditorToolbarComponent or seed sample content programmatically). |
| focus | void | On focus. |
| blur | void | On blur. |
RtfEditorToolbarComponent — <app-rtf-editor-toolbar>
Formatting buttons (bold, headings, lists, etc.) that act directly on a textarea element you pass in.
| Input | Type | Default | Description |
|---|---|---|---|
| textarea | HTMLTextAreaElement | — | Typically RtfEditorComponent's (ready) payload. |
| formatters | Partial<Record<RtfEditorToolbarAction, RtfEditorToolbarActionFn>> | {} | Override how a specific action mutates the textarea. |
| hiddenActions | RtfEditorToolbarAction[] | [] | Hide specific buttons. |
RtfEditorPreviewComponent — <app-rtf-editor-preview>
Renders markdown to HTML and mounts live components/elements at
slot:<name> positions (see Slots below).
| Input | Type | Default | Description |
|---|---|---|---|
| content | string | '' | Markdown source to render. |
| tagClasses | Partial<Record<RtfEditorPreviewTag, string>> | {} | Override the CSS class this renderer applies to any element tag (headings, lists, code blocks, etc.), for theming without touching this library's own styles. |
| slots | Record<string, Type<unknown> \| (() => HTMLElement \| Promise<HTMLElement>)> | {} | Maps a slot name to what gets mounted at that position. See Slots. |
RtfEditorDemoComponent — <app-rtf-editor-demo> (reference/demo only)
A working example wiring RtfEditorComponent + RtfEditorToolbarComponent +
two side-by-side RtfEditorPreviewComponents together, including a sample
slot of each kind described below. Not meant to be used as-is in a real app —
read its source (projects/rtf-editor/src/lib/rtf-editor-demo/) as the
canonical worked example for everything in this README.
| Input | Type | Default | Description |
|---|---|---|---|
| extraSlots | Record<string, Type<unknown> \| (() => HTMLElement \| Promise<HTMLElement>)> | {} | Merged on top of the demo's own built-in slots. Exists so a consuming app can register slots needing dependencies this library never takes on itself (e.g. Native Federation) — see External components below for why this matters. |
Slots
A markdown slot:<name> tag — either a fenced block on its own line:
```slot:myComponent
```or written entirely on one line:
```slot:myComponent```— gets replaced, once the surrounding markdown has rendered, with whatever
slots()[name] (or extraSlots on the demo) resolves to:
- An unregistered name renders nothing — not an error. This is intentional; a typo in a slot name shouldn't break the whole preview.
- The same name used twice produces two independent instances — not a shared one.
- A rejected/throwing resolution (see External components below) also
renders nothing at that position, but does log a
console.error, since that's a real runtime failure a developer needs to see, not an authoring typo. - Slot values receive no data from the markdown beyond their name and
position. A slot owns its own inputs/outputs, API calls, and internal
state entirely —
RtfEditorPreviewComponentnever passes anything into it.
There are exactly two shapes a slot value can take:
1. Internal components — a same-bundle Angular Type<unknown>
The simplest case — any Angular component already compiled into your app. Nothing extra to install.
import { Component } from '@angular/core';
@Component({
selector: 'my-counter',
template: `<button (click)="count = count + 1">{{ count }}</button>`,
})
export class MyCounterComponent {
count = 0;
}@Component({ /* ... */ })
export class MyPageComponent {
slots = { myCounter: MyCounterComponent };
}<app-rtf-editor-preview [content]="markdown" [slots]="slots"></app-rtf-editor-preview>Mounted via ViewContainerRef.createComponent(componentType) — full change
detection, DI, and lifecycle, exactly like any other dynamically-created
Angular component.
2. External components — a function producing an HTMLElement
For anything not compiled into your app's own bundle — most commonly a custom element loaded at runtime via Native Federation from a separately-built, separately-deployed widget.
The slot value is a plain function:
type RtfEditorPreviewElementThunk = () => HTMLElement | Promise<HTMLElement>;It's called once per render pass; its result (or resolved Promise) is
inserted directly into the DOM at the slot's position via
insertBefore/remove() — the same positional guarantee as the Type path.
RtfEditorPreviewComponent never inspects the element beyond that: it may
contain a whole separate, independent Angular application instance (as a
federated widget typically does), and this library has no business reaching
into it.
If a render pass starts again before an in-flight Promise resolves (e.g.
the user is still typing), the stale result is discarded instead of being
inserted into an already-removed placeholder — no manual bookkeeping needed
on your end.
Full setup: loading a widget via Native Federation
This is the involved case, and the part most worth documenting in detail.
Everything below lives in your consuming app, never inside rtf-editor
itself — the whole point of the function-based slot shape is that this
library stays completely unaware of Native Federation, or of how any
particular element got built.
1. Install, in the consuming app (not in rtf-editor):
npm install @angular-architects/native-federationThis pulls in @softarc/native-federation, @softarc/native-federation-orchestrator,
and es-module-shims transitively — no need to install those separately.
If the remote widget's exposed module is a raw Angular component class rather than an already-self-registering custom element (see step 6 — this is common, and easy to get wrong), also install:
npm install @angular/elements2. federation.config.mjs, at your app's workspace root (next to
angular.json):
import { withNativeFederation, shareAll, NG_SKIP_LIST } from '@angular-architects/native-federation/config';
export default withNativeFederation({
name: 'your-app-name',
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
skip: [
...NG_SKIP_LIST,
],
});3. angular.json — your app's build/serve targets must use the Native
Federation builder. This is not optional and not automatic just from
federation.config.mjs existing — nothing reads that file unless the builder
is wired in. It also isn't a simple in-place builder swap: the Native
Federation builder wraps a separate, already-defined esbuild/dev-server
target rather than replacing one outright. Rename your existing targets and
add new ones delegating to them:
{
"architect": {
// Your ORIGINAL "build" target, renamed:
"esbuild": {
"builder": "@angular/build:application",
"options": {
// ... unchanged from before, except:
"polyfills": ["zone.js", "es-module-shims"] // add this
},
"configurations": { /* unchanged */ }
},
// Your ORIGINAL "serve" target, renamed, buildTarget updated to "esbuild":
"serve-original": {
"builder": "@angular/build:dev-server",
"options": { /* unchanged */ },
"configurations": {
"production": { "buildTarget": "your-app-name:esbuild:production" },
"development": { "buildTarget": "your-app-name:esbuild:development" }
},
"defaultConfiguration": "development"
},
// NEW "build", delegating to "esbuild":
"build": {
"builder": "@angular-architects/native-federation:build",
"options": { "cacheExternalArtifacts": true },
"configurations": {
"production": { "target": "your-app-name:esbuild:production" },
"development": { "target": "your-app-name:esbuild:development", "dev": true }
},
"defaultConfiguration": "production"
},
// NEW "serve", delegating to "serve-original":
"serve": {
"builder": "@angular-architects/native-federation:build",
"options": {
"target": "your-app-name:serve-original:development",
"rebuildDelay": 500,
"cacheExternalArtifacts": true,
"dev": true,
"devServer": true,
"port": 4300
}
}
}
}The es-module-shims polyfill is required — without it, the global
importShim (which shim-mode import maps depend on) is never defined, and
every federated module load fails immediately.
4. main.ts / bootstrap.ts split. Once your app's own build/serve
run through the Native Federation builder, your app's own main.js is
bundled as an es-module-shims-wrapped module that needs an import map for
its own shared dependencies (@angular/core, @angular/platform-browser,
etc.) just to boot — independent of whether you ever load an external widget.
That import map only exists once initFederation() has run and fetched your
app's own self-referential remoteEntry.json. A static import inside
main.ts can't work here, no matter when you call initFederation() from
within that same file's body: static imports resolve before a module's own
code runs, so by the time any of your code executes, main.ts's own Angular
imports have already failed. Split it instead:
// main.ts
import { initFederation } from '@angular-architects/native-federation';
initFederation({}) // no remotes here — see step 6
.catch(err => console.error(err))
.then(_ => import('./bootstrap')) // dynamic import: resolves AFTER the map exists
.catch(err => console.error(err));// bootstrap.ts — your app's actual, original main.ts content
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));(This is exactly what @angular-architects/native-federation's own
ng add schematic generates automatically for freshly-scaffolded apps —
do that instead of this by-hand split if you're setting up federation from
scratch in a brand-new app.)
5. Where to put the widget's files. Copy the remote widget's own build
output (e.g. dist/<widget>/browser/* from its own ng build) into your
app's static assets, under a widgets/<widget-name>/ subfolder — e.g.
src/assets/widgets/hello-widget/ (or public/widgets/hello-widget/ if your
app uses Angular's newer public/ folder convention instead of src/assets/
— check your own angular.json's assets config to see which one actually
applies). This serves the widget same-origin, so no second dev server is
needed to test locally. Every widget your app loads this way should live
under this same widgets/ folder — it's a convention, not something specific
to any one widget.
6. The loader function itself. This is what actually becomes the slot's value. Two variants, depending on what the remote exposes:
If the remote's exposed module already self-registers a custom element
(i.e. it calls customElements.define(...) as a side effect of its own
bootstrap running):
import { loadRemoteModule } from '@angular-architects/native-federation';
export async function loadMyWidget(): Promise<HTMLElement> {
await loadRemoteModule({
remoteEntry: '/assets/widgets/my-widget/remoteEntry.json',
exposedModule: './MyWidgetElement',
});
await customElements.whenDefined('my-widget');
return document.createElement('my-widget');
}If the remote's exposed module is a raw Angular component class instead
(check by inspecting the served chunk — if it's just
export { SomeComponent } with no customElements.define call anywhere,
you're in this case; this is what ui-block-stack's hello-widget actually
does, despite documentation suggesting otherwise). customElements.whenDefined
would hang forever here, since nothing ever defines the tag. Wrap it yourself
instead, via @angular/elements:
import { Injector, Type } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { loadRemoteModule } from '@angular-architects/native-federation';
let defined = false;
export async function loadMyWidget(injector: Injector): Promise<HTMLElement> {
const module = await loadRemoteModule({
remoteEntry: '/assets/widgets/my-widget/remoteEntry.json',
exposedModule: './MyWidgetComponent',
});
const MyWidgetComponent = (module as { MyWidgetComponent: Type<unknown> }).MyWidgetComponent;
if (!defined) {
customElements.define('my-widget', createCustomElement(MyWidgetComponent, { injector }));
defined = true;
}
await customElements.whenDefined('my-widget');
return document.createElement('my-widget');
}Note the top-level loadRemoteModule is used here (not the one returned
by initFederation()'s own resolved result) — it's the only variant
supporting the remoteEntry option, which is documented as enabling
"lazy-loading remotes not registered during initFederation." That's
deliberate: step 4's initFederation({}) call registers no remotes at all,
so every widget gets registered lazily, on first actual use, the same way.
The top-level function's deprecation note warns about relying on "the most
recent initFederation call" in multi-host setups — irrelevant for a normal
app that only ever calls initFederation() once.
If your widget's outputs surface as DOM CustomEvents (a common convention —
check the widget's own docs), listen for them before returning the element:
element.addEventListener('someOutput', (event) => {
console.log('someOutput:', (event as CustomEvent).detail);
});7. Wire it into slots. Since this loader needs an Injector (only in
the wrap-a-raw-component case) and imports @angular-architects/native-federation
directly, keep it in your app's own source — never inside a shared,
published library's component, even one that itself renders a
RtfEditorPreviewComponent. A component like RtfEditorDemoComponent gets
bundled as one shared file per published package (ng-packagr's FESM output);
anything it imports becomes something every consumer of that package needs
resolvable, whether or not they use the widget-loading feature at all.
// app.component.ts, or wherever you own the injection context
import { Injector, inject } from '@angular/core';
import { loadMyWidget } from './my-widget-slot';
export class AppComponent {
private injector = inject(Injector);
slots = { myWidget: () => loadMyWidget(this.injector) };
}```slot:myWidget
For a complete, real, working reference implementation of every step above,
see `src/app/rtf-editor-hello-widget-slot.ts` and the `angular.json`/`main.ts`
changes in this workspace's own `app-lib` project (the app that runs
`RtfEditorDemoComponent`'s `hello-widget` slot).