주석을 DOM Range에 고정한 다음, 렌더링된 조각 사각형을 사용해 레이블을 배치합니다. 이 가이드에서는 문구가 인라인 요소를 가로질러 여러 줄로 줄바꿈되어도 메모가 계속 붙어 있는 간단한 Mac 데스크톱 브라우저 데모를 보여 줍니다.

여기서 할 일은 명확합니다. 일반적인 웹페이지 레이아웃에서 텍스트를 다시 구성하지 않고 문구 하나에 표시되는 주석 하나를 붙입니다. 문구를 찾고, 레이블을 놓을 조각을 선택한 다음, 레이아웃이 바뀐 뒤 다시 측정합니다. MDN은 Range.getClientRects()를 범위가 차지하는 사각형을 가져오는 브라우저 API로 설명합니다. Range: getClientRects() method.

줄바꿈된 텍스트에서 레이블이 어긋나는 이유는 무엇인가요?

문구에는 서로 관련된 두 가지 표현이 있습니다. 문서 안의 위치와 화면에 표시되는 현재 기하 정보입니다. WHATWG는 범위를 각각 노드와 오프셋으로 이루어진 시작 및 끝 경계점으로 정의합니다. 이 끝점은 서로 다른 텍스트 노드에 있을 수 있으므로, 하나의 범위가 기존 강조 요소를 가로지를 수 있습니다. DOM Standard.

하나의 경계 사각형만으로는 세부 정보가 사라집니다. 문구가 한 줄의 중간에서 시작해 다음 줄의 앞부분에서 끝나는 경우를 생각해 보세요. 문구를 둘러싼 사각형에는 두 줄 옆의 빈 공간도 포함됩니다. 따라서 그 사각형의 중앙에 레이블을 놓으면 단어와 떨어져 보일 수 있습니다. Floating UI는 여러 줄 링크와 범위 선택에서 이런 문제가 발생한다고 명시합니다. inline.

개별 사각형도 해석이 필요합니다. W3C는 범위가 선택된 요소의 테두리 사각형과 텍스트 사각형을 콘텐츠 순서대로 반환할 수 있다고 규정합니다. 따라서 중첩 요소가 겹치는 사각형을 만들 수 있으며, 사각형 개수는 줄 수를 정확히 나타내지 않습니다. 텍스트 경계는 정확히 그려진 글리프 외곽선이 아니라 글꼴 메트릭을 사용합니다. CSSOM View Module.

주석을 어느 조각에 붙여야 하나요?

위치 지정 코드를 작성하기 전에 배치 규칙을 정하세요. 다음 의사결정표는 주석 대상 텍스트와 레이블이 나타나는 지점을 구분합니다.

의도한 동작 기준 기하 정보 배치 규칙
문구를 따라가는 메모 전체 범위 단순한 가로 방향, 왼쪽에서 오른쪽으로 쓰는 레이아웃에서 사용할 수 있는 마지막 조각 아래에 붙입니다
포인터 상호작용 근처의 레이블 범위의 조각 사각형 상호작용의 클라이언트 좌표에 있는 조각을 선택합니다
중앙에 놓이는 개요 레이블 둘러싼 사각형 레이블이 빈 공간에 놓일 수 있음을 감수합니다
문구 전체의 배경 표시 전체 범위 레이블 배치와 별도로 텍스트 하이라이트를 사용합니다

데모에서는 첫 번째 규칙을 사용합니다. 마지막 사각형이 유용한 끝점 기준이 되도록 쓰기 방향과 방향성을 의도적으로 고정합니다. 이는 이 예제의 전제 조건입니다. W3C는 이러한 사각형이 콘텐츠 순서를 따른다고 규정하지만, 왼쪽 위에서 오른쪽 아래로 이어지는 보편적인 순서를 보장하지는 않습니다. CSSOM View Module.

