初探富文本之搜索替换算法

在我们的在线文档系统中,除了基础的富文本编辑能力外,搜索替换的算法同样也是重要的功能实现。纯文本搜索替换算法相对来说是比较容易实现的,而在富文本的场景中,由于存在图文混排、嵌入模块如Mention等,实现这一功能则相对要复杂得不少。那么本文则以Quill实现的富文本编辑器为基础,通过设计索引查找和图层渲染的方式来实现搜索替换。

描述

首先我们先来思考一下纯文本的搜索替换算法,对于纯文本而言我们可以直接通过一些方法来查找对应文本的索引值,然后无论是将其直接替换还是切割再联合的方式,都可以轻松地完成这个功能。而富文本本质上可以看作是携带着格式的纯文本,那么在这种情况下我们依然可以延续着对于纯文本的处理思路,只不过由于存在文本会被切割为多个片段的问题,就需要我们来兼容这个数据层面上的问题。

实际上在前边我们强调了当前的实现是以Quill为基础实现的方案,因为本质上搜索替换与数据结构的实现强相关,quill-delta是扁平的数据结构,相对来说比较方便我们来处理这个问题。而如果是嵌套的数据结构类型例如Slate的实现,我们仍然可以依照这个思路来实现,只不过在这里如果我们将其作为DEMO来实现并没有那么直观,嵌套的数据结构类型对于替换的处理上也会复杂很多。

那么在这里我们先来构造Quill的编辑器实例,在我们实现的实例中,我们只需要引用quill.js的资源,然后指定实例化的DOM节点即可初始化编辑器,我们在这里通过配置来注册了样式模块。文中的相关实现可以在https://github.com/WindRunnerMax/QuillBlocks/blob/master/examples/find-replace.html中查看。

<div class="editor-container">
    <div id="$editor"></div>
</div>
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<script>
const editor = new Quill($editor, {
  modules: {
    toolbar: [[{ header: [1, 2, false] }], ["bold", "italic", "underline"], ["code-block"]],
  },
  placeholder: "Enter Text...",
  theme: "snow",
});
</script>

Quill中是使用quill-delta来描述富文本内容的,quill-delta是一个扁平的数据结构,除了能描述富文本内容之外,还可以描述富文本内容的变更。在这里我们首先关注的是文档内容的描述,因此这里我们则是全部以insert的形式来描述,而之前我们也提到了富文本实际上就是纯文本携带属性,因此我们可以很轻松地使用下面的样式描述普通文本与加粗文本。

const delta = new Delta([
  { insert: "Hello " },
  { insert: "World", attributes: { bold: true } },
]);

我们的搜索替换方案本质上是在数据描述的基础上来查找目标内容,而从上边的数据结构来看,我们可以明显地看出在attributes上也可能存在需要查找的目标内容,例如我们需要实现Mention组件,是可以在attributes中存储user信息来展示@user的信息,而insert置入的内容则是空占位,那么在这里我们就实现一下QuillMention插件。

const Embed = Quill.import("blots/embed");
class Mention extends Embed {
  static blotName = "mention";
  static className = "ql-mention";
  static tagName = "SPAN";
  static create(value) {
    const node = super.create(value);
    node.innerHTML = `@${value}`;
    node.setAttribute("data-value", value);
    return node;
  }
  static value(domNode) {
    return domNode.getAttribute("data-value");
  }
}
Quill.register(Mention);

则此时我们的预置的数据结构内容则如下所示,我们可能注意到数据结构中Mentioninsert是对象并且实际内容是并不是在attributes而是在insert中。这是因为在Quill中描述parchmentblots/embedvalue是由insert存储的blotName对象来完成的,因此在示例中我们查找的目标还是在insert对象中,但是并不实际影响我们需要对其进行额外的查找实现。

editor.setContents([
  { "insert": "查找替换" },
  { "attributes": { "header": 1 }, "insert": "\n" },
  { "insert": "查找替换的富文本基本能力,除了基本的" },
  { "attributes": { "bold": true }, "insert": "文本查找" },
  { "insert": "之外,还有内嵌结构例如" },
  { "attributes": { "italic": true }, "insert": "Mention" },
  { "insert": "的" },
  { "insert": { mention: "查找能力" } },
  { "insert": "。\n" },
]);

搜索算法

