기존 인라인 요소를 보존하면서 텍스트 범위를 감싸려면 선택된 각 텍스트 조각을 각각 span으로 감쌉니다. 이 Mac용 Safari 안내에서는 강조 요소, 텍스트 순서, 요소 참조를 그대로 유지하기 위해 예시 문단을 사용합니다.

하나의 바깥쪽 span이 실패할 수 있는 이유

브라우저 범위는 한 인라인 요소 내부에서 시작해 그 바깥에서 끝날 수 있습니다. 예를 들어 선택 범위가 강조 텍스트의 중간에서 시작해 중첩 요소를 지나 문단 끝부분의 텍스트에서 끝날 수 있습니다. 이러한 선택 범위가 반드시 하나의 완전한 하위 트리에 대응하는 것은 아닙니다.

Range.surroundContents()는 이런 선택을 모두 처리할 수 없습니다. 범위에 텍스트가 아닌 노드가 부분적으로 포함되면 예외를 발생시킵니다. 현재 DOM Standard는 이 경우 InvalidStateError를 지정합니다. 텍스트와 완전히 선택된 요소를 포함하는 범위에는 이 방법을 사용할 수 있지만, 요소 경계를 가로지르는 경우에는 다른 방법이 필요합니다. DOM Standard, Range: surroundContents() method

범위를 하나의 새 span으로 추출하는 방법도 있지만, 보존 규칙이 다릅니다. extractContents()는 부분적으로 선택된 조상 요소를 복제합니다. 원래 인라인 요소가 온전하게 유지되고 동일한 정체성을 보존해야 한다면, 복제된 분기는 적합하지 않습니다. Range: extractContents() method

문서를 변경하기 전에 다음 판단 기준을 참고하세요.

필요한 결과 적합한 방법
텍스트와 완전히 선택된 요소를 하나의 래퍼로 감싸기 surroundContents() 고려
부분 선택을 가로질러 원래 인라인 요소를 온전하게 유지하기 선택된 텍스트 조각을 각각 감싸기
부분적으로 선택된 요소 경계 전체를 하나의 래퍼로 감싸기 구조 변경을 허용하고 추출 및 복제 동작을 검토하기
요소를 추가하지 않고 시각적으로 강조하기 작업에 span이 실제로 필요한지 다시 검토하기

아래 예제에서는 별도의 래퍼를 사용합니다. 여기서 구조를 보존한다는 것은 span을 그 아래에 추가하면서 기존 요소 객체와 조상 관계를 유지한다는 뜻입니다. 선택 범위가 시작되거나 끝나는 위치에서는 텍스트 노드가 분할됩니다.

Mac에서 Safari로 예시 열기

새 Safari 탭을 열고 about:blank를 입력합니다. 그런 다음 Safari → Settings… → Advanced를 선택하고 Show features for web developers를 활성화합니다. Develop → Show Web Inspector를 열거나 Option–Command–I를 누릅니다. 이 단계는 WebKit 문서에 안내되어 있습니다. Enabling Web Inspector

Console 탭을 선택합니다. 아래의 완전한 JavaScript 프로그램을 프롬프트에서 실행합니다. WebKit 문서에 따르면 콘솔 프롬프트는 검사 중인 페이지에서 선택된 실행 컨텍스트의 JavaScript를 평가합니다. 이 예제에서는 최상위 페이지 컨텍스트를 사용합니다. Web Inspector Interface

라이브러리, import, 빌드 단계 또는 기존 문서 콘텐츠는 필요하지 않습니다. 이 프로그램은 자체 문단을 만들고, 미리 정해진 범위를 감싸고, 감싼 텍스트를 가로지르는 정방향 선택을 복원한 다음 제거 버튼을 추가합니다. 지원 입력 범위는 의도적으로 제한되어 있습니다. 텍스트와 일반 em, strong, span 또는 code 하위 요소로 구성된 문단 하나이며, 범위의 양쪽 경계가 모두 텍스트 노드 안에 있어야 합니다.

