要在保留现有行内元素的同时包裹文本范围,请将每个选中的文本片段分别包裹在自己的 span 中。本 Safari for Mac 操作指南使用合成段落,以保持强调元素、文本顺序和元素引用不变。
为什么一个外围 span 可能行不通
浏览器范围可以从一个行内元素内部开始,并在该元素外部结束。例如,选区可能从强调文本的中间开始,跨过一个嵌套元素,最后停在段落末尾的文本中。这种选区未必对应一个完整的子树。
Range.surroundContents() 无法处理所有这类选区。当范围部分包含非文本节点时,它会抛出异常。当前 DOM 标准对这种情况规定了 InvalidStateError。如果范围包含文本和完全选中的元素,可以使用此方法;但跨越元素边界时则需要采用其他方案。DOM Standard、Range: surroundContents() method
将范围提取到一个新 span 中是另一种可能的变换方式,但它遵循不同的保留规则。extractContents() 会克隆部分选中的祖先元素。如果任务要求原有行内元素保持完整且身份不变,那么这些克隆出来的分支就不合适。Range: extractContents() method
修改文档前,请参考以下决策指南:
| 所需结果 | 适用方案 |
|---|---|
| 用一个包装元素包住文本和完全选中的元素 | 考虑使用 surroundContents() |
| 部分选区跨越边界时,原有行内元素保持完整 | 分别包裹选中的文本片段 |
| 一个包装元素跨越部分选中的元素边界 | 接受结构调整,并检查提取和克隆行为 |
| 只需视觉强调,不添加元素 | 重新考虑任务是否真的需要 span |
下面的示例选择分别包裹文本片段。这里的保留结构,指的是保留现有元素对象及其祖先关系,同时在它们下方添加 span。选区起点或终点处的文本节点会被拆分。
在 Mac 上的 Safari 中打开合成示例
打开一个新的 Safari 标签页并输入 about:blank。然后选择 Safari → Settings… → Advanced,并启用 Show features for web developers。打开 Develop → Show Web Inspector,或按下 Option–Command–I。这些步骤由 WebKit 文档记录。Enabling Web Inspector
选择 Console 标签页。在提示符中运行下面的完整 JavaScript 程序。WebKit 文档说明,控制台提示符会在所检查页面当前选定的执行上下文中执行 JavaScript。本示例请使用顶层页面上下文。Web Inspector Interface
无需库、导入、构建步骤或现有文档内容。该程序会自行创建段落、包裹预定范围、恢复对包裹后文本的正向选区,并添加一个移除按钮。它支持的输入范围刻意限定为:一个段落,其中包含文本和普通的 em、strong、span 或 code 后代元素,且范围的两个边界都位于文本节点内部。
(() => {
function wrapTextRange(root, range) {
const doc = root.ownerDocument;
const startNode = range.startContainer;
const endNode = range.endContainer;
if (root.tagName !== "P" ||
startNode.nodeType !== 3 || endNode.nodeType !== 3 ||
!root.contains(startNode) || !root.contains(endNode)) {
throw new TypeError("Use one paragraph and text-node boundaries.");
}
const allowed = new Set(["EM", "STRONG", "SPAN", "CODE"]);
for (const element of root.querySelectorAll("*")) {
if (!allowed.has(element.tagName) ||
element.hasAttribute("data-range-wrap")) {
throw new TypeError("Unsupported element or existing wrappers.");
}
}
if (range.collapsed) return [];
// Snapshot every fragment before changing the live DOM range.
const jobs = [];
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!range.intersectsNode(node)) continue;
const start = node === startNode ? range.startOffset : 0;
const end = node === endNode ? range.endOffset : node.length;
if (end > start) jobs.push({ node, start, end });
}
const spans = [];
for (const { node, start, end } of jobs) {
// Split the end first so the start offset remains valid.
if (end < node.length) node.splitText(end);
const selected = start > 0 ? node.splitText(start) : node;
const span = doc.createElement("span");
span.setAttribute("data-range-wrap", "");
span.style.backgroundColor = "#fff0a6";
selected.parentNode.insertBefore(span, selected);
span.appendChild(selected);
spans.push(span);
}
return spans;
}
const panel = document.createElement("section");
const root = document.createElement("p");
const strong = document.createElement("strong");
const em = document.createElement("em");
const tail = document.createTextNode(" gamma delta.");
strong.append(document.createTextNode("bright "));
em.textContent = "blue";
strong.append(em);
root.append(document.createTextNode("Alpha "), strong, tail);
panel.append(root);
document.body.append(panel);
let clicks = 0;
strong.addEventListener("click", () => { clicks += 1; });
const originalText = root.textContent;
const range = document.createRange();
range.setStart(strong.firstChild, 3);
range.setEnd(tail, 6);
const selectedText = range.toString();
const spans = wrapTextRange(root, range);
const selection = window.getSelection();
const restored = document.createRange();
restored.setStart(spans[0].firstChild, 0);
const lastText = spans[spans.length - 1].firstChild;
restored.setEnd(lastText, lastText.length);
selection.removeAllRanges();
selection.addRange(restored);
strong.dispatchEvent(new Event("click"));
console.assert(root.textContent === originalText, "Text changed");
console.assert(strong.parentNode === root, "Strong element moved");
console.assert(em.parentNode === strong, "Emphasis element moved");
console.assert(clicks === 1, "Existing listener failed");
console.assert(spans.map(s => s.textContent).join("") === selectedText);
console.log(spans.map(s => s.textContent));
const remove = document.createElement("button");
remove.textContent = "Remove spans";
remove.addEventListener("click", () => {
selection.removeAllRanges();
for (const span of spans) {
span.replaceWith(...Array.from(span.childNodes));
}
remove.disabled = true;
});
panel.append(remove);
})();
了解哪些文本会被包裹
该段落的内容是 Alpha bright blue gamma delta.。它现有的 strong 元素包含 bright ,并嵌套了一个包含 blue 的 em 元素。范围从 bright 中的偏移量 3 开始,在 gamma delta. 中的偏移量 6 结束。这些是程序中定义的示例输入。
预期的控制台输出包含三个片段:
[\"ght \", \"blue\", \" gamma\"]
第一个 span 会成为原有 strong 元素的子元素。第二个会成为原有 em 元素的子元素。第三个会成为段落的子元素。按文档顺序读取这些片段的文本,会还原出选中的字符串 ght blue gamma。未选中的前缀 bri 和后缀 delta. 留在包装元素之外。
遍历过程使用 createTreeWalker() 和 NodeFilter.SHOW_TEXT 访问后代文本节点。intersectsNode() 用于找出候选节点,边界偏移量则用于裁剪第一个和最后一个候选节点。长度为零的片段会被排除。Document: createTreeWalker() method、Range: intersectsNode() method
该程序会在进行任何修改前记录所有片段边界。这一点很重要,因为 DOM 范围是动态的:拆分、插入或移除节点时,范围的边界点可能随之更新。克隆出来的 Range 也同样是动态的;保存节点引用和数值偏移量,可以建立此处使用的稳定工作列表。DOM Standard: Ranges
对于每个片段,程序先拆分结束边界,再拆分起始边界。splitText() 会将前缀留在原文本节点中,并把剩余文本作为同级节点返回。这样可以隔离选中的子字符串,而无需重新创建包围它的元素。Text: splitText() method
已在 Node.js 中确定性地检查了四种片段规划情况:跨元素文本、单个节点中的子字符串、折叠范围,以及边界未选中任何字符的情况。准备过程中未执行浏览器 DOM 修改或 Safari 交互;运行程序时可通过其中的断言检查这些结果。
保留正确的结构和生命周期约定
此程序绝不会移除、替换或克隆现有的 strong 和 em 元素。它们的元素引用仍然可用,附加到 strong 的监听器也仍然附着在同一个对象上。示例会检查父子关系,并在包裹后分发合成事件。
文本节点遵循不同的约定。拆分边界会引入新的文本节点,每个选中的文本片段也会移入新建的 span。如果应用持有文本节点引用,就必须考虑这种变化。此方法会保留文本内容和原有元素层级,但不会保留每个原有文本节点的边界。
添加 span 也会改变文本的直接父子关系。在现有文档中采用这种变换前,请检查依赖元素首个子节点为文本节点的代码、依赖直接子元素的 CSS 选择器,以及会检查事件目标的事件处理程序。仅仅保留元素对象,并不能保证周边应用的所有假设仍然成立。
示例会在包裹后明确重建正向选区。它不会保留反向选区方向,也不会提供编辑器撤销事务。将此操作集成到编辑界面时,请把这些视为单独的需求。包装函数本身会返回其创建的 span,但不承诺浏览器处于某种特定的选区状态。
点击 Remove spans 会仅取消包裹本次运行所创建的 span,并清除选区。它不会合并拆分后的文本节点。此移除操作会恢复原有元素层级和文本,但不会精确还原文本节点的身份。如果其他代码依赖这些引用,请避免自动规范化。
调整示例时不要悄然扩大其适用范围
对于用户拖动产生的选区,请先检查 window.getSelection() 是否返回了选区,以及 rangeCount 是否非零,然后再获取其范围。getRangeAt(0) 会返回第一个范围;请求超出选区范围数量的索引会导致错误。Selection: getRangeAt() method
只有当该范围的两个边界容器都是目标段落内的文本节点时,才将它传给同一个辅助函数。边界容器为元素的选区会在修改前被拒绝。支持这类边界需要额外明确地将其转换为后代文本位置;单纯移除保护条件并不能实现这种转换。
辅助函数还会在进行更改前拒绝不受支持的后代元素和已有的标记包装元素。对于重复或重叠的标注,请先明确新 span 应该嵌套、合并还是替换之前的标注。示例会有意将这个决定交由调用者处理,而不在没有策略的情况下不断累积包装元素。
偏移量以 UTF-16 代码单元为单位。对于包含多代码单元字符的文本,如果位置是计算得出的,请确保边界不会切开预期的字符序列。合成示例使用简单的拉丁文本,因此数值偏移量没有歧义。Text: splitText() method
对于此任务,所需的不变量很简单:新 span 中文本的拼接结果必须等于原先选中的字符串;段落的完整文本必须保持不变;现有行内元素必须保留其身份和祖先关系。调整示例时请检查这三项;它们共同定义了成功保留的含义。
来源
- DOM Standard — WHATWG。
- Enabling Web Inspector — WebKit。
- Web Inspector Interface — WebKit。
- Range: surroundContents() method — MDN Web Docs。
- Range: extractContents() method — MDN Web Docs。
- Document: createTreeWalker() method — MDN Web Docs。
- Range: intersectsNode() method — MDN Web Docs。
- Text: splitText() method — MDN Web Docs。
- Selection: getRangeAt() method — MDN Web Docs。