Notepad Neo

Changing font size in contenteditable without HierarchyRequestError

The obvious way to apply a font size to a selection is to wrap it in a span. The obvious way to wrap a selection in a span throws an exception on most real selections. What replaced it is uglier, deprecated, and correct.

DH

— builds and maintains Notepad Neo

· updated · 11 min read

You have a selection in a contenteditable and a number from a dropdown. You want the selected text to become that size. The DOM has an API that looks purpose-built for the job:

const span = document.createElement('span');
span.style.fontSize = '24px';
range.surroundContents(span);   // throws, most of the time

It throws because of one sentence in the DOM specification: the range must contain only text nodes and completely selected nodes. Partially select any element and the call is refused.

In a plain textarea that restriction would almost never bite. In a rich-text editor it is the normal case. Drag across a word that happens to be bold. Drag from the middle of one paragraph into the next. Drag over a link. In every one of those the range's start and end boundary points sit in different elements, and the call throws.

Why surroundContents refuses a selection that crosses an element boundary A selection starting inside a bold element and ending in the plain text after it. The start boundary point sits inside the b element and the end boundary point sits in its parent, so wrapping the range in one new element would require splitting the bold element in half. surroundContents refuses rather than choosing where to split. THE SELECTION <b>he llo</b> wor ld start: inside <b> end: in the parent WHAT WRAPPING WOULD REQUIRE <b>he</b> ✕ split here <span><b>llo</b> wor</span> There is no single correct way to wrap this range in one element. The bold has to be broken in half, and the spec does not say where. So surroundContents declines to guess, and throws instead. That is a principled refusal — and completely unhelpful.
The refusal is correct behaviour. It just leaves you having to do the splitting yourself, which is the part every editor framework spends its complexity budget on.

Doing it properly means walking the range, splitting text nodes at both boundaries, cloning the ancestor chain for every partially-covered element, reassembling the result, and then normalising what you produced so the next operation does not compound the mess. That is not a weekend project; it is the core of a document model.

Borrowing the browser's own splitter

There is a shortcut, and it is a deprecated API.

document.execCommand('fontSize') has been doing exactly this node-splitting work inside every browser engine for two decades. MDN lists it as deprecated and advises against using it, which is fair — but the splitting logic underneath it is the same code the browser runs when you press Ctrl+B, and it is battle-tested in a way nothing written in an afternoon will be.

The catch is what it emits. execCommand('fontSize') speaks the HTML 3.2 <font size> vocabulary — seven fixed buckets, 1 through 7, mapping to browser-defined sizes. It cannot express "24px" and never will.

So it gets used purely as a splitter. Ask for size 7, the value least likely to already exist in the document, then find every <font size="7"> it just created and swap each one for a span with the size you actually wanted.

Using execCommand as a node splitter and replacing what it emits Four stages. A selection crossing a bold boundary is passed to execCommand with fontSize 7. The browser splits the nodes correctly and wraps them in font elements with size 7. Those elements are queried back by selector and each is replaced with a span carrying the real pixel size and a line height. Finally, conflicting font-size and line-height declarations are stripped from the new span's descendants. 1 · SELECTION <b>he[llo</b> wor]ld crosses a boundary 2 · BROWSER SPLITS execCommand( 'fontSize', false, '7') deprecated, and correct 3 · SENTINEL MARKUP <b>he<font size="7">llo</font></b> <font size="7"> wor</font>ld bold survived the split 4 · REPLACE EACH SENTINEL querySelectorAll('font[size="7"]') → <span style="font-size:24px; line-height:1.4"> Size 7 is a sentinel, not a size. Nothing else in the document uses it, so it is safe to query back and overwrite wholesale.
The browser does the hard part — splitting text nodes, cloning ancestors, preserving bold, italic and link structure across the boundary. We get a marker we can find by selector.
document.execCommand('fontSize', false, '7');

