2022 年 10 月 30 日

選取和範圍

在本章節中,我們將介紹文件中的選取,以及表單欄位中的選取,例如 <input>

JavaScript 可以存取現有的選取,選取/取消選取 DOM 節點的全部或部分,從文件中移除選取的內容,將其包裝成標籤,等等。

您可以在章節結尾的「摘要」區段中找到一些常見任務的範例。也許這涵蓋了您目前的需要,但如果您閱讀全文,您將獲得更多收穫。

底層的 RangeSelection 物件很容易理解,然後您就不需要任何範例就能讓它們執行您想要的操作。

範圍

選取的基本概念是 範圍,它基本上是一對「邊界點」:範圍開始和範圍結束。

Range 物件是在沒有參數的情況下建立的

let range = new Range();

然後,我們可以使用 range.setStart(node, offset)range.setEnd(node, offset) 來設定選取範圍。

正如你所猜測的,我們將進一步使用 Range 物件來進行選取,但首先讓我們建立幾個這樣的物件。

部分選取文字

有趣的是,這兩個方法中的第一個引數 node 可以是文字節點或元素節點,而第二個引數的意義取決於此。

如果 node 是文字節點,則 offset 必須是其文字中的位置。

例如,給定元素 <p>Hello</p>,我們可以建立包含字母「ll」的範圍,如下所示

<p id="p">Hello</p>
<script>
  let range = new Range();
  range.setStart(p.firstChild, 2);
  range.setEnd(p.firstChild, 4);

  // toString of a range returns its content as text
  console.log(range); // ll
</script>

這裡我們取 <p> 的第一個子節點(即文字節點),並指定其內部的文字位置

選取元素節點

或者,如果 node 是元素節點,則 offset 必須是子節點的編號。

這對於建立包含節點整體的範圍非常方便,而不是停在文字內部的某個地方。

例如,我們有一個更複雜的文件片段

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

以下是其包含元素節點和文字節點的 DOM 結構

讓我們為 "範例:<i>斜體</i>" 建立一個範圍。

正如我們所看到的,這個片語恰好包含 <p> 的兩個子節點,索引為 01

  • 起點的父節點為 <p>node0

    因此,我們可以將其設定為 range.setStart(p, 0)

  • 終點的父節點也為 <p>node2(它指定範圍直到,但不包含 offset)。

    因此,我們可以將其設定為 range.setEnd(p, 2)

以下是範例。如果你執行它,你可以看到文字被選取

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

<script>
  let range = new Range();

  range.setStart(p, 0);
  range.setEnd(p, 2);

  // toString of a range returns its content as text, without tags
  console.log(range); // Example: italic

  // apply this range for document selection (explained later below)
  document.getSelection().addRange(range);
</script>

這裡有一個更靈活的測試台,你可以在其中設定範圍的開始/結束數字並探索其他變體

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

From <input id="start" type="number" value=1> – To <input id="end" type="number" value=4>
<button id="button">Click to select</button>
<script>
  button.onclick = () => {
    let range = new Range();

    range.setStart(p, start.value);
    range.setEnd(p, end.value);

    // apply the selection, explained later below
    document.getSelection().removeAllRanges();
    document.getSelection().addRange(range);
  };
</script>

例如,在同一個 <p> 中從偏移量 14 選取會給我們範圍 <i>斜體</i> 和 <b>粗體</b>

開始和結束節點可以不同

我們不必在 setStartsetEnd 中使用同一個節點。一個範圍可以跨越許多不相關的節點。重要的是,結束點在文件中必須在開始點之後。

選取更大的片段

讓我們在範例中進行更大的選取,如下所示

我們已經知道如何這樣做。我們只需要將開始和結束設定為文字節點中的相對偏移量即可。

我們需要建立一個範圍,它

  • <p> 第一個子節點中的位置 2 開始(取「範例:」的前兩個字母以外的所有字母)
  • 結束於 <b> 第一個子節點中的位置 3(取「體」的前三個字母,但不再取更多)
<p id="p">Example: <i>italic</i> and <b>bold</b></p>

<script>
  let range = new Range();

  range.setStart(p.firstChild, 2);
  range.setEnd(p.querySelector('b').firstChild, 3);

  console.log(range); // ample: italic and bol

  // use this range for selection (explained later)
  window.getSelection().addRange(range);
</script>

正如你所看到的,建立我們想要的任何範圍都相當容易。

如果我們想取節點整體,我們可以在 setStart/setEnd 中傳遞元素。否則,我們可以在文字層級上工作。

範圍屬性

