DEV Community

Cover image for What I learned putting my own text editor into production
Ankitkumar Singh
Ankitkumar Singh

Posted on

What I learned putting my own text editor into production

A follow-up to "The 5.2 kB editor I had to write because nothing else fit."

Shipping a package to npm is one thing. Actually using it in the production app it was built for is a different exercise entirely. You find the edge cases nobody would catch in unit tests, because they only show up when the code touches real data, real forms, and a real design system with its own opinions.

This is the follow-up to the first post about ng-text-editor-lite, the sub-6-kB Angular WYSIWYG editor I built when nothing else fit the shape of what we needed. The editor is live now. It's handling task descriptions, comments, and log entries across the app. And along the way, I hit four issues that weren't obvious from the docs, weren't obvious from the code, and would have saved me a couple of afternoons if someone had written them down.

So here they are. Written down.


Issue 1: "No value accessor for form control" and the reactive forms trap

The first place I dropped the editor into was a comment form. Reactive forms, FormBuilder, the usual pattern. I wrote what I thought was the obvious code:

<ng-text-editor-lite 
  formControlName="comment"
  [config]="editorConfig"
/>
Enter fullscreen mode Exit fullscreen mode

And got a runtime error:

Error: No value accessor for form control with name: 'comment'
Enter fullscreen mode Exit fullscreen mode

For a while I assumed the editor's ControlValueAccessor implementation was broken. I checked the package, checked my import, checked my version. All fine. The problem was somewhere else entirely.

Two things went wrong at the component level:

  1. ReactiveFormsModule wasn't in the component's imports array. formControlName is a directive from ReactiveFormsModule, not from the editor package. Without it, Angular has no idea what formControlName means and can't find any value accessor at all.
  2. The template didn't have a [formGroup] wrapper. formControlName only works inside a form that's bound to a FormGroup.

Both of these are Angular basics, honestly. The kind of thing you'd expect to trip up a beginner, not someone who's been shipping Angular apps for years. But the error message points at the editor, which sends you down the wrong debugging path.

The working setup looks like this:

// Component imports
imports: [ReactiveFormsModule, EditorComponent]

// The form
form = this.fb.group({
  comment: ['', Validators.required],
});
Enter fullscreen mode Exit fullscreen mode
<form [formGroup]="form">
  <ng-text-editor-lite 
    formControlName="comment"
    [config]="editorConfig"
  />
</form>
Enter fullscreen mode Exit fullscreen mode

Three things to check the moment you see "No value accessor":

  • Is ReactiveFormsModule imported into the component (or its parent, if this is a nested component sharing a form)?
  • Is there a [formGroup] binding on a parent element in the template?
  • Does the FormGroup actually have the key you're referencing in formControlName?

Nine times out of ten it's one of those three. Not the editor.


Issue 2: Preview mode with legacy data, or why your bold text vanished

The task app I'm building this editor for is replacing an older internal tool. Which means we have historical data. A lot of it. Task comments, activity logs, notes fields, all sitting in a database with HTML that was written by a different editor years ago.

When I flipped the new editor into preview mode to render this legacy content, most of it looked fine. But some of it looked wrong. Bold text wasn't bold. Italics rendered as plain text. Strikethrough was just regular text with no line through it.

The formatting was there in the raw HTML. I could see it in the database. It just wasn't showing up.

The reason turned out to be sanitization. The editor allows only a strict whitelist of tags: strong, em, s, and a handful of block-level ones. Anything outside that gets stripped silently on the way in. Legacy content used the older tag names:

  • <b> for bold, not <strong>
  • <i> for italic, not <em>
  • <strike> or <del> for strikethrough, not <s>

The sanitizer sees these, doesn't recognize them, strips them. What comes out is unformatted text.

I had two options. Change the sanitizer's whitelist (bad idea, opens the door to weird edge cases), or normalize the content before handing it to the editor. Normalizing is the right call because it means the rendered output stays clean without loosening the security model.

A small helper does the job:

private normalizeRichText(html: string): string {
  if (!html) return '';
  return html
    .replace(/<b(\s[^>]*)?>/gi, '<strong>')
    .replace(/<\/b>/gi, '</strong>')
    .replace(/<i(\s[^>]*)?>/gi, '<em>')
    .replace(/<\/i>/gi, '</em>')
    .replace(/<(strike|del)(\s[^>]*)?>/gi, '<s>')
    .replace(/<\/(strike|del)>/gi, '</s>');
}
Enter fullscreen mode Exit fullscreen mode