editorEl.querySelectorAll('font[size="7"]').forEach(el => {
  const span = document.createElement('span');
  span.style.fontSize = size;
  span.style.lineHeight = '1.4';
  while (el.firstChild) span.appendChild(el.firstChild);
  el.parentNode?.replaceChild(span, el);
  // …strip conflicting sizes from descendants (see below)
});

One detail in there is easy to write wrong. The children are moved, not cloned — while (el.firstChild) span.appendChild(el.firstChild) relocates the live nodes. Cloning would produce identical-looking markup, but the user's selection is anchored to the original text nodes, and cloning orphans those anchors. The selection collapses, and the caret ends up somewhere the user did not put it.

This works. It also creates five problems.

Problem 1 — lines overlap unless you set line-height too

The editor paper has font-size: 14px and line-height: 1.6. Because that line-height is unitless, it computes to 22.4px on the paper, and every descendant inherits that computed pixel value — not the ratio.

So a span with font-size: 32px and no line-height of its own gets 32-pixel glyphs packed into a 22.4-pixel line box. Ascenders and descenders collide with the lines above and below.

Large text inside an inherited pixel line-height On the left, 32 pixel text inside a line box of 22.4 pixels inherited from the paper's 14 pixel base times 1.6. The glyphs overflow their box and collide with the lines above and below. On the right, the span carries line-height 1.4, which recomputes against its own 32 pixel font size to a 44.8 pixel box, and the lines clear each other. NO LINE-HEIGHT ON THE SPAN line-height: 1.4 ordinary body text at 14px Heading 22.4px box the line below it ✕ glyphs cross the box edges in both directions — the collision is real, not a rounding artefact ordinary body text at 14px Heading 44.8px box the line below it ✓ the box grows with the text 14px × 1.6 = 22.4px, inherited as a length. 32px × 1.4 = 44.8px, recomputed per element.
A unitless line-height on the parent is what makes the child override possible at all. Had the base rule been line-height: 22.4px, the span would have inherited the length and there would be nothing to recompute against.

Every size span gets it, without exception:

span.style.fontSize = size;
span.style.lineHeight = '1.4';   // never omit — inherited px line-height clips large text

Problem 2 — re-applying a size silently does nothing

This one took longest to characterise, because the reproduction has three steps and each step looks fine on its own.

  1. Select a sentence. Apply 24px. It becomes 24px.
  2. Select the same sentence again. Apply 12px.
  3. Nothing happens. The text stays at 24px.

The cause is nesting. After step 1 the DOM holds a 24px span. In step 2, execCommand wraps that existing span in a new <font size="7">, which gets replaced with a 12px span. Both declarations are inline styles, so specificity is identical and the innermost one wins. The 12px span is real, correctly applied, and completely invisible.

Why applying a second size appears to do nothing Applying 24 pixels then 12 pixels produces a 12 pixel span wrapping a 24 pixel span. Both are inline styles with equal specificity, so the inner declaration wins and the text keeps rendering at 24 pixels. Stripping font-size and line-height from the new span's descendants removes the inner declaration and lets the outer one take effect. AFTER APPLY 24, THEN APPLY 12 <span style="font-size: 12px"> <span style="font-size: 24px"> text </span> Equal specificity — both are inline styles. The innermost declaration wins, so the text still renders at 24px. AFTER STRIPPING DESCENDANTS <span style="font-size: 12px"> text </span> Only font-size and line-height are removed. Colour, background and font-family on the same descendants survive untouched.
The empty style attribute is dropped too, which is what stops the document accumulating <span style=""> debris every time somebody changes their mind about a size.
span.querySelectorAll<HTMLElement>('[style]').forEach(child => {
  child.style.removeProperty('font-size');
  child.style.removeProperty('line-height');
  if (!child.style.cssText.trim()) child.removeAttribute('style');
});

Problem 3 — a collapsed selection has nothing to wrap

If the user picks a size with no text selected, they mean "type at this size from here". There is no range content for execCommand to split, so it does nothing at all — no error, no markup, no feedback.

That branch is handled separately by inserting an empty span containing a zero-width space and dropping the caret just after it, so the next keystroke lands inside a correctly-sized span:

