background
One day, the product reported the research and development results and product performance output to the boss.
The product is great: boss is very satisfied with our research and development results, balabala... (inner OS: don't listen, focus on the key points)
The product is great: our customer service IM desktop client is now capable of receiving online visitors one-to-many. Later, we want to make special problems for visitors that cannot be solved by the current reception customer service, and can pull the special customer service to initiate a group chat.
Me: Well, this is a good idea. The dedicated customer service can see the context of the previous conversation at a glance, and the visitor does not need to restart the chat.
Da da da, the function is completed, and it will be launched in two weeks for acceptance. The customer is very satisfied, and the boss also ordered a 👍🏻 in the group.
The product is coming again: Well, this function is good, we also support group chat for customer service, which helps customer service group communication and problem feedback.
Me: Well, this idea is also good. The content of group chats will also help in the later mining of user voice data.
Bang bang bang, it's done, go online, adjust your mentality, and wait to be overwhelmed by the big guys 👍🏻 from all walks of life.
Customer Service A: It's crazy, you don't give @ function in group chat, how can I diao you to develop it.
Customer Service B/C/D: ➕10086.
I:. . .
Hahaha, the above virtual scene is purely for fun, the following is how to perfectly realize a @ function expansion, including teaching and meeting.
Before entering the topic, let's prepare some "basic operations" knowledge of the cursor selection. The elements on the page that operate on the cursor selection are roughly divided into two categories: text/textarea text input box elements and rich text elements. The following is a brief introduction to some commonly used APIs.
text/textarea to operate the cursor selection in the text input box
First of all, looking at this kind of operation method, it is almost unnecessary to use the Selection
and Range
related APIs, using the native method of the text input box element.
Actively select an area
To actively select an area in the text input box element, you can use setSelectionRange .
element.setSelectionRange(selectionStart, selectionEnd [, selectionDirection]);
- selectionStart The position index of the first character selected, starting at 0.
- selectionEnd The next position index of the last character selected.
- selectionDirection A string for the selection direction.
$input.setSelectionRange(0, 6);
// $input.select() // 全部选中
$input.focus();
focus on a location
If we want to move the cursor to the back of 😃😄😁, it is actually the same effect as setting the starting position of the selection area
$input.setSelectionRange(6, 6);
$input.focus();
Restore previous selection
In some scenarios, we have selected the current input
text box selection. We need to do other operations, and we need to restore the selection when we come back. At this time, we must save the selection location before doing other operations, which will be used selectionStart
and selectionEnd
two attributes.
$input.addEventListener('mouseup', () => {
this.pos = {
start: $input.selectionStart,
end: $input.selectionEnd,
};
});
const { start, end } = this.pos;
$input.setSelectionRange(start, end);
$input.focus();
Insert (replace) content in the specified selection
To insert content or replace the content of the selection at a certain position in the specified text input box, you can use setRangeText to achieve this.
setRangeText(replacement)
setRangeText(replacement, start, end, selectMode)
This method has 2 forms, the second form has 4 parameters:
- The first parameter
replacement
is the replacement text; -
start
andend
is the starting position, the default value is the position of the currently selected area of the element; - The last parameter
selectMode
is the state of the selection area after replacement. It has 4 states: select (selected after replacement), start (cursor before replacement after replacement), end (cursor after replacement after replacement) ) and preserve (default, try to preserve the selection);
We insert or replace text at the cursor or selection position 😂😂😂, you can do this
$input.setRangeText('😂😂😂');
$input.focus();
Let's take a look at the second form with 4 parameters. It is found that after inserting the content at the starting position we specified, the current selection and cursor try to retain. You can test the other three scenarios of the state of the constituency after the replacement.
$input.setRangeText('😂😂😂', 8, 8, 'preserve');
$input.focus();
About the operation of the selection area, the operations in the input/textarea input box are almost the same, and the following is a brief summary
Operate cursor selection in rich text - Selection & Range
set rich text
First of all, rich text elements are editable elements. You can see the cursor on the elements. In addition to form elements, you can also add attributes to ordinary elements to convert text rich text elements. You can convert them in the following three ways.
- Add attribute to element
contenteditable="true"
; - Add CSS property
-webkit-user-modify: "read-write"
; - Set by the
document.designMode="on"
method of js;
Selection & Range
In the operation of cursor and selection in rich text, we have to mention two native objects of JavaScript: Selection and Range .
- The Selection object represents the text range selected by the user or the current position of the caret. It represents a selection of text in the page, which may span multiple elements. Text selections are created by the user dragging the mouse over text. To get the
Selection
object for inspection or modification, call window.getSelection() . - Range objects represent document fragments containing nodes and partial text nodes. The
selection
object obtained through therange
object is the focus of our operation of the cursor.
You may have noticed that getRangeAt(0)
, there may be multiple constituencies range
? Really, Firefox supports multiple constituencies, through the cmd
key ( windows
above is ctrl
key) can realize multiple selection area.
Look at an attribute returned by a range, collapsed
, which indicates whether the start and end points of the selection overlap. When collapsed
is true
, the selected area is compressed into a point. For ordinary elements, you may not see anything. If it is on an editable element, this is compressed The dot becomes a blinking cursor.
Actively select a node with rich text
To select an independent tag in rich text, two APIs can be used, Range.selectNode() and Range.selectNodeContent() , the difference between the two is that the former includes the node itself, while the latter does not include itself. For example, if we want to independently select the second sub-element of rich text <span style="color: #00965e;">Selection</span>
, use the Range.selectNode()
API to select the element, delete the entire element, and enter the content without style.
const $span = $input.childNodes[1];
range.selectNode($span);
selection.removeAllRanges();
selection.addRange(range);
Of course, some browsers (such as Chrome104) are different. After deleting the entire node, a tag ( font
) will be inserted into the newly entered content and the previous style will be integrated.
Use the Range.selectNodeContents()
API to select the internal content of the node. When the selected content is deleted, the node itself is still there.
const $span = $input.childNodes[1];
range.selectNodeContents($span);
selection.removeAllRanges();
selection.addRange(range);
The above is the selection operation of a single node in the rich text. However, in fact, the nodes in the rich text may be nested or parallel. How to select the operation across multiple elements?
Actively select an area of rich text
A form input box has only a single text, while a rich text element or a normal element contains multiple elements. When you actively select an area of the page, you must first create a Range
object, which may span multiple elements, so you need to set the start node of the selection, which are Range.setStart() and Range.setEnd() method.
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
- startNode start node
- startOffset The offset from the start position of startNode
- endNode end node
- endOffset the offset from the end of endNode
The above is to set the selection range, then add it to the selection and check Selection.addRange() . However, generally before adding, the previous selection will be cleared, then Selection.removeAllRanges() will be used.
const selection = document.getSelection();
const range = document.createRange();
range.setStart($input.firstChild, 0);
range.setEnd($input.firstChild, 4);
selection.removeAllRanges();
selection.addRange(range);
Note that the first child node (text node) of the rich text element is taken here, not the element itself, otherwise the offset will be exceeded and an error will occur. Because there is a difference between calculating element node and text node offsets:
If the start node type is one of Text, Comment, or CDATASection, then startOffset refers to the offset in characters from the start node. For other Node type nodes, startOffset refers to the offset of the child node from the start node.
So the question is, if I want to select the paragraph at the beginning of the rich text 😃😄😁<span style="color: #00965e;">Selection</span> 对象
, how to do it?
For a more complex rich text element structure, to achieve arbitrary range selection, the key is to find the start node and end node, and their respective offsets.
It seems that there is no better native method to use. Here is a solution. First, traverse the rich text including all text nodes, then record the offset of the boundary of each node, find the start and end nodes that satisfy the interval conditions, and finally Calculate the required text offset from the start and end nodes.
/**
* 获取所有文本节点及其偏移量
* @param {HTMLElement} wrapDom 最外层节点
* @param {Number} start 开始位置
* @param {Number} end 结束位置
* @returns
*/
function getNodeAndOffset(wrapDom, start = 0, end = 0) {
const txtList = [];
const map = function (children) {
[...children].forEach((el) => {
if (el.nodeName === '#text') {
txtList.push(el);
} else {
map(el.childNodes);
}
});
};
// 递归遍历,提取出所有 #text
map(wrapDom.childNodes);
// 计算文本的位置区间
const clips = txtList.reduce((arr, item, index) => {
const end =
item.textContent.length + (arr[index - 1] ? arr[index - 1][2] : 0);
arr.push([item, end - item.textContent.length, end]);
return arr;
}, []);
// 查找满足条件的范围区间
const startNode = clips.find((el) => start >= el[1] && start < el[2]);
const endNode = clips.find((el) => end >= el[1] && end < el[2]);
return [startNode[0], start - startNode[1], endNode[0], end - endNode[1]];
}
With our own encapsulated tool method, we can select any text range without considering the element structure of rich text.
const selection = document.getSelection();
const range = document.createRange();
const nodes = this.getNodeAndOffset($input, 4, 17);
range.setStart(nodes[0], nodes[1]);
range.setEnd(nodes[2], nodes[3]);
selection.removeAllRanges();
selection.addRange(range);
focus on a location
Through the above, it has been achieved to select any range of text, and then look at focusing the cursor on a certain position, it is much simpler, just choose to set the same starting and ending range positions.
const selection = document.getSelection();
const range = document.createRange();
const nodes = this.getNodeAndOffset($input, 7, 7);
range.setStart(nodes[0], nodes[1]);
range.setEnd(nodes[2], nodes[3]);
selection.removeAllRanges();
selection.addRange(range);
Restore previous selection
Save the current cursor position with the text input box. In rich text, you can save the entire selection, and then restore the selection later.
// 保存光标
$input.addEventListener('mouseup', () => {
const selection = document.getSelection();
const range = selection.getRangeAt(0);
this.lastRange = range;
});
// 还原光标
const selection = document.getSelection();
const range = document.createRange();
selection.removeAllRanges();
selection.addRange(this.lastRange);
Insert (replace) content in the specified selection
To insert content in the selection, you can use the Range.insertNode() method, which means inserting a node at the starting point of the selection, and will not replace the currently selected one. If you need to replace, you can delete it first. To delete the selection, you can use Range.deleteContent( ) method.
const $text = document.createTextNode('😂😂😂');
this.lastRange.deleteContents();
this.lastRange.insertNode($text);
It is found from the above customer service that after inserting the content, the content is selected by the selection. If you want the cursor to be behind the inserted content, you can use Range.setStartAfter() to set the starting point of the selection to the back of the element, and the end point of the default selection is Range behind the element. .setEndAfter() , no need to set.
this.lastRange.setStartAfter($text);
$input.focus();
Similarly, Range.setEndBefore and setStartBefore can also be used to set the cursor to the front of the content.
Wrap the label for the specified selection
There are also some more advanced usage applications, such as adding a background mark effect to a sentence, similar to adding a background color to the text selection of a word document. This can be achieved through the Range.surroundContents() method.
However, if the selection contains multiple elements, that is, if a non-text node is disconnected and only contains one of the boundaries of the node, an exception will be thrown.
So how can you circumvent this problem and implement checkmarks across multiple nodes? The answer is extractContents() , which will move our selection multi-node to the DocumentFragment
object, it should be noted that
Event listeners added using DOM events are not persisted during fetch. HMTL property events will be preserved and copied as-is by the Node.cloneNode() method. The HTML id attribute is also cloned, which can result in an invalid document if a partially selected node is extracted and appended to the document.
Wrap the document fragment with a tag and insert it.
const $mark = document.createElement('mark');
// this.lastRange.surroundContents($mark)
const fragments = this.lastRange.extractContents();
$mark.append(fragments);
this.lastRange.insertNode($mark);
The position coordinates of the cursor/selection
Sometimes we want to determine the selected part of the text area or the window coordinates of the cursor, so that similar notes or floating boxes can be positioned nearby. The Range.getBoundingClientRect() API can be used here, which returns a DOMRect object that encloses the contents of the range; that is, the object is a rectangle that encloses all elements in the range.
const pos = this.lastRange.getBoundingClientRect();
const highlight = document.getElementById('highlight');
highlight.style.left = `${pos.x}px`;
highlight.style.top = `${pos.y}px`;
highlight.style.width = `${pos.width}px`;
highlight.style.height = `${pos.height}px`;
#highlight {
position: absolute;
background-color: aqua;
}
3 rows are selected, but not the complete 3 rows. The information returned is the rectangular position coordinates of a minimum wrapping selection. If you need to know the more detailed position coordinates of each element in the selection, you can use Range.getClientRects() , which What is returned is a list of DOMRect objects representing the area the Range occupies on the screen. This list is equivalent to aggregating the results of calling Element.getClientRects() on all elements in the range.
this.lastRange.getClientRects();
There are many APIs for Selection&Range, the commonly used ones are roughly listed above, and also briefly summarized.
Function implementation of @
If you are already familiar with the above "basic operations", then half the success of implementing a @ function is achieved, and the remaining half is the idea, which can be roughly divided into the following steps:
- Monitor user input
@
character, display user list; - Click on the user, causing the input box to lose focus, and save the cursor information in time;
- Click Finish, the user list is hidden, the cursor of the input box is restored, and the user name is inserted after the cursor;
First we write HTML
, CSS
part is omitted here
<!-- 富文本聊天消息输入框 -->
<div
class="chat-input"
ref="chatInput"
contenteditable="true"
placeholder="请输入内容"
@input="inputChatContent"
@blur="chatContentBlur"
@mouseup="chatContentMouseup"
></div>
<!-- 用户列表浮窗 -->
<ul
class="popper"
v-show="isShowUserList"
ref="popper"
:style="popperStyle"
v-click-out-hide
>
<li v-for="(item, index) in userList" :key="index" @click="selectUser(item)">
<el-row>{{item.name}}</el-row>
</li>
</ul>
Then we monitor the rich text input
event, when the @ character is monitored, the user list will be displayed, otherwise it will be hidden. At the same time, use Range.getBoundingClientRect() to get the position of the cursor, so that the floating frame of the user list can be positioned near the cursor.
/**
* 输入聊天内容
* @param {*} ev
*/
inputChatContent(ev) {
if (ev.data === '@') {
const pos = this.getCaretPos()
this.showUserList()
this.$nextTick(() => {
this.setUserListPos(pos)
})
} else {
this.hideUserList()
}
},
/**
* 获取光标位置
* @returns
*/
getCaretPos() {
const range = this.getRange()
const pos = range.getBoundingClientRect()
return pos
},
/**
* 设置用户列表的位置
* @param {*} pos
*/
setUserListPos(pos) {
const $popper = this.$refs.popper
const panelWidth = $popper.offsetWidth
const panelHeight = $popper.offsetHeight
const { x, y } = pos
this.popperStyle = {
top: y - panelHeight - 20 + 'px',
left: x - panelWidth / 2 + 'px'
}
},
hideUserList() {
this.isShowUserList = false
},
showUserList() {
this.isShowUserList = true
},
When clicking on the user list, the input box will be out of focus. At this time, save the cursor first, create the user name text node to be inserted at the same time, then restore the cursor, and then Range.insetNode()
put the user name text node Range.insetNode()
insert into the selection, and finally Selection.removeAllRanges()
remove all page selections, Selection.addRange()
insert into the current selection.
/**
* 选择用户
*/
selectUser(user) {
// 让失焦事件先执行
setTimeout(() => {
this.hideUserList()
this.insertContent(user)
})
},
/**
* 恢复光标
*/
restoreCaret() {
if (this.lastRange) {
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(this.lastRange)
}
},
/**
* 插入内容
* @param {*} data
*/
insertContent(data) {
this.restoreCaret() // 还原光标
const selection = window.getSelection()
const range = selection.getRangeAt(0)
range.collapse(false) // 折叠选区,光标移到最后
range.insertNode(data.content)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
@Function Enhanced Edition
The above is a simple implementation of the @ function, and there are more places to improve user experience in practical applications
- Currently, the user name is deleted through the return key
@xxx
, and it is found that the deletion is character by character, and what the user wants more is to click the return key to delete the entire user name; - How to send it out and parse it into a specific data structure that the backend can recognize, and how to convert the specific data structure back to rich text?
With these two problems for the time being, how to solve them?
Implement the overall deletion of usernames
The first thing that comes to mind is that the username inserted into the rich text is a label, and you can delete the entire label by pressing the back button? The answer is no, the text in the rich text label is deleted one by one if it is not selected. Unless the element is a non-editable element span.contentEditable = false
.
Here we need to put @xxx
into the non-editable tag, and delete it together. At this point, there will be a problem. After each user tag is inserted, there will be an extra @ character, so the @ character that has been entered before needs to be deleted before inserting the tag. The specific implementation is to find the start node where the selection area is located Range.startContainer
, and then find the offset of the end position of the selection area Range.endOffset
, under normal circumstances, the previous end of the selection area is the @ character, select delete Can.
/**
* 删除输入框中光标位置现有的@字符
*/
deleteCaretAtCode() {
const range = this.getRange()
// 光标开始节点和光标在节点上的偏移量,找到光标准确位置,选中光标位置前一个字符范围并删除,
const node = range.startContainer
const end = range.endOffset
// 开始节点内容最后一个字符是@,删除,否则不删除
if (node.textContent[end - 1] === '@') {
range.setStart(node, end ? end - 1 : 0)
range.deleteContents()
}
},
/**
* 转换要插入光标位置的内容
* @param {*} data
*/
parseContent(data) {
const { type = 'text', name } = data
let content = null
// type 是插入内容类型,可能是文本、@标签、图片、表情等
if (type === 'text') {
content = document.createTextNode(name)
} else if (type === 'at') {
// 删除输入框中光标位置现有的@字符
this.deleteCaretAtCode()
const $span = document.createElement('span')
$span.contentEditable = false
$span.classList.add('tag')
$span.innerHTML = `@${name}`
// 插入一个空格字符(\u0010)到@标签后面,可以解决部分浏览器上光标在聊天输入框后面
const $space = document.createTextNode('\u0010')
const frag = document.createDocumentFragment()
frag.appendChild($span)
frag.appendChild($space)
content = frag
}
return content
},
/**
* 插入内容
* @param {*} data
*/
insertContent(data) {
this.restoreCaret() // 还原光标
const selection = window.getSelection()
const range = selection.getRangeAt(0)
range.collapse(false) // 折叠选区,光标移到最后
const pc = this.parseContent(data)
range.insertNode(pc)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
some compatible processing
You may have noticed that a space character is added after the user label @xxx
, so that the cursor can be displayed, but you need to press the backspace key twice to delete the label, which depends on the business needs. .
Note that on Mac Chrome (V104), if you do not append characters after the label, the cursor will be behind the outer rich text label. It is normal to add any characters after the non-editing label.
After testing, the Firefox browser has such a bug: the user label @xxx
is not inserted into the correct position of the rich text input box, but is inserted into the item clicked by the user list.
It is bold to speculate that Firefox can also set the cursor for ordinary elements, so that when an element in the user list is clicked, the element obtains the cursor, and then obtains a new selection selection.getRangeAt(0)
In fact, on the clicked element, the non-recovery rich On the text input box, and then insert the user label, which is the above scenario.
My solution here is to set the user list item to be unselectable, so naturally the cursor cannot be obtained.
.popper li {
/* 用户不能选中文本 firfox 非编辑编辑元素也可选中 */
user-select: none;
-webkit-user-select: none;
list-style: none;
padding: 10px;
}
Let's take a look at the performance on Safari again
It was found that the preceding @
characters were not deleted as expected, and an error was reported in the console.
The general meaning is that the 0th position in selection.getRangeAt(0)
is beyond the allowable range. Does Safari clear the selection by default when the input box loses focus? It was found through experiments that
// 输入框失去焦点和获取焦点时打印选区个数
const selection = window.getSelection();
console.log('selection.rangeCount: ', selection.rangeCount);
The normal number of other browsers' selections remains the same
So how to solve this situation? We save the selection when the input box is out of focus. At this time, Safari has emptied the selection, so the selection saved at this time is empty. Therefore, we need to save the selection in front, and enter @
Save the selection (cursor) when you character, you can do this
/**
* 输入聊天内容
* @param {*} ev
*/
inputChatContent(ev) {
if (ev.data === '@') {
// 在输入@字符时候就保存一下光标
this.saveCaret()
const pos = this.getCaretPos()
this.showUserList()
this.$nextTick(() => {
this.setUserListPos(pos)
})
} else {
this.hideUserList()
}
},
Summarize
This article takes the "perfect" implementation of an @ function as an introduction, and introduces the input/textarea text input box and the selection operation of rich text. On this basis, combined with the realization idea of the @ function, and improve while practicing, there are many places that have not been fully considered, just as a demo of learning, welcome to correct. The online case of this article can be clicked here , end~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。