Engineering Journal
Pdf Processor
Pdf Processor

Inline formatting in a contenteditable that must survive a semantic round-trip

2026-08-12

Inline formatting in a contenteditable that must survive a semantic round-trip

TLDR: document.execCommand is deprecated and has no replacement, so a WYSIWYG-over-HTML editor still has to build on it. The trap is styleWithCSS: it makes color commands emit inline styles (good) but makes bold/italic emit styled spans instead of <strong>/<em> (bad for any parser that reads tags). The fix is to flip styleWithCSS only around color commands and leave every other formatting semantic.

The problem class

You are building an editor whose editable surface is not a fresh document but the exact HTML that a parser already produced. Your export pipeline parses that HTML back into a structured representation (runs with bold/italic/superscript/subscript flags) and renders it again. Formatting tools that the user clicks in the editor must therefore produce output the parser can still read.

Two facts collide here.

First, execCommand is the only built-in path for contenteditable formatting. It is deprecated on paper, but no browser API has replaced it for caret-aware inline styling. Any toolbar that does bold, lists, alignment, color, or highlight is calling it, whether you know it or not.

Second, execCommand output is governed by a document-level flag that most tutorials never mention: styleWithCSS.

The naive approach and why it fails

The obvious implementation is one click handler per command:

function fmt(cmd, val) {
    document.execCommand(cmd, false, val || null);
}
$('#btn-bold').on('click', () => fmt('bold'));
$('#btn-font-color').on('click', () => fmt('foreColor', '#cc0000'));

This works for the user and fails silently for the pipeline. With styleWithCSS left at its default (false), foreColor emits a <font color> tag, which your HTML sanitizer strips and your IR parser ignores. Set styleWithCSS true globally and color becomes an inline style (good), but now bold also emits <span style="font-weight:bold"> instead of <strong>, and any downstream reader that checks for <strong>/<em> tags stops seeing bold at all. You have traded one broken format for the other.

The failure is invisible at the surface. The text looks bold and red in the editor. Only export breaks, and only for some formats, and only after the user closes the tab.

The better model

Route formatting by command type instead of treating every command the same:

function fmtColor(cmd, color) {
    document.execCommand('styleWithCSS', false, true);
    document.execCommand(cmd, false, color);
    document.execCommand('styleWithCSS', false, false);
}

styleWithCSS is transient. It is forced true only for the color commands that need to emit styles, then immediately restored so bold and italic keep producing <strong> and <em>. The semantic tags stay readable by the parser, and the color survives as an inline style.

The same discipline applies to highlight. hiliteColor writes background-color, which is inline regardless of the flag in Chromium, but forcing the flag costs nothing and keeps the output consistent across browsers.

Implementation evidence

In a toolbar that grew superscript, subscript, highlight, font color, and a paragraph border:

The state reflection tells the same story: active states for bold and sup/sub come from queryCommandState, but highlight and font color cannot be read that way, so they walk the caret's inline ancestors and compare computed styles against the page default.

Tradeoffs

execCommand is a moving target. Browser teams no longer fix its quirks, and behavior differs in edge cases (color with collapsed selections, hiliteColor removal semantics). A serious product should eventually move to an editor framework that owns its own document model. That is a real cost and the next section's hottake argues when it is worth paying.

But within the constraint that the editable surface is arbitrary extracted HTML that must round-trip, routing styleWithCSS by command type is the smallest change that keeps both the surface and the serializer correct.

Read this post in the full Engineering Journal →