(() => {
  function wrapTextRange(root, range) {
    const doc = root.ownerDocument;
    const startNode = range.startContainer;
    const endNode = range.endContainer;

    if (root.tagName !== "P" ||
        startNode.nodeType !== 3 || endNode.nodeType !== 3 ||
        !root.contains(startNode) || !root.contains(endNode)) {
      throw new TypeError("Use one paragraph and text-node boundaries.");
    }

    const allowed = new Set(["EM", "STRONG", "SPAN", "CODE"]);
    for (const element of root.querySelectorAll("*")) {
      if (!allowed.has(element.tagName) ||
          element.hasAttribute("data-range-wrap")) {
        throw new TypeError("Unsupported element or existing wrappers.");
      }
    }
    if (range.collapsed) return [];

    // Snapshot every fragment before changing the live DOM range.
    const jobs = [];
    const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    for (let node = walker.nextNode(); node; node = walker.nextNode()) {
      if (!range.intersectsNode(node)) continue;
      const start = node === startNode ? range.startOffset : 0;
      const end = node === endNode ? range.endOffset : node.length;
      if (end > start) jobs.push({ node, start, end });
    }

    const spans = [];
    for (const { node, start, end } of jobs) {
      // Split the end first so the start offset remains valid.
      if (end < node.length) node.splitText(end);
      const selected = start > 0 ? node.splitText(start) : node;
      const span = doc.createElement("span");
      span.setAttribute("data-range-wrap", "");
      span.style.backgroundColor = "#fff0a6";
      selected.parentNode.insertBefore(span, selected);
      span.appendChild(selected);
      spans.push(span);
    }
    return spans;
  }

  const panel = document.createElement("section");
  const root = document.createElement("p");
  const strong = document.createElement("strong");
  const em = document.createElement("em");
  const tail = document.createTextNode(" gamma delta.");
  strong.append(document.createTextNode("bright "));
  em.textContent = "blue";
  strong.append(em);
  root.append(document.createTextNode("Alpha "), strong, tail);
  panel.append(root);
  document.body.append(panel);

  let clicks = 0;
  strong.addEventListener("click", () => { clicks += 1; });
  const originalText = root.textContent;
  const range = document.createRange();
  range.setStart(strong.firstChild, 3);
  range.setEnd(tail, 6);
  const selectedText = range.toString();
  const spans = wrapTextRange(root, range);

  const selection = window.getSelection();
  const restored = document.createRange();
  restored.setStart(spans[0].firstChild, 0);
  const lastText = spans[spans.length - 1].firstChild;
  restored.setEnd(lastText, lastText.length);
  selection.removeAllRanges();
  selection.addRange(restored);

  strong.dispatchEvent(new Event("click"));
  console.assert(root.textContent === originalText, "Text changed");
  console.assert(strong.parentNode === root, "Strong element moved");
  console.assert(em.parentNode === strong, "Emphasis element moved");
  console.assert(clicks === 1, "Existing listener failed");
  console.assert(spans.map(s => s.textContent).join("") === selectedText);
  console.log(spans.map(s => s.textContent));

  const remove = document.createElement("button");
  remove.textContent = "Remove spans";
  remove.addEventListener("click", () => {
    selection.removeAllRanges();
    for (const span of spans) {
      span.replaceWith(...Array.from(span.childNodes));
    }
    remove.disabled = true;
  });
  panel.append(remove);
})();

어떤 텍스트가 감싸지는지 이해하기

문단에는 Alpha bright blue gamma delta.가 적혀 있습니다. 기존 strong 요소에는 bright 가 들어 있고, 그 안에 중첩된 em 요소에는 blue가 들어 있습니다. 범위는 bright 의 오프셋 3에서 시작하고 gamma delta.의 오프셋 6에서 끝납니다. 이는 프로그램에 정의된 예시 입력입니다.

예상되는 콘솔 출력에는 세 조각이 포함됩니다.

["ght ", "blue", " gamma"]

첫 번째 span은 원래 strong 요소의 자식이 됩니다. 두 번째는 원래 em 요소의 자식이 됩니다. 세 번째는 문단의 자식이 됩니다. 문서 순서대로 이들의 텍스트를 읽으면 선택된 문자열 ght blue gamma가 재현됩니다. 선택되지 않은 접두부 bri와 접미부 delta.는 래퍼 바깥에 남습니다.

순회에는 NodeFilter.SHOW_TEXT와 함께 createTreeWalker()를 사용해 하위 텍스트 노드를 방문합니다. intersectsNode()은 후보를 식별하고, 경계 오프셋은 첫 후보와 마지막 후보를 잘라냅니다. 길이가 0인 조각은 제외됩니다. Document: createTreeWalker() method, Range: intersectsNode() method

프로그램은 무엇이든 변경하기 전에 모든 조각의 경계를 기록합니다. DOM 범위는 살아 있는 객체이므로 노드를 분할, 삽입 또는 제거하면 경계 지점이 갱신될 수 있어 중요합니다. 복제한 Range도 살아 있는 객체입니다. 노드 참조와 숫자 오프셋을 저장하면 여기서 사용하는 안정적인 작업 목록을 만들 수 있습니다. DOM Standard: Ranges

각 조각에 대해 프로그램은 끝 경계를 먼저 분할한 다음 시작 경계를 분할합니다. splitText()은 접두부를 원래 텍스트 노드에 남기고 나머지 텍스트를 형제 노드로 반환합니다. 이렇게 하면 감싸는 요소를 다시 만들지 않고 선택된 부분 문자열을 분리할 수 있습니다. Text: splitText() method