Floating UI를 사용해 포인터 기준으로 배치할 때는 inline() 미들웨어가 뷰포트 기준 x 및 y 좌표를 받습니다. 문서에서는 좌표를 기준으로 선택하는 기능을 분리된 사각형에 한정하므로, 이를 제한 없는 문자 선택기로 설명해서는 안 됩니다. inline.

Mac에서 줄바꿈 텍스트 전체 데모 만들기

최신 데스크톱 Firefox를 사용하고 빈 탭에서 about:blank를 여세요. Command + Option + K를 눌러 페이지의 웹 콘솔을 엽니다. Mozilla는 이 단축키를 문서화하고 콘솔에서 페이지 컨텍스트의 JavaScript를 실행할 수 있다고 설명합니다. Web Console.

아래의 전체 프로그램을 콘솔에서 실행하세요. 샘플 콘텐츠, 스타일, 컨트롤을 모두 생성하므로 프로젝트나 가져오기, 외부 라이브러리가 필요하지 않습니다. 예시 문구는 기존 강조 요소를 가로지릅니다. 주석은 표시 전용 레이블이며 편집 컨트롤은 없습니다.

(() => {
  window.fragmentDemo?.destroy();
  const host = document.createElement('section');
  host.style.cssText = 'margin:48px 24px;padding-bottom:900px';
  const p = document.createElement('p');
  p.dir = 'ltr';
  p.style.cssText = 'font:20px/1.6 Georgia,serif;width:24rem;max-width:80vw;writing-mode:horizontal-tb';
  const a = document.createTextNode('Sample A: amber tiles follow the curved path ');
  const em = document.createElement('em');
  em.textContent = 'past the small blue circle and ';
  const c = document.createTextNode('toward the final silver square. End.');
  p.append(a, em, c);
  const button = document.createElement('button');
  button.textContent = 'Change text width';
  const status = document.createElement('p');
  host.append(button, p, status);
  document.body.append(host);
  const range = document.createRange();
  range.setStart(a, 'Sample A: '.length);
  range.setEnd(c, c.data.indexOf(' End.'));
  const note = document.createElement('div');
  note.textContent = 'Note A: review this phrase';
  note.style.cssText = 'position:fixed;z-index:1000;background:#fff2ad;color:#222;padding:6px 10px;font:14px/1.4 system-ui;pointer-events:none';
  document.body.append(note);
  const style = document.createElement('style');
  style.textContent = '::highlight(fragment-demo) {background-color:#dbeafe;}';
  document.head.append(style);
  const canHighlight = typeof Highlight !== 'undefined' && !!window.CSS?.highlights;
  if (canHighlight) CSS.highlights.set('fragment-demo', new Highlight(range));
  status.textContent = canHighlight ? 'The blue phrase owns Note A.' : 'Text highlighting unavailable; Note A still uses the range.';
  function update() {
    if (!host.isConnected) return;
    const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0);
    const r = rects.at(-1);
    note.hidden = !r || range.collapsed;
    if (note.hidden) return;
    const maxLeft = Math.max(8, innerWidth - note.offsetWidth - 8);
    note.style.left = Math.max(8, Math.min(r.left, maxLeft)) + 'px';
    note.style.top = (r.bottom + 8) + 'px';
  }
  let narrow = false;
  button.onclick = () => {
    narrow = !narrow;
    p.style.width = narrow ? '16rem' : '24rem';
    update();
  };
  const observer = new ResizeObserver(update);
  observer.observe(p);
  window.addEventListener('resize', update);
  window.addEventListener('scroll', update, true);
  document.fonts.ready.then(update);
  update();
  window.fragmentDemo = {destroy() {
    observer.disconnect();
    window.removeEventListener('resize', update);
    window.removeEventListener('scroll', update, true);
    if (canHighlight) CSS.highlights.delete('fragment-demo');
    host.remove(); note.remove(); style.remove();
  }};
})();

