"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()를 거름망으로 쓰는 거야. 부모가 display:block 이어도 화면 밖으로 밀려난 텍스트 노드는 겹치는 사각형이 없거든. 그런 건 그냥 건너뛰어. 그래서 남는 건 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 });
}
덕분에 피파는 user가 이 페이지에서 읽을 수 있는 것뿐 아니라 할 수 있는 것까지 알게 돼. 이미지의 alt 텍스트, 버튼의 aria-label, 입력창의 placeholder가 전부 맥락으로 들어오거든. 요소 종류별로 이름을 뽑아내는 로직은 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:scheduleContext로 350 ms 쯤 잠잠해질 때까지 기다렸다 보내. 워낙 자주 터지는 event 라 모아서 보내는 게 중요해.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 문제가 하나 있어. user가 side panel을 열려고 클릭하는 순간 골라 뒀던 게 풀려 버리는 거야. 그래서 미리 챙겨 둬:
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가 지금 읽고 있는 것으로 먹임.
isTopFrame: false를 달고 자기 맥락을 따로 올려 보내. 그럼 Lesson 2의 SW 합치기 로직이 안쪽 frame의 선택은 받아들이면서 맨 위 frame의 본문 텍스트는보존. 이렇게 YouTube embed 안 텍스트 highlight 하는 user가 여전히 Pippa 에 올바르게 닿음.