在上述範例中建立的範圍物件具有下列屬性

  • startContainerstartOffset – 起始節點和偏移量,
    • 在上述範例中:<p> 內的第一個文字節點和 2
  • endContainerendOffset – 結束節點和偏移量,
    • 在上述範例中:<b> 內的第一個文字節點和 3
  • collapsed – 布林值,如果範圍開始和結束於同一點(因此範圍內沒有內容),則為 true
    • 在上述範例中:false
  • commonAncestorContainer – 範圍內所有節點最近的共用祖先,
    • 在上述範例中:<p>

範圍選取方法

有許多便利的方法可操作範圍。

我們已經看過 setStartsetEnd,以下是其他類似的函式。

設定範圍開始

  • setStart(node, offset)node 中的 offset 位置設定開始
  • setStartBefore(node)node 正前方設定開始
  • setStartAfter(node)node 正後方設定開始

設定範圍結束(類似的方法)

  • setEnd(node, offset)node 中的 offset 位置設定結束
  • setEndBefore(node)node 正前方設定結束
  • setEndAfter(node)node 正後方設定結束

技術上來說,setStart/setEnd 可以執行任何動作,但更多的方法提供了更多便利性。

在所有這些方法中,node 可以是文字節點或元素節點:對於文字節點,offset 會跳過這麼多個字元,而對於元素節點,則會跳過這麼多個子節點。

更多建立範圍的方法

  • selectNode(node) 設定範圍以選取整個 node
  • selectNodeContents(node) 設定範圍以選取整個 node 內容
  • collapse(toStart) 如果 toStart=true,則設定結束等於開始,否則設定開始等於結束,因此範圍會縮小
  • cloneRange() 建立一個具有相同開始/結束的新範圍

範圍編輯方法

建立範圍後,我們可以使用這些方法來操作其內容

  • deleteContents() – 從文件中移除範圍內容
  • extractContents() – 從文件中移除範圍內容,並以 DocumentFragment 的形式傳回
  • cloneContents() – 複製範圍內容,並以 DocumentFragment 的形式傳回
  • insertNode(node) – 在範圍開頭將 node 插入文件中
  • surroundContents(node) – 將 node 包圍在範圍內容周圍。為此,範圍必須包含其內部所有元素的開啟和關閉標籤:沒有像 <i>abc 這樣的部分範圍。

使用這些方法,我們基本上可以對選取的節點執行任何操作。

以下是查看它們實際運作的測試台

Click buttons to run methods on the selection, "resetExample" to reset it.

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

<p id="result"></p>
<script>
  let range = new Range();

  // Each demonstrated method is represented here:
  let methods = {
    deleteContents() {
      range.deleteContents()
    },
    extractContents() {
      let content = range.extractContents();
      result.innerHTML = "";
      result.append("extracted: ", content);
    },
    cloneContents() {
      let content = range.cloneContents();
      result.innerHTML = "";
      result.append("cloned: ", content);
    },
    insertNode() {
      let newNode = document.createElement('u');
      newNode.innerHTML = "NEW NODE";
      range.insertNode(newNode);
    },
    surroundContents() {
      let newNode = document.createElement('u');
      try {
        range.surroundContents(newNode);
      } catch(e) { console.log(e) }
    },
    resetExample() {
      p.innerHTML = `Example: <i>italic</i> and <b>bold</b>`;
      result.innerHTML = "";

      range.setStart(p.firstChild, 2);
      range.setEnd(p.querySelector('b').firstChild, 3);

      window.getSelection().removeAllRanges();
      window.getSelection().addRange(range);
    }
  };

  for(let method in methods) {
    document.write(`<div><button onclick="methods.${method}()">${method}</button></div>`);
  }

  methods.resetExample();
</script>

還有比較範圍的方法,但這些方法很少使用。當您需要時,請參閱 規格MDN 手冊

選取

Range 是用於管理選取範圍的通用物件。不過,建立 Range 並不表示我們會在螢幕上看到選取。

我們可以建立 Range 物件,並將它們傳遞出去,它們本身並不會在視覺上選取任何內容。

文件選取由 Selection 物件表示,可以作為 window.getSelection()document.getSelection() 取得。選取可能包含零個或多個範圍。至少,選取 API 規格 是這麼說的。但在實務上,只有 Firefox 允許使用 Ctrl+click (Cmd+click 適用於 Mac) 在文件中選取多個範圍。

以下是在 Firefox 中建立 3 個範圍的選取的螢幕截圖