const span = document.createElement('span');
span.style.fontSize = size;
span.style.lineHeight = '1.4';
span.innerHTML = '&#8203;';        // U+200B — keeps the span alive
range.insertNode(span);
range.setStartAfter(span);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);

The zero-width space is load-bearing. An empty inline element generates no layout box, so the caret cannot be placed meaningfully inside it and the browser either discards the element or skips past it. A character that occupies no visual width but does exist keeps the span in the document long enough to be typed into.

A character you have to clean up everywhere downstream

U+200B leaks. It is invisible in the editor and invisible in the saved HTML, and then it turns up in every consumer of that HTML. Both exporters strip it explicitly — the DOCX writer because the character would otherwise land in word/document.xml, and the PDF writer because it is not representable in WinAnsiEncoding and would otherwise trip the "this note needs the print pipeline" pre-flight check on virtually every note ever written.

If you introduce a sentinel character into a document model, budget for finding every place that reads the document.

Problem 4 — headings fight back, in both directions

Headings get their size from a stylesheet rule on the element itself — h1 { font-size: 2em } — while our sizes arrive on a span inside it. The two do not interact the way you would guess.

Shrink the text inside an <h1> to 12px and the span dutifully renders at 12px, but the h1 still establishes a line box sized for 28px text. The small text floats in a tall, oddly-spaced row. The block's own metrics set the floor for the line box — a "strut" — and no inline element inside it can shrink that.

So after wrapping, the code walks up to the nearest heading and updates the heading itself:

let n: Node | null = freshSel.getRangeAt(0).startContainer;
while (n && n !== editorEl) {
  if (n instanceof HTMLElement && /^H[1-6]$/.test(n.tagName)) {
    n.style.fontSize = size;
    n.style.lineHeight = '1.4';
    break;
  }
  n = n.parentNode;
}

The mirror image of that bug lives in the block-type dropdown. Switching a paragraph to a heading has to clear any inline font-size and line-height left on the block, or the leftover inline style beats the stylesheet's heading rule and the new <h1> renders at body size. That cleanup runs in a setTimeout(…, 0), because execCommand('formatBlock') has not finished replacing the element when the call returns.

Two fixes pulling in opposite directions, for the same underlying reason: inline styles and element-level rules are not competing on equal terms, and which one wins depends on where the declaration landed rather than on what the user meant.

Problem 5 — the dropdown reads back the wrong value

The toolbar dropdown should follow the caret: click into 18px text and it should say 18. There is an API that looks like it answers this, and it does not.

Two ways to read the font size at the caret queryCommandValue with fontSize reports in the legacy one to seven scale and cannot express a pixel size. Reading getComputedStyle on the element at the caret returns a resolved pixel value, which is rounded and matched against the dropdown options. DO NOT USE document.queryCommandValue('fontSize') → "1" … "7" The legacy scale execCommand consumes. And since we overwrite every font element with a USE THIS getComputedStyle(el).fontSize → "17.6px" Inheritance, stylesheet rules and inline styles are already resolved. Round, then match. styled span, it usually has nothing to report. Rounding matters: browser zoom and fractional em values routinely produce 17.6px, not 18.
The rounding step is not defensive tidiness. Compared strictly, 17.6 matches no option in the list and the dropdown silently stops tracking the caret.
const node = sel.getRangeAt(0).startContainer;
const el = (node.nodeType === Node.TEXT_NODE ? node.parentElement : node) as HTMLElement | null;
if (el) {
  const pxSize = parseFloat(getComputedStyle(el).fontSize);
  if (!isNaN(pxSize)) {
    const rounded = Math.round(pxSize);
    const opts = Array.from(this.fontSizeEl.options);
    const match = opts.findIndex(o => Number(o.value) === rounded);
    if (match >= 0) this.fontSizeEl.selectedIndex = match;
  }
}

