既存のインライン要素を保持しながらテキスト範囲をラップするには、選択された各テキスト断片をそれぞれ独自の span でラップします。この Safari-on-Mac のチュートリアルでは、合成段落を使用して強調要素、テキスト順序、要素参照をそのまま維持します。

単一の外側 span が失敗する理由

ブラウザの範囲は、あるインライン要素の内部で始まり、その外側で終わることがあります。例えば、選択が強調テキストの途中で始まり、ネストした要素を横切り、段落の末尾テキストで終わる場合があります。そのような選択は、必ずしも 1 つの完全なサブツリーに対応しません。

Range.surroundContents() は、そのような選択をすべて処理できるわけではありません。範囲が非テキストノードを部分的に含む場合、例外をスローします。現在の DOM 標準では、その条件に対して InvalidStateError が指定されています。テキストと完全に選択された要素を含む範囲であればこのメソッドを使用できますが、要素の境界を横切る場合は別のアプローチが必要です。 DOM Standard, Range: surroundContents() メソッド

範囲を 1 つの新しい span に抽出することも可能な変換ですが、異なる保持ルールがあります。extractContents() は、部分的に選択された祖先要素を複製します。元のインライン要素を完全な状態で維持し、それらのアイデンティティを保持する必要がある場合、複製された分岐は不適切です。 Range: extractContents() メソッド

ドキュメントを変更する前に、以下の判断支援を使用してください:

必要な結果 適切なアプローチ
テキストと完全に選択された要素を 1 つのラッパーで囲む surroundContents() を検討
元のインライン要素を部分選択で分断せずに保持 選択されたテキスト断片を個別にラップ
部分的に選択された要素の境界を 1 つのラッパーで横断 再構築と抽出/複製の動作を検討し、許容
要素を追加せずに視覚的な強調のみを行う そもそも span が必要か再考

以下の例では、個別のラッパーを採用します。ここでの構造保持とは、既存の要素オブジェクトとそれらの祖先関係を維持しながら、それらの下に span を追加することを意味します。選択の開始または終了位置でテキストノードが分割されます。

Safari on Mac で合成例を開く

新しい Safari タブを開き、about:blank と入力します。次に Safari → 設定… → 詳細 を選び、Web 開発者向け機能を表示 を有効にします。開発 → Web インスペクタを表示 を開くか、Option–Command–I を押します。これらは WebKit によって文書化されている手順です。 Web インスペクタの有効化

コンソール タブを選択します。以下の完全な JavaScript プログラムをプロンプトで実行します。WebKit の文書によると、コンソール プロンプトは、検査対象ページの選択された実行コンテキストで JavaScript を評価します。この例ではトップレベルのページ コンテキストを使用します。 Web インスペクタ インターフェイス

ライブラリ、インポート、ビルド手順、既存のドキュメント内容は一切不要です。このプログラムは独自の段落を作成し、所定の範囲をラップし、ラップされたテキスト上で前方選択を復元し、削除ボタンを追加します。サポートされる入力は意図的に狭く限定されています:テキストと通常の em、strong、span、または code 子孫要素のみを含む 1 つの段落で、両方の範囲境界がテキストノード内にある場合です。

