web前端实现@提醒功能
@xxx功能很多地方用到,比如微博、微信、qq这些通信的都有用到,那么这个功能是如何实现的?
在开始实现功能前,先整理一下思路:
- 输入@后将列表选择框显示出来
- 点击选择框中的选项时,返回输入框
- 在输入框中显示@xxx
- 将光标放置@xxx之后
- 删除@xxx时需要整个@xxx一起删除
- 需要考虑兼容性问题
主要涉及的方法技术点:
- 获取Selection对象,Selection对象表示页面中的文本选区,window.getSelection()获取
- 获取光标在文本选区的光标信息Range对象(表示包含节点和部分文本节点的文档片段),Selection.getRangeAt(0)获取、document.createRange()获取、通过构造函数range()获取
- 获取光标的位置作为列表选择框的位置,通过jquery.caret的$(ele).caret('offset')获取相对窗口的位置
- 保存当前的光标的信息,包括range、offset、selection对象
- 选提醒人后,需通过获取之前保留的光标信息,通过range.startContainer获取光标前的文本
- range.setStart()、range.setEnd()设置光标的选区起始位置,进行选中@字符
- range.deleteContents删除光标选区的内容删除
- range.insertNode在光标选区中添加内容
- selection.extend将选区的焦点移动到一个特定的位置
- selection.collapseToEnd将当前的选区折叠到最尾的一个点
Angular框架实现
<!DOCTYPE html>
<html ng-app='@DEMO'>
<head>
<meta charset="UTF-8">
<title></title>
<script src="http://cdn.bootcss.com/angular.js/1.6.1/angular.js"></script>
<script src="http://cdn.bootcss.com/jquery/3.1.1/jquery.js"></script>
<script src="http://cdn.bootcss.com/Caret.js/0.3.1/jquery.caret.min.js"></script>
<style type="text/css">
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
.demo {
width: 80%;
height: 300px;
border: 2px solid #ccc;
border-radius: 20px;
padding: 20px;
outline: none;
}
.demo-wrap {
position: relative;
}
.select-person {
position: absolute;
width: 160px;
border: 1px solid #dcdcdc;
border-radius: 4px;
}
.select-person input {
width: 100%;
height: 40px;
border: none;
outline: none;
padding: 0 10px;
}
.row {
display: flex;
height: 40px;
border-top: 1px solid #dcdcdc;
align-items: center;
cursor: pointer;
}
.row:hover {
background: #ccc;
}
.row .col-1 {
width: 40px;
flex: 0 0 40px;
}
.row .col-2 {
width: 40px;
flex: auto;
}
.img-wrap {
width: 30px;
height: 30px;
border-radius: 15px;
background: #4caf50;
margin: auto;
font-size: 12px;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.at-text {
color: #4caf50;
}
</style>
</head>
<body>
<div class="demo-wrap" ng-controller="Controller">
<!-- 文本输入框 -->
<div class="demo" id="demo" contenteditable="true" ng-keydown="keyIn($event)"></div>
<!-- 带有输入框的选人框 -->
<div class="select-person" id="selectPerson" ng-if="showSelect" ng-style="sPersonPosi">
<input type="text" id="searchPersonInput" ng-model="personSearchText" ng-blur="missFocus()">
<ul class="person-wrap">
<li class="row" ng-click="sPersonDone({fullName:'所有人'})">
<div class="col-1">
<div class="img-wrap">
<span ng-bind="'所有'"></span>
</div>
</div>
<div class="col-2">所有人</div>
</li>
<li class="row" ng-click="sPersonDone(item)" ng-repeat="item in atList | filter :{fullName: personSearchText}">
<div class="col-1">
<div class="img-wrap">
<span ng-bind="item.fullName.slice(-2)"></span>
</div>
</div>
<div class="col-2" ng-bind="item.fullName"></div>
</li>
</ul>
</div>
</div>
</body>
<script type="text/javascript">
angular.module('@DEMO', [])
.controller('Controller', ['$scope', '$timeout', function($scope, $timeout) {
$scope.atList = [{
fullName: '张三'
}, {
fullName: '李四'
}, {
fullName: '王五'
}, {
fullName: '战前孙'
}]
$scope.keyIn = function(e) {
var selection = getSelection();
var ele = document.getElementById('demo');
if (e.code == 'Digit2' && e.shiftKey) {
console.log(selection);
// 保存光标信息
lastSelection = {
range: selection.getRangeAt(0),
offset: selection.focusOffset,
selection: selection
};
$scope.showSelect = true;
// 设置弹出框位置
var offset = $(ele).caret('offset');
$scope.sPersonPosi = {
left: offset.left + 'px',
top: offset.top + 30 + 'px'
};
$timeout(function() {
$('#searchPersonInput')[0].focus();
})
} else if (e.code == 'Backspace') {
// 删除逻辑
// 1 :由于在创建时默认会在 @xxx 后添加一个空格,
// 所以当得知光标位于 @xxx 之后的一个第一个字符后并按下删除按钮时,
// 应该将光标前的 @xxx 给删除
// 2 :当光标位于 @xxx 中间时,按下删除按钮时应该将整个 @xxx 给删除。
var range = selection.getRangeAt(0);
var removeNode = null;
if (range.startOffset <= 1 && range.startContainer.parentElement.className != "at-text")
removeNode = range.startContainer.previousElementSibling;
if (range.startContainer.parentElement.className == "at-text")
removeNode = range.startContainer.parentElement;
if (removeNode)
ele.removeChild(removeNode);
}
};
$scope.sPersonDone = function(person) {
// 成功选人后,关闭选择框,让输入框获取焦点。
$scope.showSelect = false;
var ele = $('#demo')[0];
ele.focus();
// 获取之前保留先来的信息。
// 需要修改 keyIn 的代码,保存选区以及光标信息,用于获取在光标焦点离开前,光标的位置
var selection = lastSelection.selection;
var range = lastSelection.range;
var textNode = range.startContainer;
// 删除 @ 符号。
range.setStart(textNode, range.endOffset);
range.setEnd(textNode, range.endOffset + 1);
range.deleteContents();
// 生成需要显示的内容,包括一个 span 和一个空格。
var spanNode1 = document.createElement('span');
var spanNode2 = document.createElement('span');
spanNode1.className = 'at-text';
spanNode1.innerHTML = '@' + person.fullName;
spanNode2.innerHTML = ' ';
// 将生成内容打包放在 Fragment 中,并获取生成内容的最后一个节点,也就是空格。
var frag = document.createDocumentFragment(),
node, lastNode;
frag.appendChild(spanNode1);
while ((node = spanNode2.firstChild)) {
lastNode = frag.appendChild(node);
}
// 将 Fragment 中的内容放入 range 中,并将光标放在空格之后。
range.insertNode(frag);
selection.extend(lastNode, 1);
selection.collapseToEnd();
};
$scope.missFocus = function() {
setTimeout(function() {
$scope.showSelect = false;
}, 100);
}
}]);
</script>
</html>
js封装实现
function AtMembers(iframs){
this.lastSelection = null;
this.sPersonPosi = null;
this.iframeObj = iframs;
this.clientWidth = $(window).width();
this.clientHeight = $(window).height();
this.init = function () {
this.getDomObj();
this.events();
}
}
AtMembers.prototype.getDomObj = function () {
this.childWindow = this.iframeObj.contentWindow;
$(this.iframeObj).contents().find("body").attr('id', 'dynamicText');
this.textareaObj = this.childWindow.document.getElementById('dynamicText');
this.memberWrap = $('#memberListAt');
}
AtMembers.prototype.events = function () {
var self = this;
//输入框的键盘事件
this.textareaObj.onkeydown = function (event) {
var selection = self.childWindow.getSelection();
var e = event ? event : window.event;
if ((e.code == 'Digit2' || e.which === 50) && e.shiftKey) {
// 保存光标信息
self.lastSelection = {
range: self.childWindow.document.selection ? self.childWindow.document.selection.createRange() : selection.getRangeAt(0),
offset: selection.focusOffset,
selection: selection
};
// 设置弹出框位置
var offset = $(self.textareaObj).caret('offset', {iframe: self.iframeObj});
offset.left += $(self.iframeObj).offset().left;
offset.top += $(self.iframeObj).offset().top;
position = $(self.textareaObj).caret('position', {iframe: self.iframeObj});
self.sPersonPosi = {
left: self.clientWidth -300 < offset.left ? (self.clientWidth -300)+'px' : offset.left + 'px',
top: self.clientHeight -450 < offset.top ? (self.clientHeight -450)+'px' : (offset.top + 30) + 'px'
};
self.memberWrap.css(self.sPersonPosi).css({display: 'block'});
} else if (e.code == 'Backspace') {
// 删除逻辑
// 1 :由于在创建时默认会在 @xxx 后添加一个空格,
// 所以当得知光标位于 @xxx 之后的一个第一个字符后并按下删除按钮时,
// 应该将光标前的 @xxx 给删除
// 2 :当光标位于 @xxx 中间时,按下删除按钮时应该将整个 @xxx 给删除。
var range = selection.getRangeAt(0);
var removeNode = null;
if (range.startOffset <= 1 && range.startContainer.parentElement.className != "member-at-text"){
removeNode = range.startContainer.previousElementSibling;
}
if (range.startContainer.parentElement.className == "member-at-text"){
removeNode = range.startContainer.parentElement;
}
if (removeNode && removeNode.className === "member-at-text"){
self.textareaObj.removeChild(removeNode);
}
}else{
self.memberWrap.css({display: 'none'});
}
}
//保存
this.memberWrap.on('click', 'li', function () {
self.person = {};
self.person = {
id: $(this).attr('data-id'),
name: $(this).find('span').text()
};
self.memberWrap.css({display: 'none'});
$(self.textareaObj).focus();
// 获取之前保留先来的信息。
// 需要修改 keyIn 的代码,保存选区以及光标信息,用于获取在光标焦点离开前,光标的位置
var selection = self.lastSelection.selection;
var range = self.lastSelection.range;
var textNode = range.startContainer;
// 删除 @ 符号。
range.setStart(textNode, range.endOffset);
range.setEnd(textNode, range.endOffset + 1);
range.deleteContents();
// 生成需要显示的内容,包括一个 span 和一个空格。
var spanNode1 = document.createElement('a');
var spanNode2 = document.createElement('span');
spanNode1.className = 'member-at-text';
spanNode1.setAttribute('data-id', self.person.id);
spanNode1.setAttribute('href', '/user/center/'+self.person.id);
spanNode1.setAttribute('target', '_blank');
spanNode1.innerHTML = '@' + self.person.name;
spanNode2.innerHTML = ' ';
// 将生成内容打包放在 Fragment 中,并获取生成内容的最后一个节点,也就是空格。
var frag = document.createDocumentFragment(),
node, lastNode;
frag.appendChild(spanNode1);
while ((node = spanNode2.firstChild)) {
lastNode = frag.appendChild(node);
}
// 将 Fragment 中的内容放入 range 中,并将光标放在空格之后。
range.insertNode(frag);
selection.extend(lastNode, 1);
selection.collapseToEnd();
});
$(window).resize(function () {
self.clientWidth = $(window).width();
self.clientHeight = $(window).height();
});
}
Seletion的文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Selection
Range的文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Range
caret的github:http://ichord.github.com/Caret.js/
https://www.npmjs.com/package/jquery.caret
caret实现获取光标的位置信息(依赖jquery)
- 获取相对父元素的偏移量
$('#inputor').caret('position');
- 获取相对文档的偏移量
$('#inputor').caret('offset');
- 获取指定位置的光标坐标
var fixPos = 20;
$('#inputor').caret('position', fixPos );
$('#inputor').caret('offset', fixPos );
- 在iframe的文本编辑
var iframeObj = $('#iframeId')[0];
$('#inputor').caret('offset',{ iframe: iframeObj });