C.W.K.
Stream
Lesson 03 of 07 · published

Selection and Range — Beyond toString

~12 min · selection, range, dom, deep-dive

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Selection.toString gets you the text. Range gets you the rectangles, the surrounding context, the anchor and focus nodes, and the ability to grow or shrink the selection programmatically. Lesson 3 is the deep cuts ClipDeck needs for highlights, screenshots, and the eventual surrounding-paragraph capture."

Selection vs Range

Two objects, related but distinct:

  • Selection — what the user currently has highlighted in the document. There is exactly one Selection per document, accessed via window.getSelection(). Has 0 or 1 ranges in modern browsers (multi-range was a Firefox-only feature).
  • Range — a span between two points in the DOM tree. Independent of user selection; you can create as many as you want and use them for highlighting, scrolling, measuring, or scrolling.

The user's selection is window.getSelection().getRangeAt(0). From that Range you can read everything that matters for ClipDeck's clip metadata.

The Range Fields

  • range.startContainer / range.endContainer — the DOM nodes where the selection begins and ends. Often Text nodes, sometimes Element nodes.
  • range.startOffset / range.endOffset — character offsets within Text nodes, or child indices within Element nodes.
  • range.commonAncestorContainer — the lowest DOM node that contains both endpoints. Useful for finding the surrounding paragraph or article.
  • range.collapsed — true when start === end (zero-length selection, a cursor blink).
  • range.toString() — the same plain text as selection.toString().
  • range.cloneContents() — returns a DocumentFragment with the selected nodes deep-cloned. Use to preserve formatting (links, bold, code spans) in the clip.
  • range.getBoundingClientRect() — the union rect of the selection's visible position. For screenshots and floating UI placement.
  • range.getClientRects() — array of rects, one per line. Useful when the selection spans multiple lines.

Grow to Surrounding Paragraph

The default user selection is whatever they highlighted — often mid-sentence or just a phrase. ClipDeck can offer 'expand to paragraph' as a tweak:

function expandToParagraph(range) {
  const ancestor = range.commonAncestorContainer;
  const para = (ancestor.nodeType === 1 ? ancestor : ancestor.parentElement)
    .closest('p, li, blockquote, h1, h2, h3, h4, h5, h6, div');
  if (!para) return range;
  const newRange = document.createRange();
  newRange.selectNodeContents(para);
  return newRange;
}

Useful for 'I want the whole paragraph, not just the sentence I highlighted.' Offer in the preview-and-confirm UI (Lesson 6).

Preserve Formatting in the Clip

ClipDeck v1 stores plain text. If you want to optionally preserve formatting:

const fragment = range.cloneContents();
const tmp = document.createElement('div');
tmp.appendChild(fragment);
const htmlContent = tmp.innerHTML; // sanitized via DOMPurify before storing

Store both text (from toString) and an optional html field. The side panel renders html when present; the clipboard always gets text.

Multi-Line Selection Bounds

When the user highlights several lines of text, getBoundingClientRect() returns one large rectangle wrapping all of them — useful for screenshotting a region but inaccurate for line-by-line UI. getClientRects() returns one rect per line, which is what you want if you're drawing an exact highlight overlay:

const rects = Array.from(range.getClientRects());
rects.forEach((r) => drawHighlightOverlay(r));

Programmatic Selection

To programmatically set or restore a selection (e.g., after the user clicks 'Show me where this clip came from'):

const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(rangeIRestored);

Combined with range.startContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }), you can teleport the user to a clip's original location and re-highlight it. ClipDeck v2 feature.

Selection gives you the user's intent. Range gives you the geometry, the structure, and the ability to operate on it. Together they cover everything from a simple text grab to a full screenshot-with-highlight feature.
The Shadow DOM exception. window.getSelection() in an isolated content script does NOT see selections inside open shadow roots — those have their own Selection objects accessible via the shadow root's getSelection(). Closed shadow roots are entirely opaque. For ClipDeck v1, the limitation is rare enough to defer; v2 might walk shadow roots explicitly for sites that lean on web components.

Code

content.js — selection tool returns text, html, and rects·javascript
// content.js — full selection capture with rects + optional HTML
CD_TOOLS.selection = async (args = {}) => {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return { ok: false, reason: "no-selection" };
  let range = sel.getRangeAt(0);
  if (range.collapsed) return { ok: false, reason: "empty" };

  if (args.expandToParagraph) {
    range = expandToParagraph(range);
  }

  const rect = range.getBoundingClientRect();
  const lineRects = Array.from(range.getClientRects()).map((r) => ({
    top: r.top, left: r.left, width: r.width, height: r.height,
  }));

  const fragment = range.cloneContents();
  const tmp = document.createElement("div");
  tmp.appendChild(fragment);
  const html = tmp.innerHTML; // caller sanitizes before storing

  return {
    ok: true,
    selection: {
      text: range.toString(),
      html,
      rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
      lineRects,
      ancestorTag: (range.commonAncestorContainer.nodeType === 1
        ? range.commonAncestorContainer
        : range.commonAncestorContainer.parentElement
      )?.tagName?.toLowerCase() ?? null,
    },
  };
};

function expandToParagraph(range) {
  const ancestor = range.commonAncestorContainer;
  const para = (ancestor.nodeType === 1 ? ancestor : ancestor.parentElement)
    .closest("p, li, blockquote, h1, h2, h3, h4, h5, h6, div");
  if (!para) return range;
  const newRange = document.createRange();
  newRange.selectNodeContents(para);
  return newRange;
}
content.js — restore + scroll-into-view skeleton for clip provenance·javascript
// content.js — restore + scroll-to a saved selection
// (You'd ship a serializable representation of the range and rehydrate it.)
function restoreSelection(serialized) {
  // serialized = { startContainerPath, startOffset, endContainerPath, endOffset }
  const start = nodeFromPath(serialized.startContainerPath);
  const end = nodeFromPath(serialized.endContainerPath);
  if (!start || !end) return false;
  const range = document.createRange();
  range.setStart(start, serialized.startOffset);
  range.setEnd(end, serialized.endOffset);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
  start.parentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
  return true;
}

// 'nodeFromPath' walks the DOM by indexed step list — implementation depends
// on how you serialize. Common approach: an array of child indices from
// document.body. Brittle on heavily-dynamic SPAs; works well on stable pages.

External links

Exercise

Add the first code block (CD_TOOLS.selection + expandToParagraph) to clipdeck/content.js. Reload. On a Wikipedia article, highlight a sentence in the middle of a paragraph. In the content script DevTools console, type chrome.runtime.sendMessage({type:'tool', name:'selection', args:{}}) and inspect the response — you see text, html, rect, and lineRects populated. Try again with args:{expandToParagraph:true} — the text should now cover the whole paragraph. Compare the lineRects count for a one-line vs multi-line highlight; multi-line returns more rects, useful for the Lesson 4 screenshot crop.
Hint
If cloneContents() returns an empty fragment, the selection is empty or the range was collapsed; check sel.rangeCount and range.collapsed first. If expandToParagraph returns the same range, the commonAncestorContainer has no <p> ancestor — try selecting inside an actual <p> element on the page. The serialize/restore code in the second block is illustrative; production code needs an nodeFromPath implementation that survives moderate DOM changes — document.evaluate with XPath is one robust option.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.