To wrap a text range while preserving existing inline elements, wrap each selected text fragment in its own span. This Safari-on-Mac walkthrough uses a synthetic paragraph to keep emphasis elements, text order, and element references intact.
Why one surrounding span can fail
A browser range can start inside one inline element and end outside it. For example, a selection might begin halfway through emphasized text, cross a nested element, and finish in the paragraph’s trailing text. That selection does not necessarily correspond to one complete subtree.
Range.surroundContents() cannot handle every such selection. It throws when the range partially contains a non-text node. The current DOM Standard specifies an InvalidStateError for that condition. A range containing text and fully selected elements can use this method, but crossing an element boundary requires a different plan. DOM Standard, Range: surroundContents() method
Extracting the range into one new span is another possible transformation, but it has different preservation rules. extractContents() clones partially selected ancestor elements. If your task requires the original inline elements to remain whole and retain their identities, those cloned branches are unsuitable. Range: extractContents() method
Use this decision aid before changing the document:
| Required outcome | Suitable approach |
|---|---|
| One wrapper around text and fully selected elements | Consider surroundContents() |
| Original inline elements remain whole across a partial selection | Wrap selected text fragments separately |
| One wrapper across partially selected element boundaries | Accept restructuring and review extraction/cloning behavior |
| Visual emphasis without adding elements | Reconsider whether the task needs spans at all |
The example below chooses separate wrappers. Preserving structure here means retaining the existing element objects and their ancestor relationships while adding spans beneath them. Text nodes will be split where the selection begins or ends.
Open the synthetic example in Safari on Mac
Open a new Safari tab and enter about:blank. Then choose Safari → Settings… → Advanced and enable Show features for web developers. Open Develop → Show Web Inspector, or press Option–Command–I. These are the steps documented by WebKit. Enabling Web Inspector
Select the Console tab. Run the complete JavaScript program below in its prompt. WebKit documents that the console prompt evaluates JavaScript in the inspected page’s selected execution context. Use the top-level page context for this example. Web Inspector Interface
No library, import, build step, or existing document content is required. The program creates its own paragraph, wraps a predetermined range, restores a forward selection over the wrapped text, and adds a removal button. Its supported input is deliberately narrow: one paragraph containing text and ordinary em, strong, span, or code descendants, with both range boundaries inside text nodes.
(() => {
function wrapTextRange(root, range) {
const doc = root.ownerDocument;
const startNode = range.startContainer;
const endNode = range.endContainer;
if (root.tagName !== "P" ||
startNode.nodeType !== 3 || endNode.nodeType !== 3 ||
!root.contains(startNode) || !root.contains(endNode)) {
throw new TypeError("Use one paragraph and text-node boundaries.");
}
const allowed = new Set(["EM", "STRONG", "SPAN", "CODE"]);
for (const element of root.querySelectorAll("*")) {
if (!allowed.has(element.tagName) ||
element.hasAttribute("data-range-wrap")) {
throw new TypeError("Unsupported element or existing wrappers.");
}
}
if (range.collapsed) return [];
// Snapshot every fragment before changing the live DOM range.
const jobs = [];
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!range.intersectsNode(node)) continue;
const start = node === startNode ? range.startOffset : 0;
const end = node === endNode ? range.endOffset : node.length;
if (end > start) jobs.push({ node, start, end });
}
const spans = [];
for (const { node, start, end } of jobs) {
// Split the end first so the start offset remains valid.
if (end < node.length) node.splitText(end);
const selected = start > 0 ? node.splitText(start) : node;
const span = doc.createElement("span");
span.setAttribute("data-range-wrap", "");
span.style.backgroundColor = "#fff0a6";
selected.parentNode.insertBefore(span, selected);
span.appendChild(selected);
spans.push(span);
}
return spans;
}
const panel = document.createElement("section");
const root = document.createElement("p");
const strong = document.createElement("strong");
const em = document.createElement("em");
const tail = document.createTextNode(" gamma delta.");
strong.append(document.createTextNode("bright "));
em.textContent = "blue";
strong.append(em);
root.append(document.createTextNode("Alpha "), strong, tail);
panel.append(root);
document.body.append(panel);
let clicks = 0;
strong.addEventListener("click", () => { clicks += 1; });
const originalText = root.textContent;
const range = document.createRange();
range.setStart(strong.firstChild, 3);
range.setEnd(tail, 6);
const selectedText = range.toString();
const spans = wrapTextRange(root, range);
const selection = window.getSelection();
const restored = document.createRange();
restored.setStart(spans[0].firstChild, 0);
const lastText = spans[spans.length - 1].firstChild;
restored.setEnd(lastText, lastText.length);
selection.removeAllRanges();
selection.addRange(restored);
strong.dispatchEvent(new Event("click"));
console.assert(root.textContent === originalText, "Text changed");
console.assert(strong.parentNode === root, "Strong element moved");
console.assert(em.parentNode === strong, "Emphasis element moved");
console.assert(clicks === 1, "Existing listener failed");
console.assert(spans.map(s => s.textContent).join("") === selectedText);
console.log(spans.map(s => s.textContent));
const remove = document.createElement("button");
remove.textContent = "Remove spans";
remove.addEventListener("click", () => {
selection.removeAllRanges();
for (const span of spans) {
span.replaceWith(...Array.from(span.childNodes));
}
remove.disabled = true;
});
panel.append(remove);
})();
Understand which text gets wrapped
The paragraph reads Alpha bright blue gamma delta. Its existing strong element contains bright and a nested em element containing blue. The range starts at offset 3 in bright and ends at offset 6 in gamma delta. These are illustrative inputs defined by the program.
The expected console output contains three fragments:
["ght ", "blue", " gamma"]
The first span becomes a child of the original strong element. The second becomes a child of the original em element. The third becomes a child of the paragraph. Reading their text in document order reproduces the selected string, ght blue gamma. The unselected prefix bri and suffix delta. stay outside the wrappers.
The traversal uses createTreeWalker() with NodeFilter.SHOW_TEXT to visit descendant text nodes. intersectsNode() identifies candidates, and the boundary offsets trim the first and last candidates. Zero-length fragments are excluded. Document: createTreeWalker() method, Range: intersectsNode() method
The program records all fragment boundaries before mutating anything. This matters because DOM ranges are live: splitting, inserting, or removing nodes can update their boundary points. A cloned Range would also be live; saving the node references and numeric offsets supplies the stable work list used here. DOM Standard: Ranges
For each fragment, the program splits the ending boundary first, then the starting boundary. splitText() leaves the prefix in the original text node and returns the remaining text as a sibling. This isolates the selected substring without recreating its enclosing elements. Text: splitText() method
The four fragment-planning cases—cross-element text, a substring within one node, a collapsed range, and boundaries selecting no characters—were checked deterministically in Node.js. Browser DOM mutation and Safari interaction were not executed during preparation; the program includes assertions for checking those results when run.
Preserve the right structure and lifecycle contracts
The existing strong and em elements are never removed, replaced, or cloned by this program. Their element references remain usable, and the listener attached to strong remains attached to that same object. The sample checks both the parent relationships and a synthetic event dispatch after wrapping.
Text-node identity has a different contract. Boundary splitting introduces new text nodes, and each selected text fragment moves into a new span. Any application holding text-node references must account for that change. The method preserves text content and the original element hierarchy; it does not preserve every original text-node boundary.
Adding spans also changes immediate parent relationships for text. Before adopting this transformation in an existing document, check code that expects an element’s first child to be a text node, CSS selectors that depend on direct children, and event handlers that inspect the event target. Retaining an element object alone does not establish that every surrounding application assumption still holds.
The sample explicitly rebuilds a forward selection after wrapping. It does not preserve backward selection direction or provide an editor undo transaction. Treat those as separate requirements when integrating the operation into an editing interface. The wrapper function itself returns its spans without promising a particular browser selection state.
Clicking Remove spans unwraps only the spans created during that run and clears the selection. It leaves the split text nodes separate. That removal action restores the original element hierarchy and text, but it is not an exact reversal of text-node identities. Avoid adding automatic normalization if other code depends on those references.
Adapt the example without silently expanding its scope
For a user-dragged selection, obtain its range only after checking that window.getSelection() returned a selection and that rangeCount is nonzero. getRangeAt(0) returns the first range; requesting an index outside the selection’s range count produces an error. Selection: getRangeAt() method
Pass that range to the same helper only when both boundary containers are text nodes inside the intended paragraph. A selection whose boundary container is an element is rejected before mutation. Supporting such boundaries requires an additional, explicitly defined conversion to descendant text positions; merely removing the guard does not implement it.
The helper also rejects unsupported descendants and existing marked wrappers before making changes. For repeated or overlapping annotations, first define whether new spans should nest, merge, or replace earlier ones. The sample intentionally exposes that decision instead of accumulating wrappers without a policy.
Offsets are measured in UTF-16 code units. For computed positions in text containing multi-unit characters, ensure boundaries do not divide the intended character sequence. The synthetic sample uses simple Latin text, so its numeric offsets are unambiguous. Text: splitText() method
For this task, the useful invariant is straightforward: concatenating the new spans’ text must equal the original selected string, the paragraph’s full text must stay unchanged, and the existing inline elements must retain their identities and ancestor relationships. Check all three when adapting the example; together they describe what successful preservation means.
Sources
- DOM Standard — WHATWG.
- Enabling Web Inspector — WebKit.
- Web Inspector Interface — WebKit.
- Range: surroundContents() method — MDN Web Docs.
- Range: extractContents() method — MDN Web Docs.
- Document: createTreeWalker() method — MDN Web Docs.
- Range: intersectsNode() method — MDN Web Docs.
- Text: splitText() method — MDN Web Docs.
- Selection: getRangeAt() method — MDN Web Docs.