网页里的“选中复制”到底选了个啥?

  • 2022 年 1 月 29 日
  • 笔记

当我们在网页中选中某一段内容的时候,浏览器会添加选中效果来标识选中的范围。我们可以从左到右选,也可以从右到左选。我们可以选择文字,也可以选择图片,或者选择图片加文字。浏览器是如何处理我们的选中操作的呢?

我最近在处理一个网页选中的问题,顺便研究了一下规范中是如何定义选中的,发现还是蛮有趣的,分享一下。

如何定义选中的范围

不管是从左到右,还是从右到左,又或者是单单选择一个字,我们都选中了一个范围。范围由两个边界点构成。

边界点

规范中是这么定义边界点(boundary point)的:

A boundary point is a tuple consisting of a node (a node) and an offset (a non-negative integer).

边界点由两部分组成,一个是边界点所在节点(node),另一个是边界点在节点中的偏移量。

<p>Hello, everfind!</p>

举个例子,上面的文本中,如果我们选择了 everfind 这段,那么边界点就是 (p, 7)(p, 15)

边界点之间有三种位置关系:前面、相等和后面。具体的位置计算方法可以参考规范定义。

范围

范围(range)由两个边界点来定义,两个边界点被称为开始(start)节点和结束(end)节点。

range 样例

上图展示了一段 DOM 结构,如果我们选中了 syndata is awes 这段文字,那么范围可以表示如下:

const range = new Range();
const firstText = p.childNodes[1]; // 假设 p 指向 P 元素
const secondText = em.firstChild; // 假设 em 指向 EM 元素
range.setStart(firstText, 9); // 前面有一个空格
range.setEnd(secondText, 4);

注意,在一个范围中,开始节点不能在结束节点的后面。如果开始节点与结束节点相等,则认为范围是折叠的(collapsed)。如果开始节点在结束节点后面,则范围会被强制置为折叠的。

在上面的样例中,我们使用了 Range 类,其实还有一个 StaticRange 类。Range 定义的范围会随着节点树的变化而变化,比如用户节点改变了节点树中的内容,那么通过 Range 定义的范围内容也会随着变化。而 StaticRange 定义的范围比较轻量,不会随着节点树的变化而变化。

选中

定义清楚“范围”以后,就能够定义“选中”这个概念了。在关于“选中”的规范中,规范要求,“选中”是一个单例对象,且只能与一个“范围”对象关联。如果“选中”没有与任何“范围”对象关联,那么“选中”就是空的(empty)。“选中”在初始状态下都是空的。

interface Selection {
  readonly attribute Node? anchorNode;
  readonly attribute unsigned long anchorOffset;
  readonly attribute Node? focusNode;
  readonly attribute unsigned long focusOffset;
  readonly attribute boolean isCollapsed;
  readonly attribute unsigned long rangeCount;
  readonly attribute DOMString type;
  Range getRangeAt(unsigned long index);
  undefined addRange(Range range);
  undefined removeRange(Range range);
  undefined removeAllRanges();
  ...
};

上面摘录了一些 Selection 接口 的字段说明,我们可以看到,Selection 对象中并不是直接保存 Range 对象,而是通过 anchorNodeanchorOffsetfocusNodefocusOffset 来标识选中的范围。

我们知道,用户选择是有方向的,而 Range 中的开始节点与结束节点是严格按照 DOM 顺序的,因此 Selection 做了变通。anchorNode 是用户开始选择的位置,focusNode 是用户结束选择的位置。如果您使用桌面鼠标进行选择,则 anchorNode 放置在您按下鼠标按钮的位置,focusNode 放置在您释放鼠标按钮的位置。千万不要将 anchorNodefocusNodeRange 的开始节点、结束节点相混淆。

我们看到,在 Selection 接口中定义了一些 Range 操作函数,看着像是可以有多个 Range。其实 Selection API 最初由 Netscape 创建并允许多个范围(例如,允许用户从 <table> 中选择一列)。但是,Gecko 以外的浏览器并没有实现多个范围。规范最终要求 Selection 始终具有单个 Range

在浏览器中,我们可以通过 document.getSelection()window.getSelection() 来获取选中的内容,这两个 API 是相等的。通过 Selection 接口中定义的操作函数,我们可以动态需改选中的内容。

deeplinks 是一个选中内容分享工具。它通过 Selection API,将用户选中的文本计算出一个 hash 值,然后将这个 hash 值放到 URL 中的 fragment 部分。用户打开这个新的 URL 时,deeplinks 会通过 fragment 计算出选中的范围,通过 Selection API 高亮选中的文字。

在其核心 API selectionToFragment 函数中,

export function selectionToFragment(selection: Selection): string {
  type HashNodeOffset = [string, Text, string];
  type DupeData = [boolean[], number, number];
  const ranges: [HashNodeOffset, HashNodeOffset, DupeData][] = [];
  for (let i = 0; i < selection.rangeCount; i++) {
    const range = normalizeRange(selection.getRangeAt(i)); // normalizeRange 去除非 Text 节点
    if (range && !range.collapsed) {
      const [startNode, endNode] = [range.startContainer, range.endContainer];
      if (startNode.nodeType == TEXT_NODE && endNode.nodeType == TEXT_NODE) {
        ranges.push([
          [hashNode(startNode as Text), startNode as Text, fromNumber(Math.max(range.startOffset - countLeadingWhitespace(startNode as Text), 0))],
          [hashNode(endNode as Text), endNode as Text, fromNumber(Math.max(Math.min(range.endOffset - countLeadingWhitespace(endNode as Text), (endNode as Text).wholeText.trim().length), 0))],
          [[], 0, 0],
        ]);
      }
    }
  }
  ...
}

我们可以看到,通过 selection.getRangeAt(i) 获取当前选中范围,然后通过一些操作过滤掉非 Text 节点,拿到只包含文本的新的 Range。之后经过一些计算,标记出用户选中的真实范围。

由于 deeplinks 本身实现逻辑较为复杂,与本篇主题有一些偏离,这里没有详细说明,感兴趣的同学可以自行阅读源码。

常见面试知识点、技术方案分析、教程,都可以扫码关注公众号“众里千寻”获取,或者来这里 //everfind.github.io/posts/
众里千寻