注釈を DOM Range に固定し、Range が描画されたときのフラグメント矩形を使ってラベルを配置します。このガイドでは、フレーズがインライン要素をまたいで複数行に折り返されても、メモをその位置に保つ小さな Mac デスクトップブラウザーのデモを紹介します。

ここで扱うのは、通常のウェブページのレイアウトでテキストを組み直すことなく、フレーズに1つの注釈を表示して固定するという具体的な作業です。フレーズを特定し、ラベルを置くフラグメントを選び、レイアウトが変わったら再計測します。MDN では、Range が占める矩形を取得するブラウザー API として Range.getClientRects() を説明しています。Range: getClientRects() メソッド。

折り返されたテキストからラベルがずれるのはなぜですか?

フレーズには、文書内での位置と、画面上に現在表示されている形状という、関係する2つの表現があります。WHATWG は、Range を開始点と終了点で定義し、それぞれがノードとオフセットで構成されるとしています。両端の点が異なるテキストノードにあってもよいため、既存の強調要素をまたぐ1つの Range を作成できます。DOM Standard。

単一の境界矩形では詳細が失われます。ある行の途中から始まり、次の行の先頭近くで終わるフレーズを想像してください。それを囲む矩形には、両方の行の横にある空白も含まれます。そのため、この矩形の中央に置いたラベルは、単語から離れて見えることがあります。Floating UI は、複数行にまたがるリンクや Range 選択でこの問題が生じることを明示しています。inline。

個々の矩形も正しく解釈する必要があります。W3C の仕様によると、Range は選択された要素の境界矩形とテキストの矩形の両方を、コンテンツ順に返すことがあります。そのため、入れ子になった要素では矩形が重複することがあり、矩形の数を行数の目安にはできません。テキストの境界は、正確に描画された字形の輪郭ではなく、フォントメトリクスに基づきます。CSSOM View Module。

注釈をどのフラグメントに紐づけますか?

位置を決めるコードを書く前に、配置ルールを選びます。次の判断表では、注釈を付けるテキストと、ラベルを表示する位置を分けています。

意図する動作 基準となる形状 配置ルール
フレーズに追従するメモ Range 全体 単純な横書き・左から右へのレイアウトでは、最後に利用可能なフラグメントの下に配置する
ポインター操作の近くに置くラベル Range のフラグメント矩形 操作時のクライアント座標にあるフラグメントを選ぶ
全体を中央に示すラベル 範囲を囲む矩形 ラベルが空白部分に重なる場合があることを受け入れる
フレーズ全体の背景マーキング Range 全体 ラベルの配置とは独立して、テキストハイライトを使う

このデモでは最初のルールを使います。最後の矩形を有用な終点の基準として使えるよう、書字方向とテキスト方向を意図的に固定しています。これはこの例の前提条件です。W3C はこれらの矩形がコンテンツ順になると規定していますが、左上から右下への普遍的な順序を保証してはいません。CSSOM View Module。

Floating UI でポインターを基準に配置する場合、inline() ミドルウェアはビューポート相対の x と y の座標を受け取ります。ドキュメントでは、この座標による選択は分離した矩形に限られるとしているため、制限のない文字選択機能として説明すべきではありません。inline。

Mac で折り返しテキストのデモを作る

デスクトップ版の最新の Firefox を使い、新しいタブで about:blank を開きます。Command + Option + K を押して、ページの Web コンソールを開きます。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 をクリックすると、段落の幅が2種類に切り替わります。同じ Range が引き続き注釈の対象となり、ラベルは最後に利用可能なフラグメントの下に配置されます。ウィンドウをスクロールしたりサイズ変更したりして、動作を確認してください。window.fragmentDemo.destroy() でデモを削除できます。

このプログラムは JavaScript の構文チェックに合格し、サンプルのオフセットとフラグメント位置の計算も決定論的なチェックに合格しました。ここではブラウザーでの描画を実行していません。上記の手順で表示を確認できます。

デモはテキストの流れと字形形成をどう保ちますか?

