C.W.K.
Stream
Lesson 04 of 06 · published

DOM Access — Selection, Listeners, MutationObserver

~14 min · dom, selection, events, mutation-observer, spa

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The content script has the DOM. Lesson 4 is the four moves you'll repeat for every feature: find the node, read the selection, listen for the event, survive the SPA reshuffle."

Finding Nodes

Content scripts have the full DOM API — the same one any web page has. The four you'll reach for constantly:

  • document.querySelector(selector) — first match, or null.
  • document.querySelectorAll(selector) — NodeList of all matches (snapshot, not live).
  • document.getElementById(id) — fastest path for known ids.
  • node.closest(selector) — walk up the tree until a parent matches. Crucial for event delegation when the click target is some inner span and you want the row.

CSS-selector syntax. The same one your styles use. No jQuery needed; querySelector handles div.row[data-id="42"] > button:not(:disabled) directly.

Reading the Selection

The Selection API is what ClipDeck's Track 3 milestone rides on. window.getSelection() returns a Selection object describing the currently-highlighted text in the document. The two methods you'll use:

  • selection.toString() — plain text of the highlighted range.
  • selection.getRangeAt(0) — a Range object with anchor/focus nodes, start/end offsets, and getBoundingClientRect() for positioning a floating UI near the highlight.

A selection of zero length (just a cursor blink) returns an empty string from toString(). Always guard. The selection persists across DOM events until the user clicks elsewhere or the script clears it — so reading it inside a mouseup handler is reliable, reading it inside a setTimeout 500ms later usually is not.

Listening for Events

Three patterns, ranked by ClipDeck usefulness:

  • Delegation on document. Register one listener at document.addEventListener('mouseup', handler) and use event.target.closest(...) to filter. Survives DOM reshuffles for free because the handler is on the root, not on any swappable node.
  • Capture phase. addEventListener(event, fn, { capture: true }) runs your handler before the page's own handlers. Useful when the page calls stopPropagation on the bubble phase and your handler never gets the event otherwise. Use sparingly — running before the page can break the page's logic if you mutate the event.
  • Passive listeners. { passive: true } for scroll / touchmove so you can't accidentally call preventDefault. Tells Chrome it can skip the cancellation check and keep scrolling smooth.

MutationObserver — Surviving SPA Reshuffles

Single-page apps (Gmail, Twitter/X, YouTube, anything React-y) tear down and rebuild large portions of the DOM as the user navigates. A button you injected into the header at load time will be gone the moment the framework re-renders.

The fix is MutationObserver. Subscribe to the area that gets reshuffled, and re-run your injection logic each time a relevant change lands. Two pointers:

  • Observe as narrow a subtree as you can. document.body with subtree: true works but fires constantly; observing a specific container is much cheaper.
  • Use a data-* attribute as a marker so you can tell which nodes you already processed. if (node.dataset.clipdeckHandled) return; node.dataset.clipdeckHandled = '1'; — idempotent injection.

The Idempotency Habit

Whatever you inject — a button, a stylesheet, an event listener — assume your script will run twice: once on initial load, once after a MutationObserver tick, occasionally a third time after a tab restore. Make every insertion safe to repeat:

  • Check by id or marker attribute before inserting nodes.
  • Track listeners you've attached and don't re-attach to the same node.
  • For styles, use a single <style id="clipdeck-css"> tag and update its textContent rather than appending new tags.

The pages you visit are not yours. They will not be polite about your injected state. Idempotency is the bare minimum self-defense.

Find the node, read the selection, listen on document, survive the reshuffle. Most content-script bugs trace back to violating one of these four — usually the last.
Selection inside iframes. window.getSelection() only sees selections in your document. If the user highlighted text inside a YouTube comment (which lives in a same-origin iframe) or a cross-origin embed, your top-level content script reads nothing. Add the iframe URLs to matches and set all_frames: true in the manifest, or accept that nested-frame selections are out of scope. ClipDeck v1 doesn't chase them; v2 might.

Code

Selection capture on mouseup — read text + bounding rect·javascript
// content.js — selection capture skeleton
function readSelection() {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return null;
  const text = selection.toString();
  if (!text.trim()) return null;
  const range = selection.getRangeAt(0);
  const rect = range.getBoundingClientRect();
  return { text, rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height } };
}

document.addEventListener("mouseup", () => {
  const result = readSelection();
  if (!result) return;
  console.log("[ClipDeck content] selection:", result.text);
  console.log("[ClipDeck content] at:", result.rect);
  // Track 3 lesson 6 will turn this log into a save action.
});
Idempotent button injection that survives SPA re-renders·javascript
// content.js — MutationObserver-friendly button injection
const BUTTON_ID = "clipdeck-save-btn";
const BUTTON_MARKER = "data-clipdeck-handled";

function injectButtonIfMissing() {
  if (document.getElementById(BUTTON_ID)) return;

  const host = document.querySelector("header") || document.body;
  if (host.hasAttribute(BUTTON_MARKER)) return;
  host.setAttribute(BUTTON_MARKER, "1");

  const btn = document.createElement("button");
  btn.id = BUTTON_ID;
  btn.textContent = "📎 Save to ClipDeck";
  btn.style.cssText = "position:fixed;top:8px;right:8px;z-index:2147483647;padding:6px 10px;";
  btn.addEventListener("click", () => {
    const sel = window.getSelection()?.toString();
    if (!sel) return alert("Select some text first.");
    chrome.runtime.sendMessage({ type: "saveClip", payload: { text: sel, url: location.href } });
  });
  host.appendChild(btn);
}

injectButtonIfMissing();

const observer = new MutationObserver(() => injectButtonIfMissing());
observer.observe(document.body, { childList: true, subtree: true });
Capture-phase keyboard listener — beat the page to the event·javascript
// content.js — capture-phase listener for sites that stopPropagation
// Example: catch a keyboard shortcut before a page-defined handler eats it.
document.addEventListener(
  "keydown",
  (event) => {
    const isMac = navigator.platform.toUpperCase().includes("MAC");
    const meta = isMac ? event.metaKey : event.ctrlKey;
    if (meta && event.shiftKey && event.key.toLowerCase() === "k") {
      event.preventDefault();
      event.stopPropagation();
      console.log("[ClipDeck content] hotkey caught:", event.key);
      // Trigger ClipDeck save action here.
    }
  },
  { capture: true }
);

External links

Exercise

Replace clipdeck/content.js with the second code block (the idempotent button injection). Reload the extension. Open three different pages — a wikipedia article, a github repository view, and a youtube video. On each, look for the floating 📎 Save to ClipDeck button in the top-right. On youtube, navigate from the home page to a video and back. Does the button vanish on the SPA transition? It should reappear within a tick thanks to the MutationObserver. Now select some text on the wikipedia article and click the button — open the SW DevTools console and confirm a saveClip message arrived (we'll wire the SW handler in Lesson 6).
Hint
If the button never appears, check that <all_urls> is still in your manifest's content_scripts.matches and that you reloaded the extension after editing content.js. If the button appears but vanishes forever on the SPA transition, MutationObserver isn't firing — confirm observer.observe(document.body, { childList: true, subtree: true }) is at the bottom of the file. If your button collides visually with the page's own UI, bump the z-index higher or move it to bottom-right; ClipDeck's eventual design uses a small unobtrusive corner badge, not a fixed button on every page.

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.