其他瀏覽器最多支援 1 個範圍。正如我們將看到的,有些 Selection 方法暗示可能有多個範圍,但同樣地,在 Firefox 以外的所有瀏覽器中,最多只有 1 個。

以下是一個小範例,顯示目前的選取(選取一些內容並按一下)為文字

選取屬性

如前所述,選取理論上可能包含多個範圍。我們可以使用下列方法取得這些範圍物件

  • getRangeAt(i) - 取得第 i 個範圍,從 0 開始。在 Firefox 以外的所有瀏覽器中,只使用 0

此外,還有一些屬性通常提供更好的便利性。

與範圍類似,選取物件有一個開始,稱為「錨點」,以及一個結束,稱為「焦點」。

主要的選取屬性為

  • anchorNode - 選取開始的節點,
  • anchorOffset - 選取在 anchorNode 中開始的偏移量,
  • focusNode - 選取結束的節點,
  • focusOffset - 選取在 focusNode 中結束的偏移量,
  • isCollapsed - 如果選取沒有選取任何內容(空範圍),或不存在,則為 true
  • rangeCount - 選取中的範圍數量,在 Firefox 以外的所有瀏覽器中最多為 1
選取結束/開始與範圍

選取錨點/焦點與 Range 開始/結束之間有重要的差異。

如我們所知,Range 物件的開始總是在結束之前。

對於選取,並非總是如此。

使用滑鼠選取內容可以朝兩個方向進行:從「左到右」或「右到左」。

換句話說,當按下滑鼠按鈕,然後在文件中向前移動時,其結束位置(焦點)會在開始位置(錨點)之後。

例如,如果使用者從「範例」開始,使用滑鼠選取到「斜體」

…但也可以反向進行相同的選取:從「斜體」開始到「範例」(反向),則其結束位置(焦點)會在開始位置(錨點)之前

選取事件

有一些事件可供追蹤選取

  • elem.onselectstart – 當選取特別在元素 elem(或其內部)開始時。例如,當使用者在元素上按下滑鼠按鈕並開始移動指標時。
    • 防止預設動作會取消選取開始。因此,從此元素開始選取將變得不可能,但元素仍然可選取。訪客只需從其他地方開始選取即可。
  • document.onselectionchange – 每當選取變更或開始時。
    • 請注意:此處理常式只能設定在 document 上,它會追蹤其中的所有選取。

選取追蹤示範

以下是一個小示範。它會追蹤 document 上目前的選取,並顯示其邊界

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

From <input id="from" disabled> – To <input id="to" disabled>
<script>
  document.onselectionchange = function() {
    let selection = document.getSelection();

    let {anchorNode, anchorOffset, focusNode, focusOffset} = selection;

    // anchorNode and focusNode are text nodes usually
    from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
    to.value = `${focusNode?.data}, offset ${focusOffset}`;
  };
</script>

選取複製示範

有兩種方法可以複製選取的內容

  1. 我們可以使用 document.getSelection().toString() 將其取得為文字。
  2. 否則,若要複製完整的 DOM,例如,如果我們需要保留格式,可以使用 getRangeAt(...) 取得基礎範圍。Range 物件反過來有 cloneContents() 方法,可以複製其內容並傳回 DocumentFragment 物件,我們可以將其插入其他地方。

以下示範如何將選取的內容複製為文字和 DOM 節點

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

Cloned: <span id="cloned"></span>
<br>
As text: <span id="astext"></span>

<script>
  document.onselectionchange = function() {
    let selection = document.getSelection();

    cloned.innerHTML = astext.innerHTML = "";

    // Clone DOM nodes from ranges (we support multiselect here)
    for (let i = 0; i < selection.rangeCount; i++) {
      cloned.append(selection.getRangeAt(i).cloneContents());
    }

    // Get as text
    astext.innerHTML += selection;
  };
</script>

選取方法

我們可以透過新增/移除範圍來處理選取

  • getRangeAt(i) - 取得第 i 個範圍,從 0 開始。在 Firefox 以外的所有瀏覽器中,只使用 0
  • addRange(range) – 將 range 新增到選取。除了 Firefox 以外的所有瀏覽器都會忽略此呼叫,如果選取已經有相關聯的範圍。
  • removeRange(range) – 從選取中移除 range
  • removeAllRanges() – 移除所有範圍。
  • empty()removeAllRanges 的別名。

