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

Selection 과 Range — toString 너머

~12 min · selection, range, dom, deep-dive

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Selection.toString 이 텍스트 줘. Range 가 rectangle, 둘러싼 context, anchor 와 focus node, selection 을 programmatic 으로 grow / shrink 할 능력 줘. Lesson 3 가 ClipDeck 이 highlight / screenshot / 결국 둘러싼-문단 capture 에 필요한 deep cut."

Selection vs Range

두 object, 관련 있지만 distinct:

  • Selection — user 가 현재 document 에서 highlight 한 것. document 당 정확히 하나의 Selection, window.getSelection() 통해 access. 현대 browser 에서 0 또는 1 개 range 가짐 (multi-range 는 Firefox-only feature 였음).
  • Range — DOM tree 의 두 지점 사이 span. User selection 과 독립; 원하는 만큼 만들고 highlight / scroll / 측정 / 스크롤에 사용 가능.

User 의 selection 이 window.getSelection().getRangeAt(0). 그 Range 에서 ClipDeck 의 clip metadata 에 중요한 모든 것 read 가능.

Range field

  • range.startContainer / range.endContainer — selection 이 시작 / 끝나는 DOM node. 종종 Text node, 가끔 Element node.
  • range.startOffset / range.endOffset — Text node 안 문자 offset, 또는 Element node 안 child index.
  • range.commonAncestorContainer — 두 endpoint 다 포함하는 가장 낮은 DOM node. 둘러싼 문단이나 article 찾기에 유용.
  • range.collapsed — start === end 일 때 true (길이 0 selection, cursor blink).
  • range.toString()selection.toString() 과 같은 plain text.
  • range.cloneContents() — 선택 node 들이 deep-clone 된 DocumentFragment 반환. Clip 에 formatting (link / bold / code span) 보존에 사용.
  • range.getBoundingClientRect() — selection 의 visible 위치 union rect. Screenshot 과 floating UI 배치용.
  • range.getClientRects() — rect 배열, 줄 당 하나. Selection 이 여러 줄 걸칠 때 유용.

둘러싼 문단으로 grow

Default user selection 은 highlight 한 거 — 종종 mid-sentence 나 그냥 phrase. ClipDeck 이 'expand to paragraph' 를 tweak 으로 제공 가능:

function expandToParagraph(range) {
  const ancestor = range.commonAncestorContainer;
  const para = (ancestor.nodeType === 1 ? ancestor : ancestor.parentElement)
    .closest('p, li, blockquote, h1, h2, h3, h4, h5, h6, div');
  if (!para) return range;
  const newRange = document.createRange();
  newRange.selectNodeContents(para);
  return newRange;
}

'highlight 한 문장만 아닌 전체 문단 원함' 에 유용. Preview-and-confirm UI (Lesson 6) 에서 제공.

Clip 의 formatting 보존

ClipDeck v1 이 plain text 저장. Formatting 선택적으로 보존 원하면:

const fragment = range.cloneContents();
const tmp = document.createElement('div');
tmp.appendChild(fragment);
const htmlContent = tmp.innerHTML; // 저장 전 DOMPurify 통해 sanitize

text (toString 에서) 와 optional html field 둘 다 저장. Side panel 이 html 있으면 렌더; clipboard 는 항상 text 받음.

Multi-line selection bound

User 가 텍스트 여러 줄 highlight 하면, getBoundingClientRect() 가 다 감싸는 큰 rectangle 하나 반환 — 영역 screenshot 에 유용하지만 line-by-line UI 엔 부정확. getClientRects() 가 줄 당 rect 하나 반환, 정확한 highlight overlay 그릴 때 원하는 거:

const rects = Array.from(range.getClientRects());
rects.forEach((r) => drawHighlightOverlay(r));

Programmatic selection

Selection 을 programmatic 으로 설정 / 복원 (예: user 가 '이 clip 이 어디서 왔는지 보여 줘' click 후):

const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(rangeIRestored);

range.startContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }) 와 결합, user 를 clip 의 원래 위치로 teleport 하고 다시 highlight 가능. ClipDeck v2 feature.