首先我们先来设计一下内容的搜索算法,既然前边我们已经提到了富文本实际上就是带属性的纯文本,那么我们就先来设想对于纯文本的搜索实现。文本搜索算法实现本质上就是字符串文本的匹配,那么我们很容易想到的就是KMP算法,而实际上KMP算法并不是最高效的实现算法,在各种文本编辑器中实现的查找功能更多的是使用的Boyer-Moore算法。

在这里我们先不考虑高效的算法实现,我们实现的目标则是在长文本中查找目标文本的索引值,因此可以直接使用String对象的indexOf来实现查找。而我们实际上是需要获取目标文本的所有索引值,因此需要借助这个方法的第二个参数position来实现查找的起始位置。而如果我们使用正则来实现索引则容易出现输入的字符串被处理为正则保留字的问题,例如搜索(text)字符串时,则在不特殊处理的情况下会被认为搜索text文本。

const origin = "123123000123";
const target = "123";
const indices = [];
let startIndex = 0;

while ((startIndex = origin.indexOf(target, startIndex)) > -1) {
  indices.push(startIndex);
  startIndex += target.length;
}

console.log(indices); // [0, 3, 9]
const origin = "123123000123";
const target = "123";
const indices = [];
const regex = new RegExp(target, "g");
let match;

while ((match = regex.exec(origin)) !== null) {
  indices.push(match.index);
}

console.log(indices); // [0, 3, 9]

在我们上述的测试中,可以将其放置于https://perf.link/测试一下执行性能,将其分别实现防止于Test Cases中测试,可以发现实际上indexOf的性能还会更高一些,因此在这里我们就直接使用indexOf来实现我们的搜索算法。在浏览器中测试万字文本的indexOf搜索,结果稳定在1ms以内,性能表现是完全可以接受的,当然这个搜索结果和电脑本身的性能也是强相关的。

indexOf // 141,400 ops/s
RegExp // 126,030 ops/s

那么此时我们就来处理关于Delta的索引建立,那么由于富文本的表达存在样式信息,反映在数据结构上则是存在文本的片段,因此我们的搜索首先需要直接将所有文本拼接为长字符串,然后使用indexOf进行查找。此外在前边我们已经看到数据结构中存在insert,在这里我们需要将其作为不可搜索的文本占位,以避免在搜索时将其作为目标文本的一部分。

const delta = editor.getContents();
const str = delta.ops
  .map(op => (typeof op.insert === "string" ? op.insert : String.fromCharCode(0)))
  .join("");
let index = str.indexOf(text, 0);
const ranges = [];
while (index >= 0) {
  ranges.push({ index, length: text.length });
  index = str.indexOf(text, index + text.length);
}

而对于类似于Mention模块的Void/Embed结构处理则需要我们特殊处理,在这里不像是先前对于纯文本的内容搜索,我们不能将这部分的相关描述直接放置于insert中,因此此时我们将难以将其还原到原本的数据结构中,如果不能还原则无法将其显示为搜索的结果样式。那么我们就需要对delta.ops再次进行迭代,然后case by case地将需要处理的属性进行处理。

const rects = [];
let iterated = 0;
delta.ops.forEach(op => {
  if (typeof op.insert === "string") {
    iterated = iterated + op.insert.length;
    return void 0;
  }
  iterated = iterated + 1;
  if (op.insert.mention) {
    const value = op.insert.mention;
    const mentionIndex = value.indexOf(text, 0);
    if (mentionIndex === -1) return void 0;
    // 绘制节点位置 ...
  }
});

当我们将所有的数据收集到起来后,就需要构建虚拟图层来展示搜索的结果。首先我们需要处理最初处理的insert纯文本的搜索结果,由于我们在输入的时候通常都是使用input来输入搜索的文本目标,因此我们是不需要处理\n的情况,这里的图层处理是比较方便的。而具体的虚拟图层实现在先前的diff算法文章中已经描述得比较清晰,这里我们只实现相关的调用。

const rangeDOM = document.createElement("div");
rangeDOM.className = "ql-range virtual-layer";
$editor.appendChild(rangeDOM);
const COLOR = "rgba(0, 180, 42, 0.3)";
const renderVirtualLayer = (startRect, endRect) => {
  // ...
};
const buildRangeLayer = ranges => {
  rangeDOM.innerText = "";
  ranges.forEach(range => {
    const startRect = editor.getBounds(range.index, 0);
    const endRect = editor.getBounds(range.index + range.length, 0);
    rangeDOM.appendChild(renderVirtualLayer(startRect, endRect));
  });
};

