前言
最近嘗試去寫一個富文字編輯器,覺得應該也不難,但沒想到還是花了不少時間去寫前期的主要邏輯,其間太多的邊角邏輯是沒有考慮到的。原因是前期走了 很多彎路,單純的一點一點的去實現功能,有分支功能出現就一點一點的修補,到最後發現程式碼量很多,邏輯很複雜。最後痛下決心,靜下心來分析了一下,思考用 理論邏輯去鋪墊根基,才算是構建了一個還算滿意的基礎邏輯。真心覺得,理論才是一切事情的開始點,縝密的理論邏輯才能建造基乎無bug的程式碼。
前面的都是些廢話,由於該文字編器實現了execCommaond的部分方法,下面的內容相對比較複雜,對實現不感興趣的可以忽略,如果是妹子可以直接聯絡筆者交流交流
功能
富文字編輯器實現如下的功能
- 實現fake原生execCommand的能力
- 修改style的能力
其實很多能力,原生的execCommand已經幫我們做好了,但我們的編輯器要有與execCommand相同的能力,以備原生無法實現的時候,我們的編輯器還是可以實現。
API能力
- 標籤插入與刪除
- style修改
便籤的修改能力
比如一段程式碼
1 |
<div>abcd</div> |
如果abc被選中之後,執行execCommand之後的程式碼是在abc外包裹一個strong標籤
變成
1 |
<div><strong>abc</strong>d</div> |
如果b再次選中執行execCommand(‘bold’)命令後,會變成
1 |
<div><strong>a</strong>b<strong>c</strong></div> |
可以看到原來的strong被分離了,變成單獨的兩個,還有一些更復雜的情況,如下
1 |
<div><strong>ab<u><strong>cd<u>b<span><strong>pp</strong></span></u><strong></u></strong></div> |
像這個串,如果沒有良好的理論基礎與抽象建模,靠手動的程式碼去處理基乎是不可能的。
第一步
串的正則化
如上那個複雜的串,其樹結構如下
最上面的是根結點,葉子結點都是文字節點(textNode)
我們可以看到規律
葉子結點向上回溯的過程中,通過的結點會給我們賦予不同的功能,但我們要操作的節點,如果像strong、underline一定程度是與樣式有關的,我們把它們稱為樣式結點
如果我們直接刪掉葉子結點最近的祖先中的樣式結點,很有可能會影響到其他的葉子結點,這時候我們就要把樣式結點的影響最小化,就要先進行正則處理
正則化的過程就是要把樣式結點轉化到葉子結點的上面,每個樣式結點只控制一個我們想要直接操作的葉子結點
如下的轉化過程
正則化的過程的同時,我們還要修剪一些無用的結點,比如空的span結點,比如空的textNode,轉化完了之後的樹,我們處理起來變得很簡單
正則化的實現也很簡單,從ROOT結點進行中序遍歷的演算法,即先訪問根結點,再從左向右依次訪問子結點,然後一直到葉子結點,找出葉子結點的樣式結點次續,並刪除經過的樣式結點,然後把它們插入到每個葉子結點的上面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
var scan = function(node, inhrintStyles){ // 已經是葉子節點了 if(node.nodeType === node.TEXT_NODE){ if(inhrintStyles.length){ leafNodes.push({ inhrintStyles: inhrintStyles.concat([]), node: node }); } // 非葉子節點 }else{ // 如果是style節點 // 標記這是要刪除的style節點, if(styleTagNames.indexOf(node.tagName.toLowerCase()) > -1){ // inhrintStyles存在此style 不重複增加 var exists = 0; for(var i = 0; i < inhrintStyles.length; i ++){ if(inhrintStyles[i].tagName === node.tagName){ exists = 1; break; } } if(! exists){ inhrintStyles = inhrintStyles.concat(node); } styleNodes.push(node); } for(var i = 0; i < node.childNodes.length; i ++){ scan(node.childNodes[i], inhrintStyles); } } }; scan(tree, []); |
如下程式碼實現刪除樣式結點的過程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 刪除樣式節點 for(var i = 0; i < styleNodes.length; i ++){ var styleNode = styleNodes[i]; var childNodes = styleNode.childNodes; var fragment = document.createDocumentFragment(); var child; while(child = childNodes[0]){ fragment.appendChild(child); } styleNode.parentNode.replaceChild(fragment, styleNode); } |
經過這樣處理後的樹,沒有了任何樣式結點,這也是去除樣式的一個方法
下面的程式碼展示將樣式結點最小化到textNode之上的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// 對葉子結點進行樣式插入 for(var i = 0; i < leafNodes.length; i ++){ var leafNode = leafNodes[i]; if(leafNode.node.nodeValue === ""){ leafNode.node.parentNode.removeChild(leafNode.node); continue; } var node; var styleNode; var frag = document.createDocumentFragment(); var currParent = frag; while(styleNode = leafNode.inhrintStyles.shift()){ // 去除無用atribute屬性 if(! donotTrimSpan && styleAttrTagNames.indexOf(styleNode.tagName.toLowerCase()) > - 1 && ! styleNode.attributes.length){ }else{ el = styleNode.cloneNode(); currParent.appendChild(el); currParent = el; } } var leafNodeParent = leafNode.node.parentNode; var tempNode = document.createElement("span"); leafNodeParent.replaceChild(tempNode, leafNode.node); currParent.appendChild(leafNode.node); leafNodeParent.replaceChild(frag, tempNode); } |
經過了這一輪的正則化處理之後, 我們主要做了以下三件事情
- 最小化樣式結點到文字結點上
- 去除無用的結點
- 去除重複的結點
其實這一輪正則化之後理應要再進行更多的優化,比如
- 合併結點
正則化完成之後使我們修減樣式結點變得容易很多了,為了實現execCommand的功能,我們要實現樹的如下兩個方法
葉子結點某個樣式結點的刪除
葉子結點某個樣式結點的增加
增加的實現
先向上檢查是否有該樣式結點存在,如果有了就不進行新增了,如果沒有,要進行新增
新增的實現了很簡單
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
setNodesUnderLabel: function(selectedNodes, labelName){ selectedNodes.map(function(item){ var p = item.parentNode; var hasLabel = 0; do{ if(p.childNodes.length > 1){ break; } if(p.tagName.toLowerCase() === labelName){ hasLabel = 1; break; } }while(p = p.parentNode); if(hasLabel){ }else{ var label = document.createElement(labelName); item.parentNode.replaceChild(label, item); label.appendChild(item); } }); }, |
刪除的實現也是如此,先向上檢查是否有label存在,有的話才刪除
樹的抽象功能已經建成,但後面提取最選中的葉子結點也並非一件容易的事情,主要是瀏覽器提供的API坑點太多,太不好用
Selection & Range物件
使用這兩個父子物件,能幫我們獲取被選中的區域
Selection
就是藍色被選中的可視區域
Range
就是邏輯裡面的一塊區域,與視覺化無關
獲取Range
從一個藍色選中的區域中獲取到Range物件,用如下的方法
1 2 3 4 5 |
var selection = window.getSelection(); var range; if(selection.rangeCount){ range = selection.getRangeAt(0); } |
拿到Range,我們還要分析被選中的葉子節點,這裡有很多坑點
首先Range物件,我們常用的五個屬性
startContainer 開始選中的元素
startOffset 開始選中的偏移
endContainer
endOffset
commonAncestorContainer 共同的最近的祖先結點
startContainer有兩種情況,一種是元素,但這時被選中的是其子結點, 這時候startOffset代表的是被選中的子結點的index,子結點可能是葉子結點,也可能是element結點,另外一種可能是葉子結點 (textNode),startOffset代表的是text的偏移位置,比較坑的是,有可能偏移位置是不存在的str,什麼意思呢,就是比如
startContainer對應葉子結點abcd
startOffset如果是1,那就被選中的開始就是從第1個字元開始(下標從0算)即bcd
但startOffset還可能是4,這時就坑了,開始什麼都沒有
同樣endContainer也是如此
我們的任務是要找出被選中的葉子結點,然後進行增刪樣式處理,當前,前提是保證已經正則化過了
找出被選中的葉子結點並非一件容易的事情
文章比較長,請待後續更新