Then it wires into the template via a getter or a computed signal:

<ng-text-editor-lite
  [content]="normalizedComment"
  [mode]="'preview'"
  [config]="editorConfig"
/>
Enter fullscreen mode Exit fullscreen mode

The debugging tip that would have saved me time: when preview mode looks wrong, log the raw HTML string. Do it before the editor touches it. You'll usually see legacy tag names, or you'll see inline style attributes carrying the formatting.

Inline styles are the harder case. If the old editor saved things like <span style="font-weight: bold">text</span>, the sanitizer strips the style attribute entirely because it isn't in the allowed attributes list. Regex won't help you here. You need a proper DOM parser to walk the tree and rewrite styled spans into tag wrappers. I'd suggest running a one-time migration script on the database if you're facing this at scale, rather than doing it in the browser on every render.


Issue 3: Making the editor follow your app's dark mode

Our app has a ThemeService that everything hooks into. Toggle it once, and the sidebar, buttons, cards, and material components all pick up the change. Everything except the editor.

The editor has its own theme config option that takes 'light' or 'dark'. That part works fine, but it only reads the value once, at initialization. If you toggle themes at runtime, the editor sits there in whatever theme it started in.

The fix is to treat the editor's theme like any other reactive input: subscribe to the theme observable, rebuild the config object when it changes.

