初探富文字之搜尋替換演算法
在我們的線上文件系統中,除了基礎的富文字編輯能力外,搜尋替換的演算法同樣也是重要的功能實現。純文字搜尋替換演算法相對來說是比較容易實現的,而在富文字的場景中,由於存在圖文混排、嵌入模組如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
置入的內容則是空佔位,那麼在這裡我們就實現一下Quill
的Mention
外掛。
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);
則此時我們的預置的資料結構內容則如下所示,我們可能注意到資料結構中Mention
的insert
是物件並且實際內容是並不是在attributes
而是在insert
中。這是因為在Quill
中描述parchment
的blots/embed
中value
是由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
,這樣就取得了我們需要的startRect
和endRect
,但是需要注意的是,此時我們取得是絕對位置,還需要將其換算為編輯器例項的相對位置才可以。
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);
});
批次替換
替換的實現在Delta
的結構上會比較簡單,在先前我們也提到了Delta
不僅能夠透過insert
描述文件,還可以藉助retain
、delete
、insert
方法來描述文件的變更,那麼我們需要做的就是在上述構造的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