最近为实现一个新功能弄的焦头烂额 @xxx
的实现,在实现后写下些心得,供以后会跳入这坑的同志们参考。
首先,当让是考虑使用范围,由于项目仅仅需要考虑在 WEBKIT
环境下使用,所以可以不用考虑 IE
这也使得代码少了很多的 if(){}else{}
判断。在 Mozilla 开发者网络 上发现 selection
和 range
这两个关于选区对象和光标对象,结合 Caret (一个用于判断当前光标位置的JS插件)后,一个大致的雏形就浮现出来。
大概就长这样:
先整理思路,捋一捋实现步骤。
大致思路如下:
键入 @
后将选择框显示出来
将焦点定位在弹出框中的搜索框中
点击选择框中的选项时,返回输入框
输入框中显示 @xxx
将光标定位在 @xxx
之后
删除 @xxx
时需要整个 @xxx
一起删除
由于项目使用了 angular
来构建,所以给的 demo
也是用 angular
来搭建的,但是不论用什么框架,想法有了,那么一切就好办了。
selection
和 range
对象的具体使用请参考 MDN
上的相关文章:
selection
range
DEMO页
主要涉及的几个方法:
getSelection(window.getSelectio):获取光标所在的区域(一个div或是一个textarea);
selection.getRangeAt:获取光标所在区域中光标选区的信息;
range.setStart:设置光标选区的起始位置;
range.setEnd:设置光标选区的结束位置;
range.deleteContents:将光标选区选中的内容删除;
range.insertNode:在光标选区中添加内容;
selection.extend:将选区的焦点移动到一个特定的位置;
selection.collapseToEnd:将当前的选区折叠到最末尾的一个点。
<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' }; // 让选人框中的搜索框获取焦点 $('#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' }; $('#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' }; $('#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); } };
代码的逻辑都写在注释里了,这里就不多说了。
这样就完成 @
这一功能了。