Here's the shape of the ThemeService we're using:

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private themeSubject = new BehaviorSubject<'light' | 'dark'>(this.loadFromStorage());
  theme$ = this.themeSubject.asObservable();

  toggleTheme(): void { /* ... */ }
  isDarkTheme(): boolean { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

And here's how the editor's host component consumes it:

export class CommentFormComponent implements OnInit, OnDestroy {
  editorConfig: EditorConfig = { /* defaults */ };
  private themeSub?: Subscription;

  constructor(private themeService: ThemeService) {}

  ngOnInit(): void {
    this.editorConfig = {
      ...this.editorConfig,
      theme: this.themeService.isDarkTheme() ? 'dark' : 'light',
    };

    this.themeSub = this.themeService.theme$.subscribe(theme => {
      this.editorConfig = { ...this.editorConfig, theme };
    });
  }

  ngOnDestroy(): void {
    this.themeSub?.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

The critical detail is the spread. this.editorConfig = { ...this.editorConfig, theme } creates a new object reference. If you mutate the existing object with this.editorConfig.theme = theme, Angular's OnChanges doesn't fire on the editor because the reference is the same. The visual change won't propagate.

This tripped me up longer than I'd like to admit, because the config object clearly had the new value in it, the component was clearly reacting to the theme change, and yet the editor kept rendering in the old theme. If your editor looks stuck on one theme after a toggle, this is almost always the reason.

If you're on Angular 19 and prefer signals, the same pattern with toSignal is cleaner:

currentTheme = toSignal(this.themeService.theme$, {
  initialValue: this.themeService.getTheme(),
});

editorConfig = computed<EditorConfig>(() => ({
  placeholder: 'Write a comment...',
  maxLength: 3000,
  theme: this.currentTheme(),
}));
Enter fullscreen mode Exit fullscreen mode

Then bind [config]="editorConfig()" in the template. Every consumer component that hosts an editor subscribes independently. The service is a single source of truth, but each editor's config lives in its host component. There's no global editor-theme registry to worry about.


Issue 4: The dark mode that config can't reach

Once the theme was syncing, the built-in dark theme kicked in properly. And it looked fine, but the colors didn't match our design system. Our dark mode uses a warmer palette than the editor's defaults, and I needed to align them.

Here's where I learned something about the editor's own config API that isn't obvious from the surface. The EditorConfig object accepts two color overrides:

  • mentionTextColor
  • mentionBackgroundColor

That's it. Those are the only color knobs exposed through TypeScript. The overall background, text color, border, toolbar background, and link color? None of those are in the config. They're set by the package's own theme service as inline styles on the host element.

I spent a while writing config overrides that looked correct but did nothing. Setting mentionTextColor to your app's primary text color doesn't affect the editor's own text. It only affects @mention badges, which you might not even have in your content. So the change looks like it silently fails when actually it's doing exactly what it says on the tin, just not what I thought it did.

The way to override the rest is through CSS variables. The editor exposes these tokens on the host element:

--ngx-editor-bg
--ngx-editor-color
--ngx-editor-border
--ngx-editor-toolbar-bg
--ngx-editor-link-color
--ngx-editor-mention-bg
--ngx-editor-mention-color
Enter fullscreen mode Exit fullscreen mode

You override them in your global stylesheet, scoped to your app's dark theme class:

body.dark-theme ng-text-editor-lite {
  --ngx-editor-bg: #1e1e2e !important;
  --ngx-editor-color: #cdd6f4 !important;
  --ngx-editor-border: #45475a !important;
  --ngx-editor-toolbar-bg: #181825 !important;
  --ngx-editor-link-color: #89b4fa !important;
}
Enter fullscreen mode Exit fullscreen mode

The !important is the part that caught me off guard. Normally you wouldn't reach for it. But because the editor writes its default theme values as inline styles on the host element (which is how the package guarantees the styling can't be broken by unrelated CSS resets), external CSS loses to those inline styles unless you force the override with !important.

Scoping to body.dark-theme means these overrides only apply when the app is actually in dark mode. Light mode keeps the editor's defaults. If you want to override the light theme too, add a matching block scoped to your light-mode class or leave it unscoped.

One thing worth being honest about: this is the part of the editor's API that I'd change first if I could. Having to reach for !important is a smell. A cleaner design would be a full colors object in the config, similar to how mentionTextColor works today, but for every token. That's on the list for a future minor version. In the meantime, the CSS variable approach works fine, it just isn't as ergonomic as I'd like.

A related trap: global CSS bleeding into the editor

The other thing dark mode gave me was a specificity war. Our app's dark theme had a rule like this in a global stylesheet:

body.dark-theme .content-area .details-panel .detail-card .detail-row span {
  color: #ffffff !important;
}
Enter fullscreen mode Exit fullscreen mode

Fine for most cases. Terrible for the editor's @mention badges, which are <span> elements. My scoped override at the component level compiled to something like [_nghost-xyz] .mention-badge, which has lower specificity than the six-selector chain above. Both had !important, so specificity decided, and my mention badges stayed white on a white background in dark mode.

The fix wasn't to fight the specificity war. It was to scope the global rule so it stopped affecting third-party components:

body.dark-theme .content-area .details-panel .detail-card .detail-row span:not(.mention-badge) {
  color: #ffffff !important;
}
Enter fullscreen mode Exit fullscreen mode

The takeaway: if you have a "make everything in dark mode white" sledgehammer rule anywhere in your styles, it's going to bleed into every UI library you ever install. Scope those rules to what they actually need to affect. Not span, but .detail-row-label or .field-value. It's more typing up front, but it saves an entire class of bug reports later.


What I'd tell someone about to install this package

Reading this back, most of what caused me trouble wasn't really the editor's fault. It was Angular fundamentals, legacy data, and a global CSS pattern that pre-dated the editor entirely. But those are exactly the kinds of things you don't think about when you're evaluating a new dependency, and they're exactly the kinds of things that determine whether shipping the package into a real codebase feels smooth or painful.

So if you're picking up ng-text-editor-lite for the first time, these are the four things I'd have on a sticky note next to your monitor:

  1. ReactiveFormsModule and [formGroup] are non-negotiable if you're using formControlName. The editor implements ControlValueAccessor correctly, so the error is always somewhere in your form wiring.
  2. Preview mode is a mirror for your data quality. If it looks wrong, log the raw HTML and check for legacy tag names.
  3. Theme sync is a subscription, not a one-time read. Spread into a new config object on change so Angular fires OnChanges.
  4. Editor colors beyond the mention tokens go through CSS variables with !important, scoped to your app's theme class. It works, it just isn't as pretty as I'd like.

Everything else, honestly, has been quiet since we shipped. The 5.2 kB budget held. The markdown paste feature is doing what it was built to do. And the parts that weren't obvious at first are documented now, both in the package docs and here.


Try it, break it, tell me

ng-text-editor-lite is on npm at @thedevankit/ng-text-editor-lite. Source, issues, and PRs live on the GitHub repo linked from the package page.

If you use it, use it in anger. Real forms, real data, real design systems. Then tell me what broke. The package got better every time it hit something I hadn't planned for, and the fastest way for it to keep improving is more people running it into more edge cases.

Bug reports, feature ideas, and "I tried this and got confused" notes are all equally welcome. Some of the best changes have come from someone dropping into the issues with a two-sentence description of a problem I would never have found on my own.

If this post saved you an afternoon of debugging, drop a reaction on the package or star the repo. It's the cheapest way you can tell me a piece of open-source work landed.

Top comments (0)