1. 程式人生 > >天坑之路:用js給選中文字新增樣式

天坑之路:用js給選中文字新增樣式

前言

本例基於react,但是實際上就是用原生js做的。相容性做到了IE9,但是按照這個思路做是可以做到IE8甚至更低的。

需求與最初的思路

當我拿到這個需求的時候以為很簡單,就是可以給頁面上的文章做記號,比如添加個下劃線,或者背景塗色做成熒光筆的樣子。

因為只需要相容IE9,所以window.getSelection是支援的。(IE8及以下有其它的獲取選中的方法)

那麼思路就是選中文字,點選新增下劃線後,通過 window.getSelection.getRangeAt(0) 拿到選中的文字物件,獲取到文字後,通過文字物件的 surroundContents 方法來將文字替換為帶有class的元素。

初步的實現

思路很簡單,程式碼同樣也很簡單。

CSS程式碼:

.custom-underline{
  border-bottom: 1px solid #f00;
  font-style: normal;
}

.nite-writer-pen{
  background-color: lightgreen;
  border-radius: 5px;
  box-shadow: 0 0 10px lightgreen;
  font-style: normal;
}

JS程式碼:

/**
  * 用元素替換被選中的文字
  */
var replaceSelectedStrByEle = function(className){
  var selecter = window.getSelection();
  var selectStr = selecter.toString();
  if (selectStr.trim != "") {
    var rang = selecter.getRangeAt(0);
    var ele = document.createElement("i");
    ele.className = className;
    ele.textContent = selectStr
    rang.surroundContents(ele);
  }
}

replaceSelectedStrByEle('nite-writer-pen');

天坑出現

上面的思路實在是過於簡單,如果是一個很簡單的元素,那麼這種做法是沒有問題的。

但是我們的文章的html結構一般都沒有這麼簡單,比如對於以下情況:

<p>
  <p>道可道,非常道。</p>
  <p>名可名,非常名</p>
</p>

如果在頁面上我選中的操作如下:

那麼上面的程式碼實現就會出現BUG,對於這種跨元素選中的情況,想當然的用元素去替換文字是沒用的。

如果你想得更多,比如跨多個元素選中,以及選中元素為更為複雜的html結構,你就會發現這是一個多大的坑。

html結構有多複雜,這個坑就有多深。

思路的僵局與寫輪眼

其實天坑也不是完全沒有路走,在跨多個元素選中的過程中,我想給選中的內容加樣式,那麼就需要獲取到所有選中的文字節點,並且批量替換成元素。

但是 window.getSelection.getRangeAt(0) 獲取到的range物件只能獲取到最開始選中的節點和最後選中的節點的。

那麼接下來通過選中的最開始的節點和最後的節點獲取到所有的文字節點。

思路就是這麼個思路,但是實現起來是很複雜的。

面臨深坑,肯定不可能硬剛。

畢竟我已經不是當年頭鐵的愣頭青了,做專案是有時間和精力成本的,如果要填掉這個坑,那麼加班是不可避免的,最重要的是在有限時間內填的這個坑可能還有各種BUG和相容性問題。

對待這種天坑,一般就給需求來個做不了三連了。

但是這把我想贏,畢竟這個東西看起來確實簡單。

在外人的眼裡,如此之簡單,分分鐘搞定的事情。這都做不了,我還怎麼在前端的圈子裡繼續划水?

所以我要動用程式設計師的入門技——寫輪眼。

然而百度、谷歌無效,根本沒有這個解決方案,有的全是些我最初的簡單實現。

但是回憶我們以前見到的各種網頁應用與場景,很容易就能想到上面的這種操作我們是見過的。

那就是從遠古IE時代就已經出現的各種富文字編輯器元件。

目標確認,百度的ueditor,這波我要贏。

分析ueditor與複製

上github兩三下拿到ueditor原始碼,開始讀原始碼分析程式碼。

中間過程不再多說,精簡程式碼,除去一些不需要的程式碼和相容性處理後,拿到了五個檔案:

  • browser.js (瀏覽器版本判斷,用於做相容性處理)
  • domUtils.js (dom操作)
  • dtd.js (節點的型別與元素判斷)
  • Range.js (封裝的選中範圍物件)
  • utils.js (工具類)

即使精簡後,程式碼也不少,大概兩三千行。不過其中還有很多註釋,壓縮後體積並不大。

由於程式碼比較多,這裡就不全部展示了,文章最後會給出github的地址。

這裡只給出最後的使用程式碼:

/**
 * 新增下劃線
 */
addUnderline = () => {
  this.replaceSelectedStrByEle(styles['custom-underline'])
}

/**
 * 啟用熒光筆
 */
enableNiteWriterPen = () => {
  this.replaceSelectedStrByEle(styles['nite-writer-pen'])
}

/**
 * 用元素替換被選中的文字
 */
replaceSelectedStrByEle = (className) => {
  var getRange = () => {
    var me = window;
    var range = new Range(me.document);

    var sel = window.getSelection();
    if (sel && sel.rangeCount) {
      var firstRange = sel.getRangeAt(0);
      var lastRange = sel.getRangeAt(sel.rangeCount - 1);
      range.setStart(firstRange.startContainer, firstRange.startOffset)
        .setEnd(lastRange.endContainer, lastRange.endOffset);
    }
    return range
  }
  var range = getRange();
  range.applyInlineStyle('i', {
    class: className
  });
  range.select();
}

使用起來還是比較簡單的。

對i元素的處理做的一些修改

如果我們選中的是已經被包裹在i元素中的一段文字,那麼呼叫後會發現並沒有加上class屬性。

這是因為富文字編輯器和我們的需求不一樣,通過操作想把選中文字變為i元素,而文字外面本來就是i元素了,自然不會進行剩下的操作。

在填充元素的最後會有一個mergeToParent的操作,他會在填充元素的標籤和其父級元素的標籤一樣後將元素替換為文字。

if (parent.tagName == node.tagName || parent.tagName == "A") {
  //...
}

那麼這裡我們要修改原始碼加上一個判斷

if ((parent.tagName == node.tagName && parent.className == node.className) || parent.tagName == "A") {
  //...
}

至於其它的邏輯保持不變即可。

為支援回退操作做的一些修改

這裡getRange拿到的物件range在選中內容並替換樣式後依然可以使用。

可以呼叫

range.removeInlineStyle('i')

移除之前新增的樣式。

也就是說這裡如果使用一個命令模式之類的,是可以實現回退操作的。

不過這裡還是有一個坑,就是removeInlineStyle會移除掉選中內容中所有的i元素,於是我修改了

Range.js中removeInlineStyle這個方法,多加了一個className引數,每次去掉i元素時都會判斷是否引數等於className。

然後我們呼叫時就是

range.removeInlineStyle('i',styles['nite-writer-pen'])

總結

作為一個暗藏天坑的小需求,搞定之後其實還挺有成就感的。

粗略閱讀了原始碼後才發現如果自己做會有多坑,基本上沒個三五天下不來,並且在多掉了N根頭髮後仍然會發現到處都是考慮不周和各種BUG。

那麼最後貼上程式碼的github地址:github地址

如文中有謬誤,或者您有更有趣的玩法,還望不吝賜教。