/ javaScript

@ 功能 JS 实现分析及其原理

最近为实现一个新功能弄的焦头烂额 @xxx 的实现,在实现后写下些心得,供以后会跳入这坑的同志们参考。

首先,当让是考虑使用范围,由于项目仅仅需要考虑在 WEBKIT 环境下使用,所以可以不用考虑 IE 这也使得代码少了很多的 if(){}else{} 判断。在Mozilla 开发者网络上发现 selectionrange 这两个关于选区对象和光标对象,结合 Caret(一个用于判断当前光标位置的JS插件)后,一个大致的雏形就浮现出来。

大概就长这样:

image

先整理思路,捋一捋实现步骤。

大致思路如下:

  1. 键入 @ 后将选择框显示出来
  2. 将焦点定位在弹出框中的搜索框中
  3. 点击选择框中的选项时,返回输入框
  4. 输入框中显示 @xxx
  5. 将光标定位在 @xxx 之后
  6. 删除 @xxx 时需要整个 @xxx 一起删除

由于项目使用了 angular 来构建,所以给的 demo 也是用 angular 来搭建的,但是不论用什么框架,想法有了,那么一切就好办了。

selectionrange 对象的具体使用请参考 MDN 上的相关文章:

  1. selection
  2. range
  3. DEMO页

主要涉及的几个方法:

  1. getSelection(window.getSelectio):获取光标所在的区域(一个div或是一个textarea);
  2. selection.getRangeAt:获取光标所在区域中光标选区的信息;
  3. range.setStart:设置光标选区的起始位置;
  4. range.setEnd:设置光标选区的结束位置;
  5. range.deleteContents:将光标选区选中的内容删除;
  6. range.insertNode:在光标选区中添加内容;
  7. selection.extend:将选区的焦点移动到一个特定的位置;
  8. 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'
        };
        // 让选人框中的搜索框获取焦点
        $('#searchPersonInput')[0].focus();
    }
}

实现起来挺简单,代码也不复杂,利用 caret 插件获取到光标位置,将选人框在 @ 符号的下方显示出来,并同时实现了步骤中的第二步:将焦点放在搜索框中。

选人实现

主要涉及步骤为:3、4、5
当鼠标点击备选项时需要按顺序进行 3、4、5 步骤,所以需将 3、4、53 个步骤放在一起。
相关代码如下:

$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 = '&nbsp;';

    // 将生成内容打包放在 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();
    }
}

这里估计挺多人会有疑问,为啥要在生成的标签后面加一个空格,而且这个空格要通过 &nbsp; 这样的方式实现。

首先,先解释第一个问题:为啥要在标签后加一个空格?

如果不加空格的话,之后在输入文字会添加在我们生成的标签中,也就是说如果不加空格来隔断我们生成的标签,我们在文本框里所做的操作就是在我们生成的标签中进行。而加了个空格就为了避免该问题的发生,使得文本编辑在正确的编辑框中进行。

第二个问题:为啥不能直接加空格 ' ' ,而是通过 &nbsp; ,不得不说这是个过个悲伤的事实,还是碰到了兼容性的问题,在 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 环境确实是可以输入空格的,一看原来是 &nbsp;&nbsp; 不能通过 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);

    }
};

代码的逻辑都写在注释里了,这里就不多说了。
这样就完成 @ 这一功能了。