而在这里我们更需要关注的是Mention的处理,因为在这里我们不能够使用editor.getBounds来获取其相关的BoundingClientRect,因此这里我们需要自行计算其位置。因此我们此时需要取得mentionIndex所在的节点,然后通过createRange构建Range对象,然后基于该Range获取ClientRect,这样就取得了我们需要的startRectendRect,但是需要注意的是,此时我们取得是绝对位置,还需要将其换算为编辑器实例的相对位置才可以。

const [leaf, offset] = editor.getLeaf(iterated);
if (
  leaf &&
  leaf.domNode &&
  leaf.domNode.childNodes[1] &&
  leaf.domNode.childNodes[1].firstChild
) {
  const textNode = leaf.domNode.childNodes[1].firstChild;
  const startRange = document.createRange();
  startRange.setStart(textNode, mentionIndex + 1);
  startRange.setEnd(textNode, mentionIndex + 1);
  const startRect = startRange.getBoundingClientRect();
  const endRange = document.createRange();
  endRange.setStart(textNode, mentionIndex + 1 + text.length);
  endRange.setEnd(textNode, mentionIndex + 1 + text.length);
  const endRect = endRange.getBoundingClientRect();
  rects.push({ startRect, endRect });
}

rects.forEach(it => {
  const editorRect = editor.container.getBoundingClientRect();
  const startRect = {
    bottom: it.startRect.bottom - editorRect.top,
    height: it.startRect.height,
    left: it.startRect.left - editorRect.left,
    right: it.startRect.right - editorRect.left,
    top: it.startRect.top - editorRect.top,
    width: it.startRect.width,
  };
  const endRect = {
    bottom: it.endRect.bottom - editorRect.top,
    height: it.endRect.height,
    left: it.endRect.left - editorRect.left,
    right: it.endRect.right - editorRect.left,
    top: it.endRect.top - editorRect.top,
    width: it.endRect.width,
  };
  const block = renderVirtualLayer(startRect, endRect);
  rangeDOM.appendChild(block);
});

此外,关于高亮的方案并不局限于这里的图层覆盖方式,在较新的浏览器中已经实现CSS Highlight API,其可以通过Range创建任意文本范围,并用CSS伪元素的形式对文本范围进行样式化以达到高亮效果。在先前我们已经构造好了Range,那么使用Highlight API相对就简单很多,只需要定义好伪类样式,将需要高亮的Range设置好即可。

// CSS
// ::highlight(highlight-range) {
//   background-color: #aaa;
// }

// JS
const range = new Range();
range.setStart(textNode, 0);
range.setEnd(textNode, 10);
const highlight = new Highlight(range, /* range2, range3 ... */);
CSS.highlights.set("highlight-range", highlight);

批量替换

替换的实现在Delta的结构上会比较简单,在先前我们也提到了Delta不仅能够通过insert描述文档,还可以借助retaindeleteinsert方法来描述文档的变更,那么我们需要做的就是在上述构造的ranges的基础上,构造目标的变更描述。而由于我们先前构造的Mention是不允许进行实质性的替换操作的,所以我们只需要关注原本的insert文本内容即可。

这里的实现重点是实现了批量的changes构造,首先需要定义Delta实例,紧接着的preIndex是用来记录上一次执行过后的索引位置,在我们的ranges循环中,retain是用来移动指针到当前即将处理的原文本内容,然后调用delete将其删除,之后的insert是替换的目标文本,注意此时的指针位置是在目标文本之后,因此需要将preIndex更新为当前处理的索引位置,最后将其应用到编辑器即可。

const batchReplace = () => {
  const text = $input1.value;
  const replace = $input2.value;
  if (!text) return void 0;
  const changes = new Delta();
  let preIndex = 0;
  ranges.forEach(range => {
    changes.retain(range.index - preIndex);
    changes.delete(range.length);
    changes.insert(replace);
    preIndex = range.index + range.length;
  });
  editor.updateContents(changes);
  onFind();
};

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://quilljs.com/docs/delta https://www.npmjs.com/package/parchment https://www.npmjs.com/package/quill-delta/v/4.2.2 https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createRange https://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html https://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html