Building a Text Editor That Knows What You're Not Allowed to Touch

A guide to implementing editable templates with constrained mutation: user-customizable regions alongside locked structural placeholders that must survive the edit cycle in serializable form.

The Problem
Some templates aren’t just documents — they’re structured data with variables embedded in HTML. The system substitutes variables at render time: |otp| becomes an actual value, |logo| becomes an image URL.

The constraint: expose this as an editable form, allow copy/formatting edits, but ensure structural tokens survive unchanged. If the user deletes |otp|, the output breaks at render time.

This sounds simple until you hit:

  • React re-renders destroying browser selection state
  • Paste events inserting arbitrary HTML that corrupts template structure
  • Toolbar state queries only working while contentEditable has focus
  • Users deleting placeholders they shouldn’t

Why We Didn’t Use a Rich Text Editor Library:
The codebase already ships Quill.js. We spiked it. The problem: Quill assumes all content is mutable. Making some regions immutable requires custom Embed blots, a paste handler rewrite, and Delta serialization overrides. You end up fighting the library’s abstractions more than using them.

Decision framework:

Use a library (Quill, TipTap, ProseMirror) when you need ≥4 formatting options, undo/redo, or collaborative editing
Use vanilla contentEditable when the feature set is narrow (≤5 formats) and you have constrained, partially-immutable content
For three formatting buttons and two locked placeholders, vanilla was cleaner.

Core Pattern: Token ↔ Preview Transformation
The editor maintains three representations:

Canonical — backend storage with |token| strings
Edit preview — DOM with tokens replaced by image placeholders (data-token-type=“otp”)
Serialized — DOM converted back to canonical before save
On load, tokens become preview images. On save, images with data-token-type are replaced back with token strings. Validation blocks the save if any required token is missing.

The key: use data-token-type marker attributes, not src values, for reverse matching. src changes; marker attributes don’t.

Three Non-Obvious Technical Problems

  1. Toolbar buttons and focus - execCommand(‘bold’) requires focus inside contentEditable. Clicking a toolbar button blurs the editor before the command runs. Fix: use onMouseDown + e.preventDefault() instead of onClick. This prevents blur, keeping focus in the editor when the command executes.

  2. Selection loss during React re-renders - React reconciles the DOM aggressively. Any state update (toolbar active state, character count) can invalidate the browser’s Selection object. Fix: serialize selection to character offsets before any setState, restore after re-render via setTimeout(…, 0). Character offsets are DOM-agnostic and survive reconciliation.

  3. Paste normalization - Pasting from Word, web pages, or email clients inserts inline styles, foreign classes, and nested structures that break template serialization. Fix: intercept onPaste, e.preventDefault(), extract text/plain from clipboard, insert with execCommand(‘insertText’). Users paste plain text and re-apply formatting manually. A one-step cost for eliminating an entire class of corruption bugs.

Lessons
Fighting a library’s abstractions often costs more than writing targeted code for a specific problem. Know which is which.
document.execCommand is deprecated but not functionally gone. For ≤5 formatting commands, it’s simpler than the Selection API alternative.
Paste normalization is worth the one-step UX cost. A sanitizer that allows some formatted paste is more fragile than rejecting formatting outright.
Token substitution is your failure vector. Use stable marker attributes, not values that change. Validate before every save.

1 Like