본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

DOM access — Selection, listener, MutationObserver

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

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Content script가 DOM을 가졌어. Lesson 4는 모든 feature에서 반복할 네 가지 동작: node 찾기, selection read, event listen, SPA reshuffle 살아남기."

Node 찾기

Content script는 DOM API 전체 가짐 — 어떤 web page 든 가진 그것. 끊임없이 손이 가는 네 가지:

  • document.querySelector(selector) — 첫 매칭, 또는 null.
  • document.querySelectorAll(selector) — 매칭 전체의 NodeList (snapshot, live 아냐).
  • document.getElementById(id) — 알려진 id의 가장 빠른 경로.
  • node.closest(selector) — 부모 매칭될 때까지 tree 위로. click target이 안쪽 span 인데 row가 필요한 event delegation에 필수.

CSS-selector syntax. 스타일이 쓰는 같은 거. jQuery 필요 없음. querySelector가 div.row[data-id="42"] > button:not(:disabled)를 직접 처리.

Selection read

Selection API가 ClipDeck Track 3 milestone이 올라타는 거. window.getSelection()이 document의 현재 highlight 텍스트 기술하는 Selection object 반환. 쓸 두 method:

  • selection.toString() — highlight 된 range의 plain text.
  • selection.getRangeAt(0) — anchor/focus node, start/end offset, highlight 근처 floating UI 위치 잡는 getBoundingClientRect() 가진 Range object.

아무것도 안 고른 상태 (커서만 깜박이는 경우) 면 toString()이 빈 문자열을 줘. 그러니 항상 걸러 줘. 선택은 user가 다른 데를 누르거나 스크립트가 지우기 전까지는 event를 넘어서도 남아 있어. 그래서 mouseup handler 안에서 읽으면 믿을 만한데, 500ms 뒤 setTimeout 안에서 읽으면 대개 이미 없어.

Event listening

ClipDeck 유용성 순으로 세 패턴:

  • document에 몰아서 걸기. listener 하나를 document.addEventListener('mouseup', handler)로 걸어 두고 event.target.closest(...)로 걸러 내. handler가 뿌리에 붙어 있으니까 갈려 나갈 노드에 매이지 않아서, DOM이 아무리 갈아엎여도 공짜로 살아남아.
  • 내려갈 때 잡기. addEventListener(event, fn, { capture: true })로 걸면 페이지 자기 handler 보다 먼저 돌아. 페이지가 올라오는 단계에서 stopPropagation을 불러 버려서 우리 handler까지 event가 안 올 때 쓸모 있어. 대신 아껴 써. 페이지보다 먼저 돌면서 event를 건드리면 페이지 로직을 깨뜨릴 수 있거든.
  • Passive listener. scroll / touchmove{ passive: true } — 실수로 preventDefault 못 부르게. Chrome 한테 cancellation check 건너뛰고 스크롤 부드럽게 유지하라고 알려 줘.

MutationObserver — SPA reshuffle 살아남기

Single-page app (Gmail, Twitter/X, YouTube, React 적인 거 전부)가 user navigate 할 때 DOM의 큰 부분을 tear down 하고 rebuild. Load 시점에 header에 inject 한 버튼은 framework가 re-render 하는 순간 사라져.

Fix는 MutationObserver. Reshuffle 일어나는 영역 구독, 관련 변화 도착할 때마다 injection logic 재실행. 두 가지 포인터:

  • 지켜볼 범위는 최대한 좁게 잡아. document.bodysubtree: true를 걸어도 돌아가긴 하는데 쉴 새 없이 울려. 특정 컨테이너 하나만 보는 게 훨씬 싸.
  • 이미 처리한 node 알기 위해 marker로 data-* attribute 사용. if (node.dataset.clipdeckHandled) return; node.dataset.clipdeckHandled = '1'; — idempotent injection.

Idempotency 습관

Inject 하는 것 — 버튼, 스타일시트, event listener — 무엇이든 스크립트가 두 번 돌 거라 가정: 첫 load 한 번, MutationObserver tick 후 한 번, 가끔 tab restore 후 세 번째. 모든 insertion을 반복해도 안전하게:

  • Node 삽입 전 id 나 marker attribute로 확인.
  • 이미 붙인 listener를 기억해 뒀다가 같은 노드에 또 붙이지 마.
  • 스타일은 <style id="clipdeck-css"> 태그 하나 쓰고 새 태그 append 보다 그 textContent update.

우리가 들어가는 페이지는 우리 것이 아니야. 우리가 심어 둔 것들을 곱게 대해 줄 이유도 없고. 몇 번을 돌려도 같은 결과가 나오게 짜 두는 게 최소한의 자기방어야.

Node 찾고, selection read 하고, document에서 listen 하고, reshuffle 살아남기. 대부분의 content-script 버그가 이 네 가지 중 하나 위반에서 옴 — 보통 마지막.
iframe 안 selection. window.getSelection() document의 selection만 봐. User가 YouTube 댓글 (same-origin iframe 안에 사는) 이나 cross-origin embed 안 텍스트 highlight 하면, top-level content script는 아무것도 read 안 함. iframe URL을 matches에 추가하고 manifest에서 all_frames: true 설정하든가, nested-frame selection은 scope 밖이라고 받아들이든가. ClipDeck v1은 안 쫓아. v2는 그럴 수도.

Code

mouseup 시 selection capture — 텍스트 + bounding rect read·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 가 이 log 를 save action 으로 바꿈.
});
SPA re-render 살아남는 idempotent 버튼 injection·javascript
// content.js — MutationObserver-friendly 버튼 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 키보드 listener — page 보다 먼저 event 잡기·javascript
// content.js — stopPropagation 하는 site 위한 capture-phase listener
// 예: page 정의 handler 가 먹기 전에 키보드 단축키 잡기.
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);
      // 여기서 ClipDeck save action trigger.
    }
  },
  { capture: true }
);

External links

Exercise

clipdeck/content.js를 두 번째 code block (idempotent 버튼 injection)으로 교체. Extension reload. 세 page — wikipedia article, github repository view, youtube video — 열기. 각각의 우상단 floating 📎 Save to ClipDeck 버튼 찾기. youtube에서 home page에서 video로, 다시 home으로 navigate. 버튼이 SPA transition에서 사라져? MutationObserver 덕에 한 tick 안에 다시 나타나야 함. 이제 wikipedia article에서 텍스트 selection 하고 버튼 클릭 — SW DevTools console 열어 saveClip 메시지 도착 확인 (SW handler는 Lesson 6에서 wire).
Hint
버튼이 안 나타나면 manifest의 content_scripts.matches에 <all_urls> 아직 있고 content.js 편집 후 extension reload 했는지 확인. 버튼이 나타났는데 SPA transition에서 영구히 사라지면 MutationObserver가 fire 안 하는 것 — 파일 맨 아래에 observer.observe(document.body, { childList: true, subtree: true }) 있는지 확인. 버튼이 page 자체 UI와 시각적으로 충돌하면 z-index 더 높이거나 우하단으로 옮겨. ClipDeck의 최종 design은 매 page의 fixed 버튼 아닌 작은 unobtrusive corner badge 사용.

Progress

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

댓글 0

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

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