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

Anchor 3 — Content Script as the Page Sensor

~13 min · content-script, dom, viewport, case-study

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed's content-script.js walks the DOM, picks the text inside the viewport, layers in the form controls and ARIA labels, and ships it to the SW on every meaningful event. Lesson 3 is reading that 220-line file with fresh eyes after seven tracks of foundation."

The Walker Pattern

The core extraction loop:

const walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_TEXT,
  {
    acceptNode(node) {
      if (!inlineText(node.textContent)) return NodeFilter.FILTER_REJECT;
      if (!isVisibleElement(node.parentElement)) return NodeFilter.FILTER_REJECT;
      return NodeFilter.FILTER_ACCEPT;
    }
  }
);

TreeWalker visits text nodes only. For each, the acceptNode filter drops nodes whose parent is hidden (display:none, visibility:hidden, etc.) and nodes that are blank. The walker is wrapped in a budget: MAX_VISIBLE_TEXT_NODES = 900, MAX_TEXT_NODE_VISITS = 20000. The cap prevents pathological pages (10MB of text) from freezing the content script.

The Visibility-Rect Check

After the walker accepts a node, the script checks whether its bounding rect actually intersects the viewport:

function visibleTextRect(textNode) {
  const range = document.createRange();
  try {
    range.selectNodeContents(textNode);
    for (const rect of range.getClientRects()) {
      if (rectIntersectsViewport(rect)) return rect;
    }
  } finally {
    range.detach?.();
  }
  return null;
}

This is Track 7 Lesson 3's getClientRects() applied as a filter. A text node with a parent that's display:block but scrolled off-screen has no intersecting rect; it gets skipped. The result: only text the user can actually see right now.

The Control Layer

Beyond text content, the script captures form controls and ARIA-labeled elements:

const selector = 'input, textarea, select, img[alt], [role="button"], [aria-label]';
for (const element of document.querySelectorAll(selector)) {
  if (!isVisibleElement(element)) continue;
  const rect = element.getBoundingClientRect();
  if (!rectIntersectsViewport(rect)) continue;
  const text = controlLabel(element);
  if (!text) continue;
  entries.push({ top: rect.top, left: rect.left, text });
}

This is what makes Pippa aware of what the user can do on the page, not just what they can read. An image's alt text, a button's aria-label, an input's placeholder — all become part of the context. The label-extraction logic per element type is in controlLabel().

The Spatial Sort

Once entries are collected, they're sorted top-to-bottom, left-to-right:

entries.sort((a, b) => (a.top - b.top) || (a.left - b.left));

This recovers reading order from a flat collection of text fragments. Modern pages have wildly out-of-source-order layouts (CSS Grid, Flexbox with reverse), but the visual top-to-bottom order is what the user perceives. Sorting by rect coordinates respects the visual order, not the DOM order.

The Throttle

Three event listeners trigger context updates:

  • scroll — throttled via scheduleContext with a 350 ms quiet period. Heavy event; batching matters.
  • selectionchange, mouseup, pointerup, keyup, touchend — selection-related; scheduleSelectionContext fires immediately on a 0-ms setTimeout (microtask) so the selection arrives at the panel as close to real-time as possible.
  • focus — window regained focus; re-snapshot.
  • Initial call — sendContext() at the end of the file. Captures the moment-of-injection state.

The throttle on scroll vs immediate on selection is the right trade. Scroll fires hundreds of times during a single drag; selection fires only when the user actually highlights. Different cadences, different handlers.

The Selection Cache

A subtle UX detail: selections often disappear as the user clicks toward the side panel to open it. The cache:

function currentSelectionText() {
  const current = truncate(window.getSelection?.().toString() || '', MAX_SELECTION_CHARS);
  if (current) {
    lastSelection = current;
    lastSelectionAt = Date.now();
    return current;
  }
  if (lastSelection && Date.now() - lastSelectionAt <= SELECTION_CACHE_MS) {
    return lastSelection;
  }
  return '';
}

If the live selection is empty but one was captured within the last 5 minutes, return the cached version. The user thinks 'I highlighted that and asked Pippa about it' — they don't think 'oh, the click-to-open-panel cleared the selection.' The cache makes the experience match the intent.

What the SW Receives

One payload per sendContext() call, of shape detailed in Lesson 6. The content script is the sensor; the SW is the bus; the panel is the consumer. Each layer does one job; together they keep cwkPippa fed with what the user is reading right now.

Walker + visibility filter + control labels + spatial sort + throttle + selection cache. Six ideas, one 220-line file. The result is 'what the user is actually looking at' as a JSON payload Pippa can read.
Why all_frames in the manifest matters here. The content script runs in every frame; sub-frame instances send their own context with isTopFrame: false. The SW's merge logic (Lesson 2) preserves the top-frame's bulk text while merging sub-frame selections. This way a user highlighting text inside a YouTube embed still reaches Pippa correctly.

Code

content-script.js — event wiring with two throttle strategies·javascript
// embeds/chrome/content-script.js — the throttled context push loop
const QUIET_MS = 350;
let timer = null;

function scheduleContext() {
  if (timer) window.clearTimeout(timer);
  timer = window.setTimeout(() => {
    timer = null;
    sendContext();
  }, QUIET_MS);
}

function scheduleSelectionContext() {
  window.setTimeout(sendContext, 0);  // immediate for selection events
}

window.addEventListener('scroll', scheduleContext, { passive: true });
document.addEventListener('selectionchange', scheduleSelectionContext, true);
document.addEventListener('mouseup', scheduleSelectionContext, true);
document.addEventListener('pointerup', scheduleSelectionContext, true);
document.addEventListener('keyup', scheduleSelectionContext, true);
document.addEventListener('touchend', scheduleSelectionContext, true);
window.addEventListener('focus', scheduleContext);
sendContext();  // initial snapshot

External links

Exercise

Read the full embeds/chrome/content-script.js (220 lines). Identify each of the six ideas — walker, visibility filter, control labels, spatial sort, throttle, selection cache — in the actual code. Try one modification: increase QUIET_MS to 2000 and reload. Scroll a long page; notice the context now updates much more slowly (and the panel feels laggy). The constant is one of the rare numbers you'd actually tune per host context: lower for snappy reading flows, higher for battery-conscious always-on operation.
Hint
If you can't find the cache logic, look for lastSelection and lastSelectionAt — they're module-scope variables, no fancy abstraction. The selection cache is the kind of code that looks trivial when you read it but matters because real users would otherwise lose their selection on the click-to-open-panel action. After the QUIET_MS bump, restore it; the 350 ms value is roughly the right sweet spot for ChromeEmbed's intended use.

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.