The if (match >= 0) guard is doing real work too. The dropdown offers sixteen discrete sizes, and a computed value that is not one of them — 28px inside an h1 that got its size from 2em, say — leaves the dropdown showing its previous value rather than resetting to a default. Showing a stale number is bad; showing a wrong number the user might then apply is worse.

Applying a format and reading it back are two different problems

Everything above is about the write path. The read path — keeping the toolbar showing what is actually true at the caret — turned out to be roughly the same amount of work, for a reason that is structural rather than incidental.

In an editor with a document model, the toolbar is a function of the model. Here there is no model, so the toolbar is a function of whatever the DOM happens to say, re-derived on every selectionchange. That means every format has two implementations that have to agree: one that writes it, and one that recognises it.

The round trip between the toolbar and the document A dropdown change dispatches a custom DOM event carrying the requested size. The controller mutates the document. The browser fires selectionchange, and the controller reads the state back out of the DOM to update the toolbar. The write path and the read path use completely different APIs. Toolbar UI size dropdown event ToolbarController applyFontSize() The DOM the document selectionchange → updateFromSelection() nn:font-cmd Write path: execCommand + span surgery. Read path: getComputedStyle + queryCommandState. No shared code.
The toolbar and the controller never hold references to each other. They communicate through custom DOM events, because they live in separate Astro components and a shared module import would not survive the component boundary.

The read path collects everything in one pass. Bold, italic, underline, strikethrough and the four alignments come from queryCommandState, which is reliable for those because they are boolean and the browser owns the answer. Direction comes from getComputedStyle(el).direction. Size comes from computed style, as above. And font family comes from queryCommandValue('fontName') — which returns something like "Arial, sans-serif", a whole CSS font stack, not a name.

So the family dropdown cannot compare for equality either. It lowercases and substring-matches against each option, which is exactly as fragile as it sounds and is the pragmatic answer to an API that returns a different string shape depending on how the font got applied.

There is one honest piece of dead weight left over from all this. getSelectionState() still populates a fontSize field from queryCommandValue('fontSize'), and the toolbar ignores it completely in favour of computed style. It survives because every other field on that object is used and removing one member of a state struct is the kind of tidying that is easy to get wrong later. It is worth knowing it is there so nobody wires it back up.

Would a framework have been better?

Honestly, for this specific feature — probably.

ProseMirror, Lexical and TipTap all maintain a document model separate from the DOM, and in a model like that "apply a mark to a range" is a well-defined operation with none of the failure modes above. Every problem in this article is a consequence of treating the DOM itself as the source of truth: the nesting bug, the strut collision, the zero-width space, the dropdown sync. None of them exist if the document is a tree you own and the DOM is just a rendering of it.

The trade is bundle size and control. Notepad Neo ships no runtime framework at all — the core editor modules come to roughly 2,700 lines of plain TypeScript, with another 2,100 or so for the DOCX and PDF writers. Both of those writers read formatting straight off the live DOM via getComputedStyle, which is only possible because the DOM is the document. Adopting an editor framework to fix font sizing would have meant rebuilding both export pipelines, the direction handling and the tab persistence around someone else's model.

What we would not recommend to anyone is the middle path: hand-rolling the range-splitting logic that execCommand already contains. Either lean on the deprecated API that has the splitter, or move to a real document model. Writing your own surroundContents that handles partial selections is a much larger project than it looks, and it is the part of an editor where bugs are hardest to see and easiest to ship.

On depending on a deprecated API

execCommand is deprecated, not removed, and there is no replacement for the parts of it that matter here — node splitting that respects existing inline structure, and mutations that participate in the browser's native undo stack. Nothing in the standards pipeline currently offers either. Removing it would break a large fraction of the rich-text editors on the web at once, which is why every engine still ships it.

That is not an argument that it is safe forever. It is an argument that the migration cost is already priced in: the day it goes, you are moving to a document model anyway, and that was always going to be a rewrite rather than a patch.

Summary

Related: why dir="auto" breaks a mixed-language editor covers the other place where a single attribute looks like it solves a problem and solves it at the wrong granularity.

← All engineering write-ups Try the editor