A format painter is one of those features that looks like an afternoon of work. Read the formatting at the caret, remember it, and apply it to whatever the user selects next. Word has had it since the nineties. How hard can it be?
The obvious implementation is three lines, and it fails on one of the first things a real user will try.
The obvious implementation
Capture the computed style at the caret — computed, not the tag, so it works whether the source was <b>, an inline style attribute, or a class. Then wrap the target selection in a span carrying those properties.
const cs = getComputedStyle(sourceElement);
const captured = { "font-weight": cs.fontWeight, "color": cs.color, /* ... */ };
// later, on the next selection:
const span = document.createElement("span");
for (const k in captured) span.style.setProperty(k, captured[k]);
range.surroundContents(span);Paint bold red onto plain text and it works. Paint a different font onto a paragraph and it works. Then someone selects a run of bold text, paints unformatted text onto it to clear the bold, and nothing happens at all. No error. The span is in the DOM with font-weight: 400 right there in its style attribute. The text is still bold.
Inherited values lose to declared ones
This is not a specificity bug, which is where most people start looking. Specificity decides between rules competing for the same element. Here there is no competition, because the two declarations are on two different elements:
<span style="font-weight: 400"> <!-- what you painted -->
<strong>bold target</strong> <!-- what was already there -->
</span>The <strong> gets font-weight: boldfrom the browser’s own user-agent stylesheet. That is a declaration applying directly to the element. Your font-weight: 400reaches the <strong> only by inheritance from its parent.
And in the cascade, inheritance is the weakest thing there is. It is not a low-priority declaration; it is what an element falls back to when there is nodeclaration for that property at all. Any declared value on the element itself — including one from the UA stylesheet — wins. Wrapping never overrides a descendant. It cannot.
Which means a format painter cannot only add formatting. It has to remove the formatting already inside the target first.
Painting is replace, not add
Two kinds of leftovers have to go, and they fail for different reasons:
- Inline styles on descendants. A child with
style="font-weight:700"has a declared value, so it beats your inherited one. Remove those properties from every descendant. - Character-formatting tags.
<b>,<strong>,<i>,<em>,<u>,<s>,<font>. These carry no inline style to delete — their formatting comes from the UA stylesheet, so the tag itself has to be unwrapped.
One subtlety that decides whether this works: strip the fullset of properties your painter manages, not just the ones you captured. If you only capture properties that differ from the document default (see below), then painting “not bold” captures no font-weightat all — and if you strip only what you captured, you strip nothing, and the bold survives. Capture is a diff; stripping is absolute.
There is also a live-collection trap here. getElementsByTagName("*") returns a liveHTMLCollection; unwrapping elements while iterating it makes the collection shift under you and you silently skip nodes. Snapshot to an array first.
Capture the difference, not the computed style
getComputedStyle is fully resolved. Every property has a value, whether the author set it or not. Record all of them and every paint emits something like this:
<span style="font-family: -apple-system, Helvetica, Arial, sans-serif;
font-size: 16px; font-weight: 700; font-style: italic;
text-decoration-line: none; color: rgb(204, 0, 0);
letter-spacing: normal; text-transform: none;
font-variant: normal;">target</span>Nine declarations to say “bold and red”. The other seven are the document’s own defaults, restated. They bloat every save, they survive into whatever CMS or feed consumes the HTML, and they quietly pin the text to a font stack the source never actually chose — so it stops following the site’s typography.
Diff against the editable root instead. Anything the source merely inherited resolves identically at the root, so it drops out, and you are left with the two declarations that carry meaning:
const base = getComputedStyle(editableRoot);
for (const name of MANAGED_PROPERTIES) {
const v = cs.getPropertyValue(name);
if (!v || v === base.getPropertyValue(name)) continue; // inherited: skip
captured[name] = v;
}background-color deserves a special case: its computed value is rgba(0, 0, 0, 0) rather than a keyword, and carrying that around means every paint wipes highlights it was never asked to touch.
Repainting nests wrappers, and the check that catches it is wrong
Users repaint. They paint a word, decide it was the wrong source, and paint it again. Each pass wraps the selection in a fresh span, so after five attempts the markup is five spans deep for one word of text.
The instinct is to clean up stale wrappers inside the new one. That does not work, and the reason is worth understanding, because it is easy to write a test that passes anyway.
Wrapping a selection means extracting the selected content and putting it inside a new element at that position. If the user selected a word that lives insidethe previous wrapper, the new span is created inside it too. The stale wrapper is the new one’s ancestor, not its descendant. A cleanup pass that walks root.getElementsByTagName("*") will never see it.
This is exactly the case a careless test misses. Select the whole paragraph and repaint, and the old wrapper is pulled intothe extracted content — so it becomes a descendant, the cleanup finds it, and the test goes green while the real bug is untouched. Select just a word, the way a person actually would, and the nesting comes straight back.
So walk up instead. Collapse a parent only when it wraps nothing but you:
function collapseRedundantAncestors(span) {
for (;;) {
const p = span.parentNode;
if (!p || p.nodeType !== 1) return;
if (p.nodeName !== "SPAN" || !p.classList.contains("painted")) return;
if (!wrapsNothingElse(p, span)) return; // it still styles siblings
unwrap(p);
}
}The zero-length text node
wrapsNothingElse looks like it should be parent.childNodes.length === 1. Write that and the collapse never fires, with no error to explain why.
Range.extractContents()splits text nodes at the range boundaries. When the range covers an entire text node, the split still happens — it just produces empty ones. So the parent that visually contains exactly your new span actually contains three children:
#text (length 0)
SPAN.painted <-- the only thing with content
#text (length 0)Those empty nodes render nothing and appear nowhere in the serialised HTML, which is precisely why this costs an hour. Count only children that carry content:
function wrapsNothingElse(parent, child) {
for (const n of parent.childNodes) {
if (n === child) continue;
if (n.nodeType === 3 && n.nodeValue.length === 0) continue; // extractContents debris
return false;
}
return true;
}The same debris will bite anywhere you reason about structure after a range operation. Emptiness in the DOM is not the same as absence.
What a painter should refuse to carry
One deliberate limit: keep block-level properties out. Alignment, margins, line-height and indentation are tempting to include — they are “formatting” — but a painter that moves them reflows the document when the user expected a colour change. Character formatting is the whole contract, and users rely on that being the boundary.
The short version
- Wrapping cannot override descendants. Inherited values lose to declared ones, including UA-stylesheet ones. Strip before you apply.
- Capture a diff against the root; strip the absolute property set. They are not the same list.
- Stale wrappers end up as ancestors, not descendants. Test with a word-sized selection, not a whole block.
extractContents()leaves zero-length text nodes, sochildNodes.lengthlies.
RichTextEditor ships the format painter described here in the base licence. CKEditor 5 lists format painter under “Unlock this feature with selected CKEditor Plans”, and TinyMCE’s Format Painter plugin is documented as “only available for paid TinyMCE subscriptions”. You can read the API in the authoring tools documentation or try it in the live demo.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.