시작점과 끝점은 접두사 Sample A: 와 접미사 End.를 제외합니다. Change text width를 클릭하면 문단 너비가 두 가지 사이에서 바뀝니다. 같은 범위가 주석 대상으로 유지되며 레이블은 사용할 수 있는 마지막 조각 아래로 다시 배치됩니다. 동작을 살펴보려면 스크롤하고 창 크기를 조정하세요. window.fragmentDemo.destroy()로 데모를 제거할 수 있습니다.

프로그램은 JavaScript 구문 검사를 통과했으며, 샘플 오프셋과 조각 위치 계산도 결정론적 검사를 통과했습니다. 여기서는 브라우저 렌더링을 실행하지 않았습니다. 위 단계에 따라 화면에서 확인할 수 있습니다.

데모는 텍스트 흐름과 셰이핑을 어떻게 유지하나요?

범위가 기존 텍스트를 측정합니다. 샘플을 구성한 뒤에도 프로그램은 문구의 텍스트 노드와 강조 요소를 그대로 둡니다. 레이블은 별도로 추가하며, 선택적으로 CSS Custom Highlight API를 통해 문구를 칠합니다. MDN은 이 API가 기본 DOM 구조를 바꾸지 않고 작동한다고 설명합니다. CSS custom highlight API.

이 구분은 접근 방식을 응용할 때 중요합니다. 문자 단위 사이에 인라인 패딩, 테두리 또는 여백을 추가하면 셰이핑 경계가 생길 수 있습니다. W3C는 초기가 아닌 vertical-align과 양방향 격리 경계도 셰이핑을 깨뜨리는 조건으로 설명합니다. 좌표를 얻기 위해 문자를 각각 위치 지정된 래퍼로 감싸지 말고, 브라우저의 기존 렌더링 결과를 측정하세요. CSS Text Module Level 3, Shaping Across Element Boundaries.

실제 애플리케이션 텍스트에서는 의도한 텍스트 경계에서 범위 끝점을 계산하세요. DOM 텍스트 오프셋은 코드 단위 기준이며, 눈에 보이는 글리프 개수가 아닙니다. 표시되는 문자마다 오프셋 하나를 차지한다고 가정하지 말고, 끝점을 의도한 완전한 문자에 맞추세요. DOM Standard.

하이라이트는 선택 사항입니다. MDN은 Highlight를 Baseline 2025로 분류하며 이전 브라우저에서는 지원되지 않을 수 있다고 설명합니다. 데모의 기능 확인 코드는 하이라이트를 사용할 수 없어도 주석 위치를 유지하고 안내 상태 메시지를 표시합니다. Highlight.

페이지가 다시 배치될 때 무엇을 업데이트해야 하나요?

데모는 문단 크기 변경, 창 크기 조정, 스크롤 시 다시 측정합니다. ResizeObserver는 브라우저 창 크기 조정과 무관한 변경을 포함해 요소 크기의 변화를 감지합니다. 여기서 콜백은 별도의 레이블만 이동하므로 측정 중인 문단의 크기를 반복해서 바꾸는 피드백 루프가 생기지 않습니다. ResizeObserver.

글꼴 로딩은 별도로 업데이트해야 합니다. 프로그램은 즉시 측정한 다음 document.fonts.ready가 완료되면 다시 측정합니다. MDN에 따르면 이 프로미스는 문서의 글꼴 로딩과 관련 레이아웃 작업이 끝난 뒤 완료됩니다. 애플리케이션이 나중에 글꼴이나 콘텐츠를 변경하는 경우에는 측정을 다시 요청해야 합니다. FontFaceSet: ready property.

좌표는 하나의 체계로 유지하세요. 데모는 빈 페이지에서 고정 위치 레이블을 사용하고 클라이언트 좌표를 그대로 적용합니다. 문서 스크롤 오프셋을 더하면 문서 좌표로 바뀝니다. MDN은 클라이언트 사각형이 뷰포트 기준이며, 문서 기준 좌표가 필요할 때 스크롤 오프셋을 더해 변환한다고 설명합니다. Element: getBoundingClientRect() method.