(() => {
  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 と、blue を含むネストした em 要素が含まれます。範囲は bright のオフセット 3 で始まり、 gamma delta. のオフセット 6 で終わります。これらはプログラムで定義された説明用の入力です。

期待されるコンソール出力には 3 つの断片が含まれます:

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

最初の span は元の strong 要素の子になります。2 番目は元の em 要素の子になります。3 番目は段落の子になります。ドキュメント順でそれらのテキストを読むと、選択された文字列 ght blue gamma が再現されます。未選択の接頭辞 bri と接尾辞 delta. はラッパーの外側に残ります。

巡回には createTreeWalker() と NodeFilter.SHOW_TEXT を使用し、子孫テキストノードを訪問します。intersectsNode() で候補を特定し、境界オフセットで最初と最後の候補をトリミングします。長さゼロの断片は除外されます。 Document: createTreeWalker() メソッド, Range: intersectsNode() メソッド

プログラムは、何らかの変更を行う前にすべての断片境界を記録します。これは DOM 範囲がライブであるため重要です:ノードの分割、挿入、削除によって境界点が更新される可能性があります。複製された Range もライブであるため、ノード参照と数値オフセットを保存して、ここで使用される安定した作業リストを提供します。 DOM Standard: Ranges

各断片について、プログラムはまず終了境界を分割し、次に開始境界を分割します。splitText() は接頭辞を元のテキストノードに残し、残りのテキストを兄弟ノードとして返します。これにより、囲む要素を再作成せずに選択された部分文字列が分離されます。 Text: splitText() メソッド

4 つの断片計画ケース(要素間テキスト、1 ノード内の部分文字列、折りたたまれた範囲、文字を選択しない境界)は、Node.js で決定論的に検証されました。ブラウザ DOM 変異と Safari 操作は準備中に実行されませんでした;プログラムには実行時にそれらの結果を検証するアサーションが含まれています。

正しい構造とライフサイクル契約を保持する

既存の strong および em 要素は、このプログラムによって決して削除、置換、複製されません。それらの要素参照は使用可能なままであり、strong にアタッチされたリスナーは同じオブジェクトにアタッチされたままです。サンプルでは、ラップ後の親関係と合成イベント ディスパッチの両方を検証します。

テキストノードのアイデンティティには異なる契約があります。境界分割によって新しいテキストノードが導入され、各選択テキスト断片が新しい span に移動します。テキストノード参照を保持しているアプリケーションは、その変更を考慮する必要があります。このメソッドはテキスト内容と元の要素階層を保持しますが、すべての元のテキストノード境界を保持するわけではありません。

span の追加により、テキストの直接の親関係も変化します。既存のドキュメントでこの変換を採用する前に、要素の最初の子がテキストノードであることを期待するコード、直接の子に依存する CSS セレクタ、イベント ターゲットを検査するイベント ハンドラがないか確認してください。要素オブジェクトのみを保持しても、周囲のすべてのアプリケーション前提が依然として成り立つとは限りません。

サンプルはラップ後に前方選択を明示的に再構築します。後方選択の方向は保持せず、エディタの元に戻すトランザクションも提供しません。それらを編集インターフェイスへの統合時に別個の要件として扱ってください。ラッパー関数自体は、特定のブラウザ選択状態を約束せずに span を返します。

Remove spans をクリックすると、その実行中に作成された span のみがアンラップされ、選択がクリアされます。分割されたテキストノードは分離されたままです。その削除アクションは元の要素階層とテキストを復元しますが、テキストノードのアイデンティティの完全な逆転ではありません。それらの参照に依存する他のコードがある場合、自動的な正規化の追加は避けてください。

例をサイレントに拡張せずに適応させる

ユーザーのドラッグ選択の場合、window.getSelection() が選択を返し、rangeCount が非ゼロであることを確認した後に範囲を取得してください。getRangeAt(0) は最初の範囲を返します;選択の範囲数を超えるインデックスを要求するとエラーになります。 Selection: getRangeAt() メソッド

その範囲を同じヘルパーに渡すのは、両方の境界コンテナが意図した段落内のテキストノードである場合のみです。境界コンテナが要素である選択は、変異前に却下されます。このような境界をサポートするには、子孫テキスト位置への明示的に定義された追加変換が必要です;単にガードを削除するだけでは実装になりません。

ヘルパーはまた、サポートされていない子孫と既存のマーク付きラッパーを変更前に却下します。繰り返しや重複するアノテーションの場合、新しい span をネストさせるか、マージするか、以前のものを置き換えるかをまず定義してください。サンプルは、ポリシーなしでラッパーを蓄積する代わりに、その決定を意図的に公開しています。

オフセットは UTF-16 符号単位で測定されます。複数単位の文字を含むテキストでの計算位置の場合、境界が意図した文字シーケンスを分割しないようにしてください。合成サンプルはシンプルなラテン文字テキストを使用しているため、その数値オフセットは明確です。 Text: splitText() メソッド

このタスクで有用な不変条件は明確です:新しい span のテキストを連結したものが元の選択文字列と等しく、段落の全テキストが変更されず、既存のインライン要素がアイデンティティと祖先関係を保持すること。例を適応させる際はこの 3 つすべてを確認してください;それらが合わせて、成功した保持の意味を説明します。

出典