Twitter 和微博都在用的 - 人的功能是如何設計與實現的?
背景
第一次使用 @人功能到現在已經有差不多 10 年了,初次使用是通過微博體驗的。@人的功能現在遍佈各種應用,只要是涉及社交、辦公等場景,就是一個必不可少的功能。最近也在調研 IM 的各種功能的實現方案,所以也稍微地瞭解了下 @人功能的前端實現。
業內實現
微博
微博的實現比較簡單,就是通過正則匹配,最後用空格表示匹配結束,所以實現上是直接使用了 textarea 標籤。但是這個實現必須依賴的一個事情是:用戶名必須唯一。微博的用戶名就是唯一的,所以正則所匹配到的 ID,一般的可以映射到唯一的一個用戶上(除非 ID 不存在)。整體的輸出比較寬鬆,你可以構造任何不存在的 ID 進行 @操作。
Twitter 的實現跟微博類似,也是以 @開始,空格結尾做匹配。但是使用的是 contenteditable 這個屬性進行富文本操作,相似之處在於 Twitter 的 ID 也是唯一,但是可以通過暱稱進行搜索,然後轉化成 ID,這一點在體驗上好了不少。
基本思路
-
監聽用戶輸入,匹配用戶以 @開頭的文字。
-
調用搜索彈窗,展示搜索出來的用戶列表。
-
監聽上、下、回車鍵控制列表選擇,監聽 ESC 鍵關閉搜索彈窗。
-
選擇需要 @的用戶,把對應的 HTML 文本替換到原文本上。在 HTML 文本上添加用戶的元數據。
一般來說,如果像平常用的 Lark 搜索,我們是不會通過唯一的『工號』去進行搜索,而是通過名字,但是名字會出現重複,所以就不太適合用 textarea 的方式,而是用 contenteditable,把 @文本替換成 HTML 標籤特殊化標記。
關鍵步驟
-
獲得用戶的光標位置
想要獲得用戶輸入的字符串,然後替換進去,第一步就是需要獲得用戶所在的光標。要獲取光標信息,那就要先了解什麼是『選擇(Selection) 』和『範圍(Range) 』。
範圍(Range)
Range
本質上是一對 “邊界點”:範圍起點和範圍終點。
每個點都被表示爲一個帶有相對於起點的相對偏移(offset)的父 DOM 節點。如果父節點是元素節點,則偏移量是子節點的編號,對於文本節點,則是文本中的位置。
例如:
let range = new Range();
然後使用 range.setStart(node, offset)
和 range.setEnd(node, offset)
來設置選擇邊界。
假設 HTML 片段是這樣的:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
選擇 "Example: <i>italic</i>"
,它是 <p>
的前兩個子節點(文本節點也算在內):
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// 範圍的 toString 以文本形式返回其內容(不帶標籤)
alert(range); // Example: italic
document.getSelection().addRange(range);
</script>
-
range.setStart(p, 0)
—— 將起點設置爲<p>
的第 0 個子節點(即文本節點"Example: "
)。 -
range.setEnd(p, 2)
—— 覆蓋範圍至(但不包括)<p>
的第 2 個子節點(即文本節點" and "
,但由於不包括末節點,所以最後選擇的節點是<i>
)。
如果像這樣操作:
這也是可以做到的,只需要將起點和終點設置爲文本節點中的相對偏移量即可。
我們需要創建一個範圍:
-
從
<p>
的第一個子節點的位置 2 開始(選擇 "Example: " 中除前兩個字母外的所有字母) -
到
<b>
的第一個子節點的位置 3 結束(選擇 “bold” 的前三個字母,就這些):
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
alert(range); // ample: italic and bol
window.getSelection().addRange(range);
</script>
range
對象具有以下屬性:
-
startContainer
,startOffset
—— 起始節點和偏移量, -
在上例中:分別是
<p>
中的第一個文本節點和2
。 -
endContainer
,endOffset
—— 結束節點和偏移量, -
在上例中:分別是
<b>
中的第一個文本節點和3
。 -
collapsed
—— 布爾值,如果範圍在同一點上開始和結束(所以範圍內沒有內容)則爲true
, -
在上例中:
false
-
commonAncestorContainer
—— 在範圍內的所有節點中最近的共同祖先節點, -
在上例中:
<p>
選擇(Selection)
Range
是用於管理選擇範圍的通用對象。
文檔選擇是由 Selection
對象表示的,可通過 window.getSelection()
或 document.getSelection()
來獲取。
根據 Selection API 規範 [1] 一個選擇可以包括零個或多個範圍。不過實際上,只有 Firefox 允許使用 Ctrl+click (Mac 上用 Cmd+click) 在文檔中選擇多個範圍。
這是在 Firefox 中做的一個具有 3 個範圍的選擇的截圖:
其他瀏覽器最多支持 1 個範圍。正如我們將看到的,某些 Selection
方法暗示可能有多個範圍,但同樣,在除 Firefox 之外的所有瀏覽器中,範圍最多是 1。
與範圍相似,選擇的起點稱爲 “錨點(anchor)”,終點稱爲 “焦點(focus)”。
主要的選擇屬性有:
-
anchorNode
—— 選擇的起始節點, -
anchorOffset
—— 選擇開始的anchorNode
中的偏移量, -
focusNode
—— 選擇的結束節點, -
focusOffset
—— 選擇開始處focusNode
的偏移量, -
isCollapsed
—— 如果未選擇任何內容(空範圍)或不存在,則爲true
。 -
rangeCount
—— 選擇中的範圍數,除 Firefox 外,其他瀏覽器最多爲1
。
看完上面,不知道了解了沒?沒關係,我們繼續往下。綜上所述,一般我們只有一個 Range,當我們的光標在 contenteditable 的 div 上閃動的時候,其實就有了一個 Range,這個 Range 的開始和結束位置都是一樣的。另外,我們還可以直接通過 Selection.focusNode
獲取到對應的節點,通過 Selection.focusOffset
獲取到對應的偏移量。就像下圖:
這樣,我們就獲取到了光標的位置以及對應的 TextNode 對象。
-
獲取需要 @的用戶
從步驟一我們獲得了光標在對應 Node 節點的偏移量,以及對應的 Node 節點。那麼就可以通過textContent
方法獲取整個文本。
一般來說,通過一個簡單的正則就可以獲取 @的內容了:
// 獲取光標位置
const getCursorIndex = () => {
const selection = window.getSelection();
return selection?.focusOffset;
};
// 獲取節點
const getRangeNode = () => {
const selection = window.getSelection();
return selection?.focusNode;
};
// 獲取 @ 用戶
const getAtUser = () => {
const content = getRangeNode()?.textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
if (match && match.length === 2) {
return match[1];
}
return undefined;
};
因爲 @的插入可能是末尾,可能是中間,所以我們在判斷前,還需要截取光標前的文本。
所以簡單地 slice 一下就好了:
content.slice(0, getCursorIndex())
-
彈窗展示以及按鍵攔截
彈窗是否展示的邏輯,跟判斷 @用戶類似,都是同一個正則。
// 是否展示 @
const showAt = () => {
const node = getRangeNode();
if (!node || node.nodeType !== Node.TEXT_NODE) return false;
const content = node.textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
return match && match.length === 2;
};
彈窗需要出現在正確的位置,幸好現代瀏覽器有不少好用的 API。
const getRangeRect = () => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0)!;
const rect = range.getClientRects()[0];
const LINE_HEIGHT = 30;
return {
x: rect.x,
y: rect.y + LINE_HEIGHT
};
};
當出現彈窗之後,我們還需要攔截掉輸入框的『上』、『下』、『回車』的操作,否則在輸入框響應這些按鍵會讓光標位置偏移到其他地方。
const handleKeyDown = (e: any) => {
if (showDialog) {
if (
e.code === "ArrowUp" ||
e.code === "ArrowDown" ||
e.code === "Enter"
) {
e.preventDefault();
}
}
};
然後在彈窗裏面監聽這些按鍵,實現上下選擇、回車確定、關閉彈窗的功能。
const keyDownHandler = (e: any) => {
if (visibleRef.current) {
if (e.code === "Escape") {
props.onHide();
return;
}
if (e.code === "ArrowDown") {
setIndex((oldIndex) => {
return Math.min(oldIndex + 1, (usersRef.current?.length || 0) - 1);
});
return;
}
if (e.code === "ArrowUp") {
setIndex((oldIndex) => Math.max(0, oldIndex - 1));
return;
}
if (e.code === "Enter") {
if (
indexRef.current !== undefined &&
usersRef.current?.[indexRef.current]
) {
props.onPickUser(usersRef.current?.[indexRef.current]);
setIndex(-1);
}
return;
}
}
};
-
替換 @文本爲定製標籤
-
把原來的 TextNode 進行切塊
假如文本是:『請幫我泡一杯咖啡 @ABC,這是後面的內容』
那麼我們需要根據光標的位置,替換掉 @ABC 文本,然後分成前後兩塊:『請幫我泡一杯咖啡』、『這是後面的內容』。
-
創建 At 標籤
爲了能實現刪除鍵能把刪除全部刪除,需要把 at 標籤的內容包裹起來。這是第一版寫的一個標籤,但是如果直接用會有點小問題,留着後續再討論。
const createAtButton = (user: User) => {
const btn = document.createElement("span");
btn.style.display = "inline-block";
btn.dataset.user = JSON.stringify(user);
btn.className = "at-button";
btn.contentEditable = "false";
btn.textContent = `@${user.name}`;
return btn;
};
-
把標籤插進去
首先,我們可以獲取 focusNode 節點,然後就可以獲取它的父節點以及兄弟節點。現在需要做的是,把舊的文本節點刪除,然後在原來的位置上依次插入『請幫我泡一杯咖啡』、【@ABC】、『這是後面的內容』。
parentNode.removeChild(oldTextNode);
// 插在文本框中
if (nextNode) {
parentNode.insertBefore(previousTextNode, nextNode);
parentNode.insertBefore(atButton, nextNode);
parentNode.insertBefore(nextTextNode, nextNode);
} else {
parentNode.appendChild(previousTextNode);
parentNode.appendChild(atButton);
parentNode.appendChild(nextTextNode);
}
-
重置光標的位置
我們這一頓操作之前,因爲原來的文本節點丟失,所以我們的光標也失去了。這時候就需要重新把光標定位到 at 標籤之後。簡單來說就是把光標定位到 nextTextNode 節點之前即可。
// 創建一個 Range,並調整光標
const range = new Range();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
-
優化 at 標籤
第 2 步中,我們創建了 at 標籤,但是會有點小問題。
這時候光標就定位到了『按鈕邊框內』,但光標的位置實際上是正確的。
爲了優化這個問題,首先想到的是在nextTextNode
中添加一個『0 寬字符』:\u200b
// 添加 0 寬字符
const nextTextNode = new Text("\u200b" + restSlice);
// 定位光標時,移動一位
const range = new Range();
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);
但是,事情沒那麼簡單。因爲我發現如果往前可能也會這樣……
最後一想:把內容區弄寬一點不就行了?比如左右加個空格?然後就把標籤包裹了一層……
const createAtButton = (user: User) => {
const btn = document.createElement("span");
btn.style.display = "inline-block";
btn.dataset.user = JSON.stringify(user);
btn.className = "at-button";
btn.contentEditable = "false";
btn.textContent = `@${user.name}`;
const wrapper = document.createElement("span");
wrapper.style.display = "inline-block";
wrapper.contentEditable = "false";
const spaceElem = document.createElement("span");
spaceElem.style.whiteSpace = "pre";
spaceElem.textContent = "\u200b";
spaceElem.contentEditable = "false";
const clonedSpaceElem = spaceElem.cloneNode(true);
wrapper.appendChild(spaceElem);
wrapper.appendChild(btn);
wrapper.appendChild(clonedSpaceElem);
return wrapper;
};
窮人粗糙版 at 人,最終完結~
總結
前端富文本的坑確實比較多,之前沒怎麼了解過這部分的知識。
雖然整個過程很粗糙,但是道理是這麼個道理。
如果有興趣,也可以到 Playground 玩一玩。
不完善的地方很多,有更好的方式可以共同討論下。
Playground
https://codesandbox.io/s/gallant-euclid-4bxsi?file=/src/Editor.tsx:1247-1985
站在巨人的肩膀
現代 JavaScript 教程 [2]
MDN[3]
參考資料
[1]
Selection API 規範: https://www.w3.org/TR/selection-api/
[2]
現代 JavaScript 教程: https://zh.javascript.info/selection-range
[3]
MDN: https://developer.mozilla.org/en-US/docs/Web/API/Range/getClientRects
歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/nrJ-TO7M8wSOsEYS0xuWPA