요소를 가로지르는 텍스트, 한 노드 안의 부분 문자열, 접힌 범위, 문자를 선택하지 않는 경계 등 네 가지 조각 계획 사례를 Node.js에서 결정적으로 확인했습니다. 준비 과정에서는 브라우저 DOM 변경과 Safari 상호작용을 실행하지 않았습니다. 프로그램에는 실행 시 결과를 확인할 수 있는 어설션이 포함되어 있습니다.

구조와 수명 주기 계약 보존하기

기존 strong 및 em 요소는 이 프로그램에서 제거, 교체 또는 복제되지 않습니다. 요소 참조는 계속 사용할 수 있고, strong에 연결된 리스너도 동일한 객체에 계속 연결되어 있습니다. 샘플은 감싼 뒤 부모 관계와 합성 이벤트 전달을 모두 확인합니다.

텍스트 노드의 정체성은 다른 계약을 가집니다. 경계 분할로 새 텍스트 노드가 생기고, 선택된 각 텍스트 조각은 새 span 안으로 이동합니다. 텍스트 노드 참조를 보유한 애플리케이션은 이 변경을 고려해야 합니다. 이 방법은 텍스트 콘텐츠와 원래 요소 계층을 보존하지만, 원래의 모든 텍스트 노드 경계까지 보존하지는 않습니다.

span을 추가하면 텍스트의 즉시 부모 관계도 바뀝니다. 기존 문서에 이 변환을 적용하기 전에 요소의 첫 번째 자식이 텍스트 노드라고 가정하는 코드, 직접 자식에 의존하는 CSS 선택자, 이벤트 대상을 확인하는 이벤트 핸들러를 점검하세요. 요소 객체를 유지하는 것만으로 주변 애플리케이션의 모든 가정이 계속 성립한다고 볼 수는 없습니다.

샘플은 감싼 뒤 정방향 선택을 명시적으로 다시 구성합니다. 역방향 선택 방향을 보존하거나 편집기 실행 취소 트랜잭션을 제공하지는 않습니다. 편집 인터페이스에 이 작업을 통합할 때는 이러한 요구사항을 별도로 다루세요. 래퍼 함수 자체는 특정 브라우저 선택 상태를 보장하지 않고 span을 반환합니다.

Remove spans를 클릭하면 이번 실행에서 만든 span만 해제하고 선택을 지웁니다. 분할된 텍스트 노드는 각각 분리된 상태로 남습니다. 이 제거 작업은 원래의 요소 계층과 텍스트를 복원하지만 텍스트 노드 정체성을 완전히 되돌리지는 않습니다. 다른 코드가 해당 참조에 의존한다면 자동 정규화를 추가하지 마세요.

범위를 묵시적으로 확장하지 않고 예제 적용하기

사용자가 드래그한 선택의 경우 window.getSelection()가 선택 객체를 반환했는지, 그리고 rangeCount가 0이 아닌지 확인한 다음에만 해당 범위를 가져옵니다. getRangeAt(0)은 첫 번째 범위를 반환하며, 선택 범위 개수를 벗어난 인덱스를 요청하면 오류가 발생합니다. Selection: getRangeAt() method

양쪽 경계 컨테이너가 모두 대상 문단 안의 텍스트 노드인 경우에만 해당 범위를 같은 도우미에 전달합니다. 경계 컨테이너가 요소인 선택은 변경 전에 거부됩니다. 이러한 경계를 지원하려면 하위 텍스트 위치로 변환하는 방식을 명시적으로 추가해야 합니다. 단순히 가드만 제거한다고 구현되는 것은 아닙니다.

도우미는 변경을 수행하기 전에 지원되지 않는 하위 요소와 기존 표시 래퍼도 거부합니다. 주석을 반복하거나 겹쳐 적용하려면 새 span을 중첩할지, 병합할지, 이전 span을 대체할지 먼저 정하세요. 샘플은 정책 없이 래퍼가 계속 누적되지 않도록 이 결정을 의도적으로 드러냅니다.

오프셋은 UTF-16 코드 단위로 측정됩니다. 여러 단위 문자가 포함된 텍스트에서 위치를 계산한다면 경계가 의도한 문자 시퀀스를 나누지 않는지 확인하세요. 예시에는 단순한 라틴 문자를 사용하므로 숫자 오프셋이 명확합니다. Text: splitText() method

이 작업에서 유용한 불변 조건은 간단합니다. 새 span의 텍스트를 이어 붙인 값이 원래 선택된 문자열과 같아야 하고, 문단 전체 텍스트는 바뀌지 않아야 하며, 기존 인라인 요소는 정체성과 조상 관계를 유지해야 합니다. 예제를 적용할 때 세 가지를 모두 확인하세요. 이 조건들이 함께 성공적인 보존의 의미를 설명합니다.

출처