還有一些方便的方法可以直接處理選取範圍,而不需要中間的 Range 呼叫

  • collapse(node, offset) – 以一個新的範圍取代選取的範圍,該範圍從指定的 node 的位置 offset 開始和結束。
  • setPosition(node, offset)collapse 的別名。
  • collapseToStart() – 縮合(以一個空範圍取代)到選取開始
  • collapseToEnd() – 縮合到選取結束
  • extend(node, offset) – 將選取的焦點移到指定的 node 的位置 offset
  • setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) – 以指定的開始 anchorNode/anchorOffset 和結束 focusNode/focusOffset 取代選取範圍。它們之間的所有內容都會被選取。
  • selectAllChildren(node) – 選取 node 的所有子節點。
  • deleteFromDocument() – 從文件中移除選取的內容。
  • containsNode(node, allowPartialContainment = false) – 檢查選取是否包含 node(如果第二個參數為 true,則為部分包含)

對於大多數任務,這些方法就夠用了,不需要存取底層的 Range 物件。

例如,選取段落 <p> 的全部內容

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

<script>
  // select from 0th child of <p> to the last child
  document.getSelection().setBaseAndExtent(p, 0, p, p.childNodes.length);
</script>

使用範圍選取相同內容

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

<script>
  let range = new Range();
  range.selectNodeContents(p); // or selectNode(p) to select the <p> tag too

  document.getSelection().removeAllRanges(); // clear existing selection if any
  document.getSelection().addRange(range);
</script>
若要選取某個內容,請先移除現有的選取。

如果文件選取已存在,請先使用 removeAllRanges() 清空它。然後再新增範圍。否則,除了 Firefox 之外的所有瀏覽器都會忽略新的範圍。

例外情況是某些選取方法,例如 setBaseAndExtent,會取代現有的選取。

表單控制項中的選取

表單元素,例如 inputtextarea,提供 特殊的選取 API,不使用 SelectionRange 物件。由於輸入值是純文字,而不是 HTML,因此不需要這些物件,一切都簡單多了。

屬性

  • input.selectionStart – 選取起點位置(可寫入),
  • input.selectionEnd – 選取終點位置(可寫入),
  • input.selectionDirection – 選取方向,其中之一:「forward」、「backward」或「none」(例如,如果使用滑鼠雙擊選取),

事件

  • input.onselect – 當選取某個內容時觸發。

方法

  • input.select() – 選取文字控制項中的所有內容(可以是 textarea,而不是 input),

  • input.setSelectionRange(start, end, [direction]) – 將選取變更為從位置 start 延伸到 end,在給定的方向(可選)。

  • input.setRangeText(replacement, [start], [end], [selectionMode]) – 使用新文字取代一段文字範圍。

    如果提供了選用的參數 startend,則設定範圍的起點和終點,否則使用使用者的選取。

    最後一個參數 selectionMode 決定在文字被取代後如何設定選取。可能的數值為

    • "select" – 新插入的文字將會被選取。
    • "start" – 選取範圍會在插入的文字之前收合(游標會在文字之前)。
    • "end" – 選取範圍會在插入的文字之後收合(游標會在文字之後)。
    • "保留" – 嘗試保留選取。這是預設值。

現在讓我們看看這些方法的實際應用。

範例:追蹤選取

例如,這個程式碼使用 onselect 事件追蹤選取

<textarea id="area" style="width:80%;height:60px">
Selecting in this text updates values below.
</textarea>
<br>
From <input id="from" disabled> – To <input id="to" disabled>

<script>
  area.onselect = function() {
    from.value = area.selectionStart;
    to.value = area.selectionEnd;
  };
</script>

請注意

  • onselect 在選取某個項目時觸發,但選取移除時不會觸發。
  • 根據 規範document.onselectionchange 事件不應觸發表單控制項內的選取,因為這與 document 選取和範圍無關。有些瀏覽器會產生它,但我們不應依賴它。

範例:移動游標

我們可以變更設定選取的 selectionStartselectionEnd

一個重要的邊界情況是 selectionStartselectionEnd 相等。這時就是游標位置。或者換句話說,當沒有選取任何內容時,選取會在游標位置處縮合。

因此,透過將 selectionStartselectionEnd 設定為相同的值,我們可以移動游標。

例如

<textarea id="area" style="width:80%;height:60px">
Focus on me, the cursor will be at position 10.
</textarea>

<script>
  area.onfocus = () => {
    // zero delay setTimeout to run after browser "focus" action finishes
    setTimeout(() => {
      // we can set any selection
      // if start=end, the cursor is exactly at that place
      area.selectionStart = area.selectionEnd = 10;
    });
  };
</script>

範例:修改選取