Range は既存のテキストを計測します。サンプルを作成した後も、プログラムはフレーズのテキストノードと強調要素をそのまま残します。ラベルは別に追加し、必要に応じて CSS Custom Highlight API でフレーズを描画します。MDN によると、この API は基盤となる DOM 構造を変えずに機能します。CSS custom highlight API。

この違いは、手法を応用する際に重要です。文字単位でインラインのパディング、境界線、マージンを加えると、字形形成の境界ができることがあります。W3C は、先頭以外の vertical-align と双方向分離境界も字形形成を妨げる条件として挙げています。座標を得るためだけに、文字ごとに位置を指定するラッパー要素を挿入するのは避け、ブラウザーが既存のレイアウトで生成した結果を計測してください。CSS Text Module Level 3, Shaping Across Element Boundaries。

実際のアプリケーションのテキストでは、意図したテキスト境界から Range の両端を決めます。DOM のテキストオフセットはコード単位であり、見える字形の数ではありません。表示されるすべての文字が1つのオフセットを占めると仮定せず、意図する完全な文字に両端を合わせてください。DOM Standard。

ハイライトは任意です。MDN によると、Highlight は Baseline 2025 で、古いブラウザーでは利用できない場合があります。デモでは機能の有無を確認し、ハイライトを使えない場合も注釈の配置を保ち、説明メッセージを表示します。Highlight。

ページの再レイアウト時に何を更新しますか?

このデモは、段落のサイズ変更、ウィンドウのサイズ変更、スクロール時に再計測します。ResizeObserver は、ブラウザーウィンドウのサイズ変更とは関係のないものも含め、要素の寸法変化を検出します。ここではコールバックが別個のラベルだけを動かすため、計測対象の段落のサイズを繰り返し変更するフィードバックループを避けられます。ResizeObserver。

フォントの読み込みには独自の更新処理が必要です。プログラムはすぐに計測し、その後 document.fonts.ready が解決した時点でもう一度計測します。MDN によると、この Promise は、文書のフォント読み込みと関連するレイアウト処理が完了した後に解決します。アプリケーションが後からフォントやコンテンツを変更する場合は、明示的に再計測を要求してください。FontFaceSet: ready property。

座標系は統一してください。このデモは空白ページ上で固定配置のラベルを使い、クライアント座標をそのまま適用します。文書のスクロールオフセットを加えると、座標は文書座標に変わります。MDN は、クライアント矩形がビューポート相対であり、文書相対の座標が必要な場合はスクロールオフセットを加えて変換すると説明しています。Element: getBoundingClientRect() メソッド。

この小さな例は水平方向の配置を画面内に収めますが、垂直方向の衝突回避、画面外での非表示、任意の変形コンテナには対応していません。また、再読み込みやテキスト全体の置き換え後に注釈を復元することもありません。アプリケーションでは、対象の文章を特定できる方法を保持し、描画後にその Range を作り直してから、再度計測してください。

Floating UI を使うのはどんなときですか?

ラベルの配置にクリッピングへの対応と、管理された更新ライフサイクルが必要な場合は Floating UI を使います。ドキュメントに記載されている仮想参照のインターフェースは、Range に次の2つのメソッドで対応します。getBoundingClientRect() は Range を囲む矩形を返し、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. レイアウトの変更後にもう一度確認する。 幅を変え、スクロールし、サイズを変更して、フォントの読み込み完了を待ちます。アプリケーションがテキストノードを置き換えた場合は、Range を作り直します。
  6. レイアウトの前提を確認する。 縦書き、混在する方向、段組み、変形コンテナでは、このデモの最後のフラグメントを使う近道を再利用する前に、適切なフラグメント選択ルールを定義してテストします。

信頼できる注釈は、正確なテキスト Range と明示的な配置ルールから始まります。折り返しと字形形成はブラウザーに任せ、関係するレイアウトが変わるたびに描画されたフラグメントを再計測してください。そうすることで、単語が自然に流れる状態を保ちながら、メモをフレーズに安定して紐づけられます。

出典