"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)— aRangeobject with anchor/focus nodes, start/end offsets, andgetBoundingClientRect()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 atdocument.addEventListener('mouseup', handler)and useevent.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 callsstopPropagationon 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 }forscroll/touchmoveso you can't accidentally callpreventDefault. 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.bodywithsubtree: trueworks 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 itstextContentrather 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.
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.