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.
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.
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.
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.
- Select a sentence. Apply 24px. It becomes 24px.
- Select the same sentence again. Apply 12px.
- 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.
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 = '​'; // 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.
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.
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 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.
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
-
range.surroundContents()throws on any selection crossing an element boundary. Do not build on it. -
Use
execCommand('fontSize', false, '7')as a splitter, then replace everyfont[size="7"]with your own styled span. Move the children, do not clone them. -
Always set
line-heighton that span, or an inherited pixel line-height clips large text. -
Strip
font-sizeandline-heightfrom the new span's descendants, or re-applying a size appears to do nothing. - Handle collapsed selections separately with a zero-width-space span — and strip that character in every exporter.
- Update the heading's own size when the caret is inside one, and clear inline sizes when converting a block into a heading.
-
Sync the dropdown from
getComputedStyle, rounded, never fromqueryCommandValue('fontSize').
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.