이 간단한 예제는 가로 위치를 화면 경계 안으로 제한하지만 세로 충돌 회피, 화면 밖에 있을 때 숨기기, 임의의 변환 컨테이너는 구현하지 않습니다. 또한 새로고침하거나 텍스트를 완전히 교체한 뒤 주석을 복원하지 않습니다. 애플리케이션에서는 의도한 구절을 식별할 방법을 보존하고, 렌더링 후 범위를 다시 만든 다음 다시 측정하세요.

Floating UI는 언제 사용해야 하나요?

레이블 위치를 클리핑에 맞춰 조정하고 업데이트 수명 주기를 관리해야 한다면 Floating UI를 사용하세요. 문서화된 가상 참조 인터페이스는 두 가지 메서드로 범위를 지원합니다. 범위를 둘러싼 사각형을 반환하는 getBoundingClientRect()과 조각 사각형을 반환하는 getClientRects()입니다. 클리핑과 업데이트 감지에 요소 컨텍스트가 있도록 텍스트를 포함하는 실제 요소를 contextElement에 지정하세요. Virtual Elements.

해당 가상 참조를 computePosition()에 전달하고, flip() 앞인 미들웨어 배열의 앞부분에 inline()을 포함하세요. 플로팅 요소의 CSS 위치 지정 방식을 선택한 전략에 맞추세요. 문서에 따르면 공간이 부족할 때 면을 바꾸려면 flip()를, 클리핑 한계 안에서 레이블을 이동하려면 shift()를 사용합니다. inline, computePosition.

주석이 표시된 동안에만 autoUpdate()을 시작하고, 레이블을 제거할 때 반환된 정리 함수를 호출하세요. 문서화된 업데이트 메커니즘은 상위 요소의 스크롤과 크기 조정, 레이아웃 이동을 처리합니다. 그래도 관찰 중인 컨테이너의 크기는 바꾸지 않으면서 선택한 텍스트의 조각에 영향을 주는 애플리케이션 변경이 있으면 업데이트를 직접 실행해야 합니다. autoUpdate.

주석이 여전히 잘못 표시될 때 진단하기

기하 문제 때문에 텍스트 대상 지정 오류를 놓치지 않도록 다음 순서대로 확인하세요.

  1. 문구를 확인합니다. 좌표를 조정하기 전에 range.toString()를 살펴보고 양 끝점을 확인하세요.
  2. 조각을 살펴봅니다. Array.from(range.getClientRects())을 기록하세요. 겹치는 항목이 여러 개라면 중첩된 인라인 콘텐츠가 원인일 수 있습니다. 항목 수를 줄 수로 해석하지 마세요.
  3. 배치 규칙을 확인합니다. 둘러싼 사각형, 마지막 조각, 포인터로 선택한 조각은 서로 다른 주석 동작에 쓰입니다.
  4. 좌표계를 확인합니다. 빈 페이지 데모에서는 고정 레이블에 클라이언트 좌표를 사용하세요.
  5. 레이아웃 변경 후 다시 확인합니다. 너비를 바꾸고, 스크롤과 크기 조정을 해 보고, 글꼴 로딩이 끝날 때까지 기다리세요. 애플리케이션이 텍스트 노드를 교체하면 범위를 다시 만드세요.
  6. 레이아웃 전제를 확인합니다. 세로 쓰기, 혼합 방향, 다단 레이아웃, 변환된 컨테이너에서는 데모의 마지막 조각을 고르는 지름길을 재사용하기 전에 적절한 조각 선택 규칙을 정하고 테스트하세요.

안정적인 주석은 정확한 텍스트 범위와 명확한 배치 규칙에서 시작합니다. 줄바꿈과 셰이핑은 브라우저가 처리하게 두고, 관련 레이아웃이 바뀔 때마다 렌더링된 조각을 다시 측정하세요. 그러면 단어가 자연스럽게 흐르는 동안 메모와 문구 사이의 관계도 유지됩니다.

출처