This article is shared by the technical team of the ELab team. The original title "How is the function of @人 used by both Twitter and Weibo designed and implemented?", with revisions.
1 Introduction
It has been almost 10 years since the first use of @人 function, and the first use was through Weibo. @人's functions are now spread across various applications, basically involving social (IM, Weibo), office (DingTalk, corporate WeChat) and other scenarios, which is an indispensable function.
Recently, I was investigating the technical implementation of various functions of IM, so I also learned in detail about the technical implementation of @人 function on the front end of the Web page. I took this opportunity to share with you the technical principles and code implementation that I have mastered.
study Exchange:
- 5 groups for instant messaging/push technology development and communication: 215477170 [recommended]
- Mobile IM development introductory article: "One entry is enough for novices: Develop mobile IM from scratch"
- Open source IM framework source code: https://github.com/JackJiang2011/MobileIMSDK
(This article has been simultaneously published at: http://www.52im.net/thread-3767-1-1.html)
2. Relevant information
The @人 function shared in this article is aimed at the front end of the web page. It is still very different from the implementation of the native code on the mobile terminal in terms of technical principles and actual implementation. So if you want to understand @人 in social applications such as IM on the mobile terminal To realize the function, you can read the article "@人 function realization in Android IM application: imitating Weibo, QQ, WeChat, zero intrusion, high scalability [graphic + source code]".
3. Realized in the industry
3.1 Implementation of Weibo
The implementation of Weibo is relatively simple, that is, through regular matching, and finally a space is used to indicate the end of the match, so the implementation directly uses the textarea tag.
But one thing this implementation must rely on is that the user name must be unique.
The username of Weibo is unique, so the ID matched by the regular rule can generally be mapped to a unique user (unless the ID does not exist). However, the overall output of this feature in Weibo is relatively loose, and you can construct any non-existent ID to perform @ operations.
3.2 Implementation of Twitter
The implementation of Twitter is similar to that of Weibo. It also starts with @ and ends with a space for matching. But the contenteditable attribute is used for rich text operations.
The similarity is that Twitter ID is also unique, but it can be searched by nickname and then converted into ID, which is a lot better in experience.
4. Technical ideas
By analyzing the mainstream implementations in the industry, the technical realization ideas of @人 function are roughly as follows:
1) Monitor user input and match user text beginning with @;
2) Invoke the search pop-up window to display the searched user list;
3) Monitor the up, down, and enter keys to control the list selection, and monitor the ESC key to close the search pop-up window;
4) Select the user who needs @, replace the corresponding HTML text with the original text, and add the user's metadata to the HTML text.
Generally speaking, if you search like the usual Lark search (Lark is "Flying Book"), we will not search by the unique "work number", but by the name, but the name will be repeated, so it is not It is suitable to use textarea, but contenteditable, replacing @ text with HTML tag specialization mark.
5. The first step of code implementation: get the user's cursor position
To get the string entered by the user and replace it, the first step is to get the cursor where the user is. To obtain cursor information, you must first understand what "Selection" and "Range" are.
5.1 Range
Range is essentially a pair of "boundary points": the start of the range and the end of the range.
Each point is represented as a parent DOM node with a relative offset from the starting point. If the parent node is an element node, the offset is the number of the child node, and for a text node, it is the position in the text.
For example:
let range = newRange();
Then use range.setStart(node, offset) and range.setEnd(node, offset) to set the selection boundary.
Suppose the HTML fragment looks like this:
<pid="p">Example: italic and bold</p>
Select "Example: italic ", which are the first two child nodes of <p> (text nodes are also included):
<pid="p">Example: italic and bold</p>
<script>
let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// The toString of the range returns its content as text (without label)
alert(range); // Example: italic
document.getSelection().addRange(range);
</script>
explain:
1) range.setStart(p, 0): Set the starting point to the 0th child node of <p> (ie the text node "Example: ");
2) range.setEnd(p, 2): Cover the range to (but not including) the second child node of <p> (ie the text node "and", but since the end node is not included, the last selected node is ) .
If you do it like this:
This can also be done, as long as the start and end points are set to the relative offset in the text node.
We need to create a range:
1) Start from position 2 of the first child node (select all letters except the first two letters in "Example:");
2) To the end of position 3 of the first child node (select the first three letters of "bold", that's all), the code is as follows.
<pid="p">Example: italic and bold</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>
The range object has the following properties:
explain:
1) startContainer, startOffset —— start node and offset:
- In the above example: the first text node and 2 in <p> respectively.
2) endContainer, endOffset-end node and offset:
- In the above example: the first text node and 3
3) collapsed —— Boolean value, true if the range starts and ends at the same point (so there is no content in the range):
- In the above example: false
4) commonAncestorContainer —— the nearest common ancestor node among all nodes in the range:
- In the above example: <p>
5.2 Selection
Range is a general object used to manage selection ranges.
The document selection is represented by the Selection object, which can be obtained through window.getSelection() or document.getSelection().
According to the Selection API specification: a selection can include zero or more ranges (but in fact, only Firefox allows Ctrl+click (Cmd+click on Mac) to select multiple ranges in a document).
This is a screenshot of a selection made in Firefox with 3 ranges:
Other browsers support at most 1 range.
As we will see, some Selection methods imply that there may be multiple ranges, but again, in all browsers except Firefox, the range is at most 1.
Similar to the range, the starting point of the selection is called the "anchor", and the end point is called the "focus".
The main selection attributes are:
1) anchorNode: the selected starting node;
2) AnchorOffset: Select the offset in the anchorNode at the beginning;
3) focusNode: the selected end node;
4) focusOffset: select the offset of focusNode at the beginning;
5) isCollapsed: true if nothing is selected (empty range) or does not exist;
6) rangeCount: The number of ranges in the selection, except for Firefox, the maximum is 1 for other browsers.
After reading the above, I don’t know if you understand it? It doesn't matter, we continue down.
To sum up: Generally, we only have one Range. When our cursor flashes on the contenteditable div, there is actually a Range. The start and end positions of this Range are the same.
In addition: we can also get the corresponding node directly through Selection.focusNode, and get the corresponding offset through Selection.focusOffset.
Just like the picture below:
In this way, we get the position of the cursor and the corresponding TextNode object.
6. Code implementation step 2: Get users who need @
In the previous section, we obtained the offset of the cursor on the corresponding Node node and the corresponding Node node. Then you can get the entire text through the textContent method.
Generally speaking, the content of @ can be obtained through a simple regularity:
// Get the cursor position
const getCursorIndex = () => {
const selection = window.getSelection();
return selection?.focusOffset;
};
// Get node
const getRangeNode = () => {
const selection = window.getSelection();
return selection?.focusNode;
};
// Get @User
const getAtUser = () => {
const content = getRangeNode()?.textContent || "";
const regx = /@(1*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
if(match && match.length === 2) {
return match[1];
}
return undefined;
};
Because the insertion of @ may be the end or the middle, we also need to intercept the text before the cursor before judging.
So simply slice it:
content.slice(0, getCursorIndex())
7. Code implementation step 3: pop-up window display and button interception
The logic of whether or not the pop-up window is displayed is similar to judging @User, which is the same rule.
// Whether to show @
const showAt = () => {
const node = getRangeNode();
if(!node || node.nodeType !== Node.TEXT_NODE) returnfalse;
const content = node.textContent || "";
const regx = /@(2*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
return match && match.length === 2;
};
The pop-up window needs to appear in the correct position. Fortunately, modern browsers have many useful APIs.
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
};
};
When the pop-up window appears, we also need to intercept the "up", "down", and "enter" operations of the input box, otherwise the cursor position will shift to other places in response to these buttons in the input box.
const handleKeyDown = (e: any) => {
if(showDialog) {
if(
e.code === "ArrowUp"||
e.code === "ArrowDown"||
e.code === "Enter"
) {
e.preventDefault();
}
}
};
Then monitor these buttons in the pop-up window to realize the functions of selecting up and down, confirming with Enter, and closing the pop-up window.
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;
}
}
};
8. The third step of code implementation: replace @ text with a custom label
Rough schematic diagram:
For details, let's take a look at it step by step.
8.1 Divide the original TextNode
If the text is: "Please make me a cup of coffee @ABC, this is the content behind".
Then we need to replace the @ABC text according to the cursor position, and then divide it into two parts: "Please make me a cup of coffee" and "This is the content behind."
8.2 Create At Tag
In order to realize that the delete key can delete all deletes, you need to wrap the content of the at tag.
This is a label written in the first edition, but if it is used directly, it will be a little problematic, so I will discuss it later:
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;
};
8.3 Insert the label
First of all: we can get the focusNode node, and then we can get its parent node and sibling nodes.
What needs to be done now is: delete the old text node, and then insert "please make me a cup of coffee", "@ABC", "this is the next content" in the original position.
Take a look at the code specifically:
parentNode.removeChild(oldTextNode);
// insert in the text box
if(nextNode) {
parentNode.insertBefore(previousTextNode, nextNode);
parentNode.insertBefore(atButton, nextNode);
parentNode.insertBefore(nextTextNode, nextNode);
} else{
parentNode.appendChild(previousTextNode);
parentNode.appendChild(atButton);
parentNode.appendChild(nextTextNode);
}
8.4 Reset the cursor position
Before our operation, because the original text node was lost, our cursor was also lost. At this time, you need to reposition the cursor after the at tag.
Simply put, position the cursor before the nextTextNode node:
// Create a Range and adjust the cursor
const range = newRange();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
8.5 Optimize at tag
In step 2, we created the at tag, but there will be a little problem.
At this time, the cursor is positioned "inside the button frame", but the position of the cursor is actually correct.
In order to optimize this problem, the first thing that comes to mind is to add a "zero-wide character"-\u200b in nextTextNode.
// Add 0 wide characters
const nextTextNode = newText("\u200b"+ restSlice);
// When positioning the cursor, move one position
const range = newRange();
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);
However, things are not that simple. Because I found that it might be the same if I go forward...
One last thought: just make the content area a little wider? For example, add a space to the left and right? Then I wrapped the label with a layer...
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;
};
The rough version of the poor at the people, finally ended~
9. Summary
There are indeed many pits of rich text on the web front end, and I haven't learned much about this part of the knowledge before. Although the whole process seems rough, the technical principle is like this.
There are many imperfections, and there are better ways to discuss them together.
If you are interested, you can also play in the Playground (click here to enter).
After the above link is opened, it is like this, you can try the operation effect of the code in this article online:
10. Reference materials
[1] Selection's W3C official API manual
[2] Modern JavaScript tutorial
[3] Range's MDN online API manual
[4] Realization of @人 function in IM application on Android: imitating Weibo, QQ, WeChat, zero intrusion, high scalability
Appendix: More IM introductory practical articles
"Follow the source code to learn IM (1): teach you how to use Netty to implement the heartbeat mechanism and the disconnected reconnection mechanism"
"Learning IM from Source Code (2): Is it difficult to develop IM by yourself? Teach you how to pick up an Andriod version of IM"
"Learning IM from Source Code (3): Develop an IM server from scratch based on Netty"
"Learn IM from the source code (4): Just pick up the keyboard and do it, teach you to develop a distributed IM system with your bare hands"
"Learning IM from the source code (5): Correctly understand the IM long connection, heartbeat and reconnection mechanism, and implement it by hand"
"Learning IM from the source code (6): teach you to use Go to quickly build a high-performance and scalable IM system"
"Learning IM from the source code (7): teach you how to use WebSocket to create IM chat on the web"
"Learning IM with the source code (8): 4D long text, teach you how to create IM chat with Netty"
This article has been simultaneously published on the official account of "Instant Messaging Technology Circle".
The synchronous publishing link is: http://www.52im.net/thread-3767-1-1.html
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。