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 结构,包含元素节点和文本节点

让我们为"Example: <i>italic</i>"创建一个范围。

正如我们所见,这个短语正好包含<p>的两个子节点,索引分别为01

  • 起点以<p>作为父node0作为偏移量。

    因此我们可以将其设置为range.setStart(p, 0)

  • 终点也以<p>作为父node,但偏移量为2(它指定了直到但不包括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>italic</i> and <b>bold</b>

开始节点和结束节点可以不同

我们不必在setStartsetEnd中使用相同的节点。一个范围可以跨越许多不相关的节点。重要的是,结束位置在文档中必须在开始位置之后。

选择更大的片段

让我们在我们的示例中进行更大的选择,就像这样

我们已经知道如何做到这一点。我们只需要在文本节点中设置开始和结束作为相对偏移量。

我们需要创建一个范围,它

  • <p>第一个子节点中的位置 2 开始(取“Example: ”的前两个字母以外的所有字母)
  • <b>第一个子节点中的位置 3 结束(取“bold”的前三个字母,但不再多)
<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中传递元素。否则,我们可以在文本级别上进行操作。

范围属性

我们在上面示例中创建的范围对象具有以下属性

  • startContainer, startOffset – 起始位置的节点和偏移量,
    • 在上面的示例中:<p> 内部的第一个文本节点和 2
  • endContainer, endOffset – 结束位置的节点和偏移量,
    • 在上面的示例中:<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,则设置 end=start,否则设置 start=end,从而折叠范围
  • 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 for Mac) 在文档中选择多个范围。

以下是在 Firefox 中创建的包含 3 个范围的选择的屏幕截图

其他浏览器最多支持 1 个范围。正如我们将看到的,一些 Selection 方法暗示可能存在多个范围,但同样,在除 Firefox 之外的所有浏览器中,最多只有 1 个范围。

以下是一个小型演示,它以文本形式显示当前选择(选择一些内容并单击)

选择属性

如前所述,选择理论上可以包含多个范围。我们可以使用以下方法获取这些范围对象

  • getRangeAt(i) – 获取第 i 个范围,从 0 开始。在除 Firefox 之外的所有浏览器中,只使用 0

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

与范围类似,选择对象也有一个起点,称为“锚点”,和一个终点,称为“焦点”。

主要的选择属性是

  • anchorNode – 选择开始处的节点,
  • anchorOffset – 选择在 anchorNode 中开始处的偏移量,
  • focusNode – 选择结束处的节点,
  • focusOffset – 选择在 focusNode 中结束处的偏移量,
  • isCollapsed – 如果选择未选择任何内容(空范围),或不存在,则为 true
  • rangeCount – 选择范围的计数,在所有浏览器中最大值为 1,除了 Firefox。
选择结束/开始与范围

选择锚点/焦点与 Range 开始/结束之间存在重要区别。

众所周知,Range 对象的开始始终在结束之前。

对于选择,情况并非总是如此。

使用鼠标选择可以在两个方向上进行:“从左到右”或“从右到左”。

换句话说,当按下鼠标按钮,然后在文档中向前移动时,其结束(焦点)将在其开始(锚点)之后。

例如,如果用户从鼠标开始选择并从“Example”移动到“italic”

…但是相同的选择可以反向进行:从“italic”开始到“Example”(反向方向),然后其结束(焦点)将在开始(锚点)之前。

选择事件

有一些事件可以跟踪选择。

  • 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]) – 将选择范围更改为从位置 startend,在给定方向(可选)。

  • input.setRangeText(replacement, [start], [end], [selectionMode]) – 用新文本替换一段文本。

    可选参数 startend,如果提供,则设置范围的开始和结束,否则使用用户选择。

    最后一个参数 selectionMode 决定文本替换后如何设置选择。可能的值是

    • "select" – 新插入的文本将被选中。
    • "start" – 选择范围折叠到插入文本之前(光标将位于插入文本之前)。
    • "end" – 选择范围折叠到插入文本之后(光标将位于插入文本之后)。
    • "preserve" – 尝试保留选择。这是默认值。

现在让我们看看这些方法的实际应用。

示例:跟踪选择

例如,这段代码使用 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>

使用更多参数,我们可以设置范围startend

在这个例子中,我们在输入文本中找到"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中使用相同的startend,那么新文本只是被插入,没有任何内容被删除。

我们也可以使用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 问题或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code>标签,对于多行代码,请使用<pre>标签,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)