将注释锚定到 DOM Range,然后使用该范围渲染出的片段矩形来定位标签。本指南展示了一个简单的 Mac 桌面浏览器演示:当短语跨越行内元素并换成多行时,注释仍会附着在原处。

任务很明确:在普通网页布局中,将一个可见注释附加到某个短语上,而不重建其文本。你将识别该短语、选择一个片段来放置标签,并在布局发生变化后重新测量。MDN 将 Range.getClientRects() 记载为浏览器 API,用于获取某个范围所占据的矩形。Range: getClientRects() method。

为什么标签会偏离换行后的文本?

一个短语有两种相关的表示方式:它在文档中的位置,以及它当前在屏幕上的几何位置。WHATWG 将范围定义为起始和结束边界点,每个边界点由一个节点和一个偏移量构成。这些端点可以位于不同的文本节点中,因此一个范围可以跨越已有的强调元素。DOM Standard。

单个边界矩形会丢失细节。设想一个短语从某行中间开始,在下一行开头附近结束。它的外接矩形会包含两行旁边的空白。因此,居中放在该矩形上的标签可能看起来与文字脱节。Floating UI 明确指出,多行链接和范围选区都会遇到这个问题。inline。

单个矩形也需要正确解读。W3C 规定,范围可以同时返回所选元素的边框矩形和文本矩形,并按内容顺序排列。因此,嵌套元素可能产生重叠矩形;矩形数量并不能可靠地表示行数。文本边界依据字体度量计算,而不是精确的字形绘制轮廓。CSSOM View Module。

应该将注释放在哪个片段上?

编写定位代码之前,先选定放置规则。下面的决策参考将被注释的文本与标签出现的位置区分开来。

预期行为 参考几何位置 放置规则
跟随短语的注释 完整范围 在简单的水平、从左到右布局中,将标签附加到最后一个可用片段下方
靠近指针交互的标签 范围的片段矩形 选择位于交互客户端坐标处的片段
居中显示的概览标签 外接矩形 接受标签可能位于空白处
为整个短语添加背景标记 完整范围 使用文本高亮,且不依赖标签的位置

此演示使用第一条规则。它特意固定书写模式和方向,使最后一个矩形成为有用的终点参考。这是示例成立的条件。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,在两种段落宽度之间切换。同一范围仍是注释目标,同时标签会重新定位到最后一个可用片段的下方。滚动并调整窗口大小以查看效果。使用 window.fragmentDemo.destroy() 移除演示。

该程序通过了 JavaScript 语法检查,示例偏移量和片段定位计算也通过了确定性检查。此处未运行浏览器渲染;以上步骤可用于目视验证。

演示如何保留文本流和字形塑形?

范围会测量现有文本。程序构建示例后,会保留短语的文本节点和强调元素。它会单独添加标签,并可选择通过 CSS Custom Highlight API 为短语着色;MDN 记载,该 API 无需更改底层 DOM 结构即可工作。CSS custom highlight API。

在改用此方法时,这一区别很重要。在字符单元之间添加行内内边距、边框或外边距可能会引入塑形边界。W3C 还指出,非初始 vertical-align 和双向隔离边界也会破坏字形塑形。不要仅为获取坐标就插入单独定位的字符包装元素;应测量浏览器已有的渲染结果。CSS Text Module Level 3, Shaping Across Element Boundaries。

对于实际应用文本,应根据预期的文本边界确定范围端点。DOM 文本偏移量以代码单元为单位,并不代表可见字形的数量。应让端点与完整的预期字符对齐,不要假设每个显示字符都只占一个偏移量。DOM Standard。

高亮是可选的。MDN 将 Highlight 列为 Baseline 2025,并指出较旧的浏览器可能不支持它。演示中的特性检查会在无法使用高亮时保留注释的位置,并显示说明状态消息。Highlight。

页面重新排版时需要更新什么?

演示会在段落尺寸变化、窗口大小变化和滚动时重新测量。ResizeObserver 会检测元素尺寸变化,包括与调整浏览器窗口无关的变化。这里的回调只移动独立的标签,避免反复改变被测段落大小而造成反馈循环。ResizeObserver。

字体加载需要单独更新。程序会立即测量,然后在 document.fonts.ready 兑现时再次测量。MDN 指出,此 Promise 会在文档的字体加载及相关布局操作完成后兑现。之后由应用驱动的字体或内容变化应显式请求再次测量。FontFaceSet: ready property。

坐标应始终使用同一坐标系。此演示在空白页面上使用固定定位标签,并直接应用客户端坐标。添加文档滚动偏移量会将这些坐标转换为文档坐标。MDN 说明,客户端矩形以视口为参照;若要获得相对于文档的坐标,可通过添加滚动偏移量进行转换。Element: getBoundingClientRect() method。

这个小示例会限制水平位置,但没有实现垂直碰撞避让、屏幕外隐藏或任意变换容器的处理。它也不会在重新加载或文本被完全替换后恢复注释。在应用中,应保留一种识别目标段落的方法,在渲染后重建其范围,然后再次测量。

什么时候应该使用 Floating UI?

如果标签需要感知裁剪地放置,并需要托管更新生命周期,请使用 Floating UI。其文档中的虚拟引用接口通过两个方法支持范围:getBoundingClientRect() 返回范围的外接矩形,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. 在布局变化后再次检查。 更改宽度、滚动、调整窗口大小,并等待字体加载完成。如果应用替换了文本节点,请重建范围。
  6. 检查布局假设。 对于竖排文字、混合方向、多栏或变换容器,应先定义并测试合适的片段选择规则,再复用演示中选取最后一个片段的简化做法。

可靠的注释始于准确的文本范围和明确的放置规则。让浏览器负责换行和字形塑形,并在相关布局变化时重新测量其渲染出的片段。这样,文字可以自然流动,同时注释仍能稳定地对应到该短语。

来源