將註解錨定至 DOM Range,然後使用 Range 渲染出的片段矩形來定位標籤。本指南展示一個簡單的 Mac 桌面瀏覽器示範,當片語跨越行內元素並換成多行時,仍能讓註記附著在文字上。

任務很明確:在一般網頁版面中,將一則可見註解附加到某個片語,而不重建其文字。你將找出該片語、選擇標籤所依據的片段,並在版面變更後重新測量。MDN 將 Range.getClientRects() 說明為瀏覽器用來取得 Range 所佔矩形的 API。Range: getClientRects() method。

為什麼標籤會偏離換行文字?

片語有兩種相關表示方式:它在文件中的位置,以及它目前在畫面上的幾何位置。WHATWG 將 Range 定義為由起點與終點邊界點構成,每個邊界點都包含一個節點和一個偏移量。這些端點可以位於不同的文字節點,因此單一 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 Console。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,即可在兩種段落寬度之間切換。同一個 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 文字偏移量以程式碼單位計算;它們不是可見字形的數量。請讓端點對齊完整且預期的字元,不要假設每個顯示字元都只佔一個偏移量。DOM Standard。

醒目標示是選用功能。MDN 將 Highlight 列為 Baseline 2025,並指出舊版瀏覽器可能不支援。若無法使用醒目標示,示範中的功能檢查仍會保留註解位置,並顯示說明狀態訊息。Highlight。

頁面重新排版時必須更新什麼?

此示範會在段落調整大小、視窗大小變更和捲動時重新測量。ResizeObserver 會偵測元素尺寸變更,包括與瀏覽器視窗大小變更無關的變更。此處的回呼只會移動獨立標籤,避免反覆改變受測段落尺寸而形成回饋迴圈。ResizeObserver。

字型載入需要另外更新。程式會立即測量,並在 document.fonts.ready 完成時再次測量。MDN 指出,此 Promise 會在文件字型載入及相關版面操作完成後解析。之後若應用程式變更字型或內容,應明確要求再次測量。FontFaceSet: ready property。

請讓座標維持在同一個系統中。此示範在空白頁面上使用固定定位標籤,並直接套用用戶端座標。若加入文件捲動偏移量,座標就會轉換為文件座標。MDN 說明,用戶端矩形是相對於視窗的;若要取得相對於文件的座標,則需加入捲動偏移量。Element: getBoundingClientRect() method。

這個小型範例會限制水平位置,但不會處理垂直碰撞避讓、畫面外隱藏或任意轉換容器。重新載入或完全替換文字後,它也不會還原註解。對於應用程式,請保留識別預期段落的方法,在渲染後重建 Range,然後再次測量。

何時應使用 Floating UI?

當標籤需要能避開裁切的定位方式和受管理的更新生命週期時,請使用 Floating UI。其文件說明的虛擬參照介面可透過兩個方法支援 Range:getBoundingClientRect() 回傳 Range 的外圍矩形,getClientRects() 回傳其片段矩形。使用包含文字的真實元素設定 contextElement,讓裁切和更新偵測能取得元素環境。Virtual Elements。

將該虛擬參照傳給 computePosition(),並將 inline() 放在中介軟體陣列前段,位於 flip() 之前。讓浮動元素的 CSS 定位方式符合所選策略。文件說明,當空間不足時,可用 flip() 切換側邊,並用 shift() 在裁切範圍內移動標籤。inline、computePosition。

只有在註解已掛載時才啟動 autoUpdate(),並在移除標籤時呼叫它回傳的清理函式。其文件說明的更新機制涵蓋祖先捲動、尺寸變更和版面位移。如果應用程式變更會改變所選文字的片段,卻未改變受觀察容器的尺寸,仍需觸發更新。autoUpdate。

診斷仍然顯示異常的註解

依序完成以下檢查,避免幾何位置問題掩蓋文字目標問題。

  1. 確認片語。 檢查 range.toString(),並在調整座標前確認兩個端點。
  2. 檢視片段。 記錄 Array.from(range.getClientRects())。多個重疊項目可能來自巢狀行內內容;不要將項目數解讀為行數。
  3. 檢查定位規則。 外圍矩形、最後一個片段和依指標選取的片段,分別適用於不同的註解行為。
  4. 檢查座標系統。 對於空白頁面示範,請讓固定定位標籤使用用戶端座標。
  5. 版面變更後再次檢查。 變更寬度、捲動、調整大小,並等待字型載入完成。如果應用程式替換了文字節點,請重建 Range。
  6. 檢查版面假設。 若使用直排文字、混合方向、多欄或轉換容器,請先定義並測試合適的片段選取規則,再沿用示範中的最後片段捷徑。

可靠的註解始於精確的文字 Range 和明確的定位規則。讓瀏覽器負責換行與字形塑形,並在相關版面變更時重新測量渲染出的片段。這樣註記便能穩定地對應片語,同時讓文字自然流動。

來源