最近为实现一个新功能弄的焦头烂额 @xxx
的实现,在实现后写下些心得,供以后会跳入这坑的同志们参考。
首先,当让是考虑使用范围,由于项目仅仅需要考虑在 WEBKIT
环境下使用,所以可以不用考虑 IE
这也使得代码少了很多的 if(){}else{}
判断。在Mozilla 开发者网络上发现 selection
和 range
这两个关于选区对象和光标对象,结合 Caret(一个用于判断当前光标位置的JS插件)后,一个大致的雏形就浮现出来。
大概就长这样:
先整理思路,捋一捋实现步骤。
大致思路如下:
- 键入
@
后将选择框显示出来 - 将焦点定位在弹出框中的搜索框中
- 点击选择框中的选项时,返回输入框
- 输入框中显示
@xxx
- 将光标定位在
@xxx
之后 - 删除
@xxx
时需要整个@xxx
一起删除
由于项目使用了 angular
来构建,所以给的 demo
也是用 angular
来搭建的,但是不论用什么框架,想法有了,那么一切就好办了。
selection
和 range
对象的具体使用请参考 MDN
上的相关文章:
主要涉及的几个方法:
- getSelection(window.getSelectio):获取光标所在的区域(一个div或是一个textarea);
- selection.getRangeAt:获取光标所在区域中光标选区的信息;
- range.setStart:设置光标选区的起始位置;
- range.setEnd:设置光标选区的结束位置;
- range.deleteContents:将光标选区选中的内容删除;
- range.insertNode:在光标选区中添加内容;
- selection.extend:将选区的焦点移动到一个特定的位置;
- selection.collapseToEnd:将当前的选区折叠到最末尾的一个点。
html 结构
<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-show="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">
<portrait src="" text="'所有'"></portrait>
</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">
<portrait src="item.img" text="item.fullName.slice(-2)"></portrait>
</div>
</div>
<div class="col-2" ng-bind="item.fullName"></div>
</li>
</ul>
</div>
</div>
样式相关的CSS
代码就不放上来了,简要分析下页面结构,一个 contenteditable="true"
的输入框和一个 id="selectPerson"
的选人框。
- 输入框使用
contenteditable="true"
主要是因为想在输入框中插入标签,将@xxx
内容显示出不同的颜色(这就需要将@xxx
放在一个标签中),绑定keyIn
的键盘输入事件,用于检索用户输入@
和backspace
,并做出相应的动作; - 选人框使用
showSelect
来控制是否显示,遍历显示需要显示的选人,以及使用input
中的内容来过滤选人。
实现 @ 选择
相关代码如下:
$scope.keyIn = function(e) {
var selection = getSelection();
var ele = $('#demo');
if (e.code == 'Digit2' && e.shiftKey) {
$scope.showSelect = true;
var offset = ele.caret('offset');
$scope.sPersonPosi = {
left: offset.left - 10 + 'px',
top: offset.top + 20 + 'px'
};
// 让选人框中的搜索框获取焦点
$timeout(function(){
$('#searchPersonInput')[0].focus();
})
}
}
实现起来挺简单,代码也不复杂,利用 caret
插件获取到光标位置,将选人框在 @
符号的下方显示出来,并同时实现了步骤中的第二步:将焦点放在搜索框中。
选人实现
主要涉及步骤为:3、4、5
。
当鼠标点击备选项时需要按顺序进行 3、4、5
步骤,所以需将 3、4、5
这 3
个步骤放在一起。
相关代码如下:
$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();
};
我们需要的效果是在 @
选人后,将整理好的 @xxx
包装成一个标签,放在原先 @
的位置,所以我们需要对原先的 $scope.keyIn
方法进行改造,保留原先的光标信息,方便在上面的方法中使用。
改造后的 $scope.keyIn
方法如下:
$scope.keyIn = function(e) {
var selection = getSelection();
var ele = $('#demo');
if (e.code == 'Digit2' && e.shiftKey) {
$scope.showSelect = true;
// 保存光标信息
lastSelection = {
range: selection.getRangeAt(0),
offset: selection.focusOffset,
selection: selection
};
$scope.showSelect = true;
// 设置弹出框位置
var offset = ele.caret('offset');
$scope.sPersonPosi = {
left: offset.left - 10 + 'px',
top: offset.top + 20 + 'px'
};
$timeout(function(){
$('#searchPersonInput')[0].focus();
})
}
}
这里估计挺多人会有疑问,为啥要在生成的标签后面加一个空格,而且这个空格要通过
这样的方式实现。
首先,先解释第一个问题:为啥要在标签后加一个空格?
如果不加空格的话,之后在输入文字会添加在我们生成的标签中,也就是说如果不加空格来隔断我们生成的标签,我们在文本框里所做的操作就是在我们生成的标签中进行。而加了个空格就为了避免该问题的发生,使得文本编辑在正确的编辑框中进行。
第二个问题:为啥不能直接加空格 ' '
,而是通过
,不得不说这是个过个悲伤的事实,还是碰到了兼容性的问题,在 chrome
下运行好好的代码,在 node-webkit
中就会各种报错。原因在不断的 defug
后发现了: node-webkit
中,将一个 ' '
添加到 contenteditable="true"
的 div
中会没有啊,坑爹啊有木有!!!呈上之前的代码来祭奠下。
var spanNode1 = document.createElement('span');
var node = document.createTextNode(' ');
spanNode1.className = 'at-text';
spanNode1.innerHTML = '@' + person.fullName;
var frag = document.createDocumentFragment();
frag.appendChild(spanNode1);
frag.appendChild(node);
range.insertNode(frag);
selection.extend(node, 1);
结果一上 node-webkit
环境各种报错。真是坑了个大爹。原因是光标定位不准,指定位置超出实际位置,但是 node-webkit
环境确实是可以输入空格的,一看原来是
而
不能通过 createTextNode
来创建,所以就有了之前的哪个曲线救国的策略了。
删除实现
终于捋到最后一个步骤了,删除时,需要将一整个标签一起删除。由于需要监听键盘的输入,所以就可与之前 keyIn
的代码写在一起。
最终的 keyIn
代码为:
$scope.keyIn = function(e) {
var selection = getSelection();
var ele = document.getElementById('demo');
if (e.code == 'Digit2' && e.shiftKey) {
// 保存光标信息
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);
}
};
代码的逻辑都写在注释里了,这里就不多说了。
这样就完成 @
这一功能了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。