将注释锚定到 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。
诊断位置仍然不对的注释
按顺序检查以下各项,避免几何位置问题掩盖文本目标选择问题。
- 确认短语。 检查
range.toString(),并在调整坐标前核对两个端点。 - 检查片段。 记录
Array.from(range.getClientRects())。多个重叠条目可能来自嵌套行内内容;不要把条目数当作行数。 - 检查放置规则。 外接矩形、最后一个片段和指针选中的片段分别适用于不同的注释行为。
- 检查坐标系。 对于空白页演示,固定标签应使用客户端坐标。
- 在布局变化后再次检查。 更改宽度、滚动、调整窗口大小,并等待字体加载完成。如果应用替换了文本节点,请重建范围。
- 检查布局假设。 对于竖排文字、混合方向、多栏或变换容器,应先定义并测试合适的片段选择规则,再复用演示中选取最后一个片段的简化做法。
可靠的注释始于准确的文本范围和明确的放置规则。让浏览器负责换行和字形塑形,并在相关布局变化时重新测量其渲染出的片段。这样,文字可以自然流动,同时注释仍能稳定地对应到该短语。
来源
- DOM Standard — WHATWG
- CSSOM View Module — W3C
- CSS Text Module Level 3 — W3C
- Range: getClientRects() method — MDN
- Element: getBoundingClientRect() method — MDN
- CSS custom highlight API — MDN
- Highlight — MDN
- ResizeObserver — MDN
- FontFaceSet: ready property — MDN
- Web Console — Mozilla
- inline — Floating UI
- Virtual Elements — Floating UI
- computePosition — Floating UI
- autoUpdate — Floating UI