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

Anchor 3 — Content script 가 page sensor

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

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed 의 content-script.js 가 DOM walk, viewport 안 텍스트 picking, form control 과 ARIA label layer, 매 의미 있는 event 마다 SW 로 ship. Lesson 3 가 일곱 track 의 foundation 후 fresh eye 로 그 220-줄 file read 하기."

Walker 패턴

Core 추출 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 가 text node 만 visit. 각자에 acceptNode filter 가 parent 가 hidden (display:none, visibility:hidden 등) 인 node 와 blank 인 node drop. Walker 가 budget 으로 wrap: MAX_VISIBLE_TEXT_NODES = 900, MAX_TEXT_NODE_VISITS = 20000. Cap 이 병적 page (10MB 텍스트) 가 content script freeze 안 시키게 방지.

Visibility-rect 확인

Walker 가 node accept 후 script 가 bounding rect 가 실제로 viewport 와 intersect 하는지 확인:

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;
}

이게 Track 7 Lesson 3 의 getClientRects() 가 filter 로 적용된 것. Parent 가 display:block 이지만 화면 밖으로 scroll 된 text node 가 intersecting rect 없음; skip 됨. 결과: user 가 지금 실제로 볼 수 있는 텍스트만.

Control layer

텍스트 content 너머, script 가 form control 과 ARIA-label 된 element capture:

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 });
}

이게 Pippa 가 user 가 page 에서 read 할 수 있는 것만 아닌 do 할 수 있는 것도 aware 하게. 이미지의 alt 텍스트, 버튼의 aria-label, input 의 placeholder — 모두 context 일부 됨. Element type 별 label-추출 logic 이 controlLabel() 에.

Spatial sort

Entry 가 모인 후 위에서 아래, 왼쪽에서 오른쪽으로 sort:

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

이게 flat 한 텍스트 fragment 모음에서 읽기 순서 회복. 현대 page 가 source 순서 wildly 벗어난 layout (CSS Grid, reverse 가진 Flexbox) 가지지만, visual 위-아래 순서가 user 가 perceive 하는 것. Rect 좌표로 sort 가 DOM 순서 아닌 visual 순서 존중.

Throttle

세 event listener 가 context update trigger:

  • scroll — 350 ms quiet period 가진 scheduleContext 통해 throttled. 무거운 event; batching 중요.
  • selectionchange, mouseup, pointerup, keyup, touchend — selection 관련; scheduleSelectionContext 가 0-ms setTimeout (microtask) 으로 즉시 fire, selection 이 panel 에 가능한 한 real-time 가깝게 도착.
  • focus — window 가 focus 되찾음; re-snapshot.
  • 초기 호출 — file 끝의 sendContext(). Injection 순간 상태 capture.

Scroll 에 throttle vs selection 에 즉시 가 맞는 거래. Scroll 이 single drag 동안 수백 번 fire; selection 이 user 가 실제로 highlight 할 때만 fire. 다른 cadence, 다른 handler.

Selection cache

미묘한 UX detail: user 가 side panel 열기 위해 click 하면서 selection 종종 사라짐. 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 '';
}

Live selection 이 비었지만 지난 5 분 안에 capture 된 거 있으면 cached version 반환. User 가 '그거 highlight 하고 Pippa 한테 물었음' 생각 — 'oh, panel 여는 click 이 selection clear 함' 안 생각. Cache 가 경험을 intent 와 매칭.

SW 가 받는 것

sendContext() 호출 당 payload 하나, Lesson 6 에서 자세히 설명된 shape. Content script 가 sensor; SW 가 bus; panel 이 consumer. 각 layer 가 한 job; 함께 cwkPippa 를 user 가 지금 읽고 있는 것으로 먹임.

Walker + visibility filter + control label + spatial sort + throttle + selection cache. 여섯 idea, 한 220-줄 file. 결과는 Pippa 가 read 할 수 있는 JSON payload 로의 'user 가 실제로 보는 것'.
여기서 manifest 의 all_frames 가 중요한 이유. Content script 가 모든 frame 에서 돔; sub-frame instance 가 isTopFrame: false 와 자체 context 보냄. SW 의 merge logic (Lesson 2) 가 sub-frame selection merge 하면서 top-frame 의 bulk 텍스트 보존. 이렇게 YouTube embed 안 텍스트 highlight 하는 user 가 여전히 Pippa 에 올바르게 닿음.

Code

content-script.js — 두 throttle 전략 가진 event wiring·javascript
// embeds/chrome/content-script.js — 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);  // selection event 엔 즉시
}

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();  // 초기 snapshot

External links

Exercise

Full embeds/chrome/content-script.js (220 줄) read. 실제 코드에서 여섯 idea — walker / visibility filter / control label / spatial sort / throttle / selection cache — 각각 식별. 한 가지 수정 시도: QUIET_MS 를 2000 으로 증가시키고 reload. 긴 page scroll; context 가 이제 훨씬 더 천천히 update (panel 이 laggy 느낌) 주목. 상수가 host context 별 실제 tune 할 드문 숫자 하나: snappy 읽기 flow 엔 낮게, battery-conscious 항상-on operation 엔 높게.
Hint
Cache logic 못 찾으면, lastSelectionlastSelectionAt 찾기 — module-scope 변수, fancy 추상화 없음. Selection cache 가 read 할 때 사소해 보이지만 실제 user 가 click-to-open-panel action 에 selection 잃을 수 있어서 중요한 종류의 코드. QUIET_MS bump 후 복원; 350 ms 값이 ChromeEmbed 의 의도된 사용에 대략 맞는 sweet spot.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.