若要修改選取的內容,我們可以使用 input.setRangeText() 方法。當然,我們可以讀取 selectionStart/End,並根據選取的知識變更 value 的對應子字串,但 setRangeText 更強大,而且通常更方便。

這是一個有點複雜的方法。在其最簡單的單一參數形式中,它會取代使用者選取的範圍並移除選取。

例如,這裡使用者選取的內容會被 *...* 包圍

<input id="input" style="width:200px" value="Select here and click the button">
<button id="button">Wrap selection in stars *...*</button>

<script>
button.onclick = () => {
  if (input.selectionStart == input.selectionEnd) {
    return; // nothing is selected
  }

  let selected = input.value.slice(input.selectionStart, input.selectionEnd);
  input.setRangeText(`*${selected}*`);
};
</script>

透過更多參數,我們可以設定範圍的 開始結束

在這個範例中,我們在輸入文字中找到 "THIS",取代它並保持取代後的選取狀態

<input id="input" style="width:200px" value="Replace THIS in text">
<button id="button">Replace THIS</button>

<script>
button.onclick = () => {
  let pos = input.value.indexOf("THIS");
  if (pos >= 0) {
    input.setRangeText("*THIS*", pos, pos + 4, "select");
    input.focus(); // focus to make selection visible
  }
};
</script>

範例:在游標處插入

如果沒有選取任何內容,或者我們在 setRangeText 中使用相等的 開始結束,則新文字會直接插入,不會移除任何內容。

我們也可以使用 setRangeText"游標處" 插入一些內容。

這裡有一個按鈕會在游標位置插入 "HELLO",並將游標放在它之後。如果選取不為空,則會取代它(我們可以透過比較 selectionStart!=selectionEnd 來偵測它,並執行其他操作)

<input id="input" style="width:200px" value="Text Text Text Text Text">
<button id="button">Insert "HELLO" at cursor</button>

<script>
  button.onclick = () => {
    input.setRangeText("HELLO", input.selectionStart, input.selectionEnd, "end");
    input.focus();
  };
</script>

無法選取

要讓某個東西無法選取,有以下三種方法

  1. 使用 CSS 屬性 user-select: none

    <style>
    #elem {
      user-select: none;
    }
    </style>
    <div>Selectable <div id="elem">Unselectable</div> Selectable</div>

    這無法讓選取從 elem 開始。但使用者可以在其他地方開始選取,並將 elem 包含在其中。

    然後 elem 會變成 document.getSelection() 的一部分,因此選取實際上會發生,但其內容通常會在複製貼上時被忽略。

  2. onselectstartmousedown 事件中防止預設動作。

    <div>Selectable <div id="elem">Unselectable</div> Selectable</div>
    
    <script>
      elem.onselectstart = () => false;
    </script>

    這會防止在 elem 上開始選取,但訪客可以在另一個元素上開始選取,然後延伸到 elem

    當同一個動作上有另一個事件處理常式會觸發選取(例如 mousedown)時,這很方便。因此我們停用選取以避免衝突,同時仍允許複製 elem 內容。

  3. 我們還可以在選取發生後使用 document.getSelection().empty() 來清除選取。這很少使用,因為這會導致選取出現和消失時產生不需要的閃爍。

參考文獻

摘要

我們介紹了兩個不同的選取 API

  1. 對於文件:SelectionRange 物件。
  2. 對於 inputtextarea:其他方法和屬性。

第二個 API 非常簡單,因為它使用文字。

最常用的範例可能是

  1. 取得選取
    let selection = document.getSelection();
    
    let cloned = /* element to clone the selected nodes to */;
    
    // then apply Range methods to selection.getRangeAt(0)
    // or, like here, to all ranges to support multi-select
    for (let i = 0; i < selection.rangeCount; i++) {
      cloned.append(selection.getRangeAt(i).cloneContents());
    }
  2. 設定選取
    let selection = document.getSelection();
    
    // directly:
    selection.setBaseAndExtent(...from...to...);
    
    // or we can create a range and:
    selection.removeAllRanges();
    selection.addRange(range);

最後,關於游標。在可編輯元素(例如 <textarea>)中的游標位置總是位於選取的開始或結束處。我們可以使用它來取得游標位置或透過設定 elem.selectionStartelem.selectionEnd 來移動游標。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有建議要改進的地方 - 請 提交 GitHub 問題 或提交 pull 要求,而不是留言。
  • 如果你無法理解文章中的某個部分 - 請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤;若要插入多行程式碼,請將它們包在 <pre> 標籤中;若要插入 10 行以上的程式碼,請使用沙盒 (plnkrjsbincodepen…)