Selection 이 user intent 줘. Range 가 geometry, 구조, 그것에 operate 할 능력 줘. 함께 단순 텍스트 grab 부터 full screenshot-with-highlight feature 까지 모든 것 cover.
Shadow DOM 예외. Isolated content script 의 window.getSelection() 이 open shadow root 안 selection 안 봄 — 그것들이 shadow root 의 getSelection() 통해 access 가능한 자체 Selection object 가짐. Closed shadow root 는 완전 opaque. ClipDeck v1 엔 제한이 충분히 드물어 defer; v2 가 web component 의존 site 위해 명시적으로 shadow root walk 할 수도.

Code

content.js — selection 도구가 text / html / rect 반환·javascript
// content.js — rect + optional HTML 가진 full selection capture
CD_TOOLS.selection = async (args = {}) => {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return { ok: false, reason: "no-selection" };
  let range = sel.getRangeAt(0);
  if (range.collapsed) return { ok: false, reason: "empty" };

  if (args.expandToParagraph) {
    range = expandToParagraph(range);
  }

  const rect = range.getBoundingClientRect();
  const lineRects = Array.from(range.getClientRects()).map((r) => ({
    top: r.top, left: r.left, width: r.width, height: r.height,
  }));

  const fragment = range.cloneContents();
  const tmp = document.createElement("div");
  tmp.appendChild(fragment);
  const html = tmp.innerHTML; // caller 가 저장 전 sanitize

  return {
    ok: true,
    selection: {
      text: range.toString(),
      html,
      rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
      lineRects,
      ancestorTag: (range.commonAncestorContainer.nodeType === 1
        ? range.commonAncestorContainer
        : range.commonAncestorContainer.parentElement
      )?.tagName?.toLowerCase() ?? null,
    },
  };
};

function expandToParagraph(range) {
  const ancestor = range.commonAncestorContainer;
  const para = (ancestor.nodeType === 1 ? ancestor : ancestor.parentElement)
    .closest("p, li, blockquote, h1, h2, h3, h4, h5, h6, div");
  if (!para) return range;
  const newRange = document.createRange();
  newRange.selectNodeContents(para);
  return newRange;
}
content.js — clip provenance 위한 restore + scroll-into-view skeleton·javascript
// content.js — 저장된 selection 복원 + scroll-to
// (range 의 serializable 표현 ship 하고 rehydrate.)
function restoreSelection(serialized) {
  // serialized = { startContainerPath, startOffset, endContainerPath, endOffset }
  const start = nodeFromPath(serialized.startContainerPath);
  const end = nodeFromPath(serialized.endContainerPath);
  if (!start || !end) return false;
  const range = document.createRange();
  range.setStart(start, serialized.startOffset);
  range.setEnd(end, serialized.endOffset);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
  start.parentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
  return true;
}

// 'nodeFromPath' 가 indexed step list 로 DOM walk — serialize 방식에 따라
// 구현 다름. 흔한 접근: document.body 에서 child index 배열. Heavily-dynamic
// SPA 엔 brittle; 안정적 page 에선 잘 동작.

External links

Exercise

첫 번째 code block (CD_TOOLS.selection + expandToParagraph) 을 clipdeck/content.js 에 추가. Reload. Wikipedia article 에서 문단 가운데 문장 highlight. Content script DevTools console 에서 chrome.runtime.sendMessage({type:'tool', name:'selection', args:{}}) 타이핑하고 응답 inspect — text, html, rect, lineRects populate 된 거 보임. args:{expandToParagraph:true} 로 재시도 — text 가 이제 전체 문단 cover 해야 함. One-line vs multi-line highlight 의 lineRects count 비교; multi-line 이 더 많은 rect 반환, Lesson 4 의 screenshot crop 에 유용.
Hint
cloneContents() 가 빈 fragment 반환하면, selection 이 비었거나 range 가 collapse 됨; sel.rangeCountrange.collapsed 먼저 확인. expandToParagraph 가 같은 range 반환하면, commonAncestorContainer 에 <p> ancestor 없음 — page 의 실제 <p> element 안에서 선택 시도. 두 번째 block 의 serialize/restore 코드는 예시; production 코드는 적당한 DOM 변경 살아남는 nodeFromPath 구현 필요 — XPath 가진 document.evaluate 가 robust option 하나.

Progress

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

댓글 0

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

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