若要在保留現有行內元素的同時包住文字範圍,請將每個選取的文字片段各自包在一個 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 → 設定⋯ → 進階,並啟用 顯示網頁開發者功能。開啟 開發 → 顯示網頁檢查器,或按下 Option–Command–I。這些步驟是 WebKit 文件所述的方式。啟用網頁檢查器

選取 主控台 分頁。在提示列中執行下方完整的 JavaScript 程式。WebKit 文件指出,主控台提示列會在受檢查頁面的所選執行環境中評估 JavaScript。本範例請使用頁面的頂層環境。網頁檢查器介面

不需要函式庫、匯入、建置步驟或現有文件內容。此程式會建立自己的段落、包住預先設定的範圍、在包住的文字上還原向前選取範圍,並新增一個移除按鈕。它支援的輸入範圍刻意設得很窄:一個段落,其中包含文字和一般的 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 ,並巢狀包含一個內容為 blue 的 em 元素。範圍從 bright 中的偏移量 3 開始,並在 gamma delta. 中的偏移量 6 結束。這些是程式定義的示範輸入。

預期的主控台輸出包含三個片段:

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

第一個 span 會成為原有 strong 元素的子元素。第二個會成為原有 em 元素的子元素。第三個會成為段落的子元素。依文件順序讀取它們的文字,會得到所選取的字串 ght blue gamma。未選取的前綴 bri 和後綴 delta. 會留在包裝元素之外。

遍歷程序使用 NodeFilter.SHOW_TEXT 搭配 createTreeWalker() 來存取子孫文字節點。intersectsNode() 會找出候選節點,而邊界偏移量則會裁切第一個和最後一個候選節點。長度為零的片段會予以排除。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,但不保證瀏覽器處於特定的選取狀態。

按一下 移除 spans 會取消包裝僅在該次執行中建立的 span,並清除選取範圍。它會保留已分割的文字節點。此移除操作會還原原有的元素階層和文字,但不會精確還原文字節點的身分。如果其他程式碼依賴這些參照,請避免自動正規化。

調整範例時,別在不知不覺中擴大適用範圍

對於使用者拖曳選取的內容,只有在確認 window.getSelection() 傳回選取範圍,且 rangeCount 不為零之後,才取得其範圍。getRangeAt(0) 會傳回第一個範圍;指定超出選取範圍數量的索引會產生錯誤。Selection: getRangeAt() method

只有在範圍的兩個邊界容器都是目標段落內的文字節點時,才將該範圍傳給相同的輔助函式。若選取範圍的邊界容器是元素,程式會在變更前拒絕處理。若要支援這類邊界,必須明確定義一套將邊界轉換為子孫文字位置的額外程序;單純移除防護條件並不會實作這項功能。

輔助函式也會在變更前拒絕不支援的子孫元素和現有的標記包裝元素。若要建立重複或重疊的註記,請先定義新的 span 應如何巢狀排列、合併或取代先前的標記。此範例刻意明確呈現這項決策,避免在沒有規則的情況下不斷累加包裝元素。

偏移量以 UTF-16 代碼單位為單位。若要計算含多單位字元的文字位置,請確保邊界不會切開預期的字元序列。合成範例使用簡單的拉丁文字,因此數字偏移量沒有歧義。Text: splitText() method

對於這項任務,實用的不變條件很簡單:新 span 文字的串接結果必須等於原先選取的字串、段落的完整文字必須保持不變,而且現有行內元素必須保留其身分和祖先關係。調整範例時請檢查這三項;它們共同說明成功保留結構的意義。

來源