天坑之路:用js給選中文字添加樣式

前言

本例基於react,可是實際上就是用原生js作的。兼容性作到了IE9,可是按照這個思路作是能夠作到IE8甚至更低的。html

需求與最初的思路

當我拿到這個需求的時候覺得很簡單,就是能夠給頁面上的文章作記號,好比添加個下劃線,或者背景塗色作成熒光筆的樣子。前端

由於只須要兼容IE9,因此window.getSelection是支持的。(IE8及如下有其它的獲取選中的方法)node

那麼思路就是選中文本,點擊添加下劃線後,經過 window.getSelection.getRangeAt(0) 拿到選中的文本對象,獲取到文本後,經過文本對象的 surroundContents 方法來將文本替換爲帶有class的元素。react

初步的實現

思路很簡單,代碼一樣也很簡單。git

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代碼:github

/**
  * 用元素替換被選中的文本
  */
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結構通常都沒有這麼簡單,好比對於如下狀況:app

<p>
  <p>道可道,很是道。</p>
  <p>名可名,很是名</p>
</p>

若是在頁面上我選中的操做以下:dom

那麼上面的代碼實現就會出現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地址

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

相關文章
相關標籤/搜索