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/54 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.

길이 0 selection (cursor blink 만) 은 toString() 이 빈 문자열 반환. 항상 guard. Selection 은 user 가 다른 데 클릭하거나 스크립트가 clear 할 때까지 DOM event 너머로 유지 — mouseup handler 안에서 read 하면 reliable, 500ms 뒤 setTimeout 안에서 read 하면 보통 안 됨.

Event listening

ClipDeck 유용성 순으로 세 패턴:

  • document 위 delegation. 한 listener 를 document.addEventListener('mouseup', handler) 로 등록하고 event.target.closest(...) 로 filter. Handler 가 root 에 있어서 swappable node 가 아니라, DOM reshuffle 을 공짜로 살아남음.
  • Capture phase. addEventListener(event, fn, { capture: true }) 가 page 자체 handler 전에 핸들러 실행. Page 가 bubble phase 에서 stopPropagation 부르고 handler 가 event 못 받을 때 유용. 아껴 써 — page 전에 도는 게 event mutate 하면 page logic 깰 수 있음.
  • 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 재실행. 두 가지 포인터:

  • 가능한 좁은 subtree 관찰. document.bodysubtree: true 도 동작하지만 끊임없이 fire; 특정 container 관찰이 훨씬 저렴.
  • 이미 처리한 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 로 확인.
  • Attach 한 listener 추적해서 같은 node 에 재attach 안 함.
  • 스타일은 <style id="clipdeck-css"> 태그 하나 쓰고 새 태그 append 보다 그 textContent update.

방문하는 page 는 우리 거 아냐. Inject 된 상태에 친절할 리 없음. Idempotency 가 최소 자기방어.

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

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

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