web 前端 @ 功能 JS 實現分析及其原理

最近爲實現一個新功能弄的焦頭爛額 @xxx 的實現,在實現後寫下些心得,供之後會跳入這坑的同志們參考。html

首先,當讓是考慮使用範圍,因爲項目僅僅須要考慮在 WEBKIT 環境下使用,因此能夠不用考慮 IE 這也使得代碼少了不少的 if(){}else{} 判斷。在Mozilla 開發者網絡上發現 selectionrange 這兩個關於選區對象和光標對象,結合 Caret(一個用於判斷當前光標位置的JS插件)後,一個大體的雛形就浮現出來。node

大概就長這樣:git

image

先整理思路,捋一捋實現步驟。github

大體思路以下:web

  1. 鍵入 @ 後將選擇框顯示出來
  2. 將焦點定位在彈出框中的搜索框中
  3. 點擊選擇框中的選項時,返回輸入框
  4. 輸入框中顯示 @xxx
  5. 將光標定位在 @xxx 以後
  6. 刪除 @xxx 時須要整個 @xxx 一塊兒刪除

因爲項目使用了 angular 來構建,因此給的 demo 也是用 angular 來搭建的,可是不論用什麼框架,想法有了,那麼一切就好辦了。chrome

selectionrange 對象的具體使用請參考 MDN 上的相關文章:網絡

  1. selection
  2. range
  3. DEMO頁

主要涉及的幾個方法:app

  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 中的內容來過濾選人。

實現 @ 選擇

相關代碼以下:spa

$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 插件獲取到光標位置,將選人框在 @ 符號的下方顯示出來,並同時實現了步驟中的第二步:將焦點放在搜索框中。

選人實現

主要涉及步驟爲:三、四、5

當鼠標點擊備選項時須要按順序進行 三、四、5 步驟,因此需將 三、四、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'
        };
        $timeout(function(){
            $('#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'
        };
        $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);

    }
};

代碼的邏輯都寫在註釋裏了,這裏就很少說了。

這樣就完成 @ 這一功能了。

相關文章
相關標籤/搜索