利用 javascript 實現富文本編輯器

利用 javascript 實現富文本編輯器javascript

閱讀 994
收藏 148
2017-11-03
原文連接:eux.baidu.com
利用 javascript 實現富文本編輯器
by.田 光宇 28 小時前
近期項目中須要開發一個兼容PC和移動端的富文本編輯器,其中包含了一些特殊的定製功能。考察了下現有的js富文本編輯器,桌面端的不少,移動端的幾乎沒有。桌面端以UEditor爲表明。可是咱們並不打算考慮兼容性,因此沒有必要採用UEditor這麼重的插件。爲此決定自研一個富文本編輯器。本文,主要介紹如何實現富文本編輯器,和解決一些不一樣瀏覽器和設備之間的bug。html

準備階段
在現代瀏覽器中已經爲咱們準備好了許多API來讓 html 支持富文本編輯功能,咱們沒有必要本身完成所有內容。前端

contenteditable=」true」java

首先咱們須要讓一個 div 成爲可編輯狀態,加入contenteditable="true" 屬性便可。node

<div contenteditable="true" id="rich-editor"></div>
在這樣的 <div> 中插入任何節點都將默認是可編輯狀態的。若是想插入不可編輯的節點,咱們就須要指定插入節點的屬性爲 contenteditable="false"。ios

光標操做web

做爲富文本編輯器,開發者須要有能力控制光標的各類狀態信息,位置信息等。瀏覽器提供了 selection 對象和 range 對象來操做光標。chrome

selection 對象數組

Selection對象表示用戶選擇的文本範圍或插入符號的當前位置。它表明頁面中的文本選區,可能橫跨多個元素。文本選區由用戶拖拽鼠標通過文字而產生。
得到一個 selection 對象瀏覽器

let selection = window.getSelection();
一般狀況下咱們不會直接操做 selection 對象,而是須要操做用 seleciton 對象所對應的用戶選擇的 ranges (區域),俗稱」拖藍「。獲取方式以下:

let range = selection.getRangeAt(0);
因爲瀏覽器當前可能存在多個文本選取,因此 getRangeAt 函數接受一個索引值。在富文本編輯其中,咱們不考慮多選取的可能性。

selection 對象還有兩個重要的方法, addRange 和 removeAllRanges。分別用於向當前選取添加一個 range 對象和 刪除全部 range 對象。以後你會看到他們的用途。

range 對象

經過 selection 對象得到的 range 對象纔是咱們操做光標的重點。Range表示包含節點和部分文本節點的文檔片斷。初見 range 對象你有可能會感到陌生又熟悉,在哪兒看見過呢?做爲一個前端工程師,想必你必定拜讀過《javascript 高級程序設計第三版》 這本書。在第12.4節,做者爲咱們介紹了 DOM2 級提供的 range 接口,用來更好的控制頁面。反正我當時看的一臉????這個有啥用,也沒有這種需求啊。這裏咱們就大量的用到這個對象。對於下面節點:

<div contenteditable="true" id="rich-editor">

<p>百度EUX團隊</p>

</div>
光標位置如圖所示:

打印出此時的 range 對象:

其中屬性含義以下:

  • startContainer: range 範圍的起始節點。
  • endContainer: range 範圍的結束節點
  • startOffset: range 起點位置的偏移量。
  • endOffset: range 終點位置的偏移量。
  • commonAncestorContainer: 返回包含 startContainer 和 endContainer 的最深的節點。
  • collapsed: 返回一個用於判斷 Range 起始位置和終止位置是否相同的布爾值。

這裏咱們的 startContainer , endContainer, commonAncestorContainer都爲 #text 文本節點 ‘百度EUX團隊’。由於光標在‘度‘字後面,因此startOffset 和 endOffset 均爲 2。且沒有產生拖藍,因此 collapsed 的值爲 true。咱們再看一個產生拖藍的例子:

光標位置如圖所示:

打印出此時的 range 對象:

因爲產生了拖藍 startContainer 和 endContainer 再也不一致,collapsed 的值變爲了 false。startOffset 和 endOffset 正好表明了拖藍的起終位置。更多的效果你們本身嘗試吧。

操做一個 range 節點,主要有以下方法:

setStart(): 設置 Range 的起點
setEnd(): 設置 Range 的終點
selectNode(): 設定一個包含節點和節點內容的 Range
collapse(): 向指定端點摺疊該 Range
insertNode(): 在 Range 的起點處插入節點。
cloneRange(): 返回擁有和原 Range 相同端點的克隆 Range 對象
富文本編輯裏面經常使用的就這麼多,還有不少方法就不列舉了。

修改光標位置

咱們能夠經過調用 setStart() 和 setEnd() 方法,來修改一個光標的位置或拖藍範圍。這兩個方法接受的參數爲各自的起終節點和偏移量。例如我想讓光標位置到」百度EUX團隊」最末尾,那麼能夠採用以下方法:

let range = window.getSelection().getRangeAt(0),

textEle = range.commonAncestorContainer;

range.setStart(range.startContainer, textEle.length);
range.setEnd(range.endContainer, textEle.length);
咱們加入一個定時器來查看效果:

然而這種方式有個侷限性,就是當光標所在的節點若是發生了變更。好比被替換或者加入新的節點了,那麼再用這種方式就不會有任何效果。爲此咱們有時候須要一種強制更改光標位置手段, 簡要代碼以下(實際中你有可能還須要考慮自閉和元素等內容):

function resetRange(startContainer, startOffset, endContainer, endOffset) {

let selection = window.getSelection();
    selection.removeAllRanges();
let range = document.createRange();
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
selection.addRange(range);

}
咱們經過從新創造一個 range 對象而且刪除原有的 ranges 來保證光標必定會變更到咱們想要的位置。

修改文本格式

實現富文本編輯器,咱們就要可以有修改文檔格式的能力,好比加粗,斜體,文本顏色,列表等內容。DOM 爲可編輯區提供了 document.execCommand 方法,該方法容許運行命令來操縱可編輯區域的內容。大多數命令影響文檔的選擇(粗體,斜體等),而其餘命令插入新元素(添加連接)或影響整行(縮進)。當使用 contentEditable時,調用 execCommand() 將影響當前活動的可編輯元素。語法以下:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
aCommandName: 一個 DOMString ,命令的名稱。可用命令列表請參閱 命令 。
aShowDefaultUI: 一個 Boolean, 是否展現用戶界面,通常爲 false。Mozilla 沒有實現。
aValueArgument: 一些命令(例如insertImage)須要額外的參數(insertImage須要提供插入image的url),默認爲null。
總之瀏覽器能把大部分咱們想到的富文本編輯器須要的功能都實現了,這裏我就不一一演示了。感興趣的同窗能夠查看 MDN – document.execCommand。

到這裏,我相信你已經能夠作出一個像模像樣的富文本編輯器了。想一想還挺激動的,可是呢,一切都沒有結束,瀏覽器又一次坑了咱們。

實戰開始,填坑的旅途
就在咱們都覺得開發如此簡單的時候,實際上手卻遇到了許多坑。

修正瀏覽器的默認效果

瀏覽器提供的富文本效果並不老是好用的,下面介紹幾個遇到的問題。

回車換行

當咱們在編輯其中輸入內容並回車換行繼續輸入後,可編輯框內容生成的節點和咱們預期是不符的。

能夠看到最早輸入的文字沒有被包裹起來,而換行產生的內容,包裹元素是 <div> 標籤。爲了可以讓文字被 <p> 元素包裹起來。
咱們要在初始化的時候,向<div>默認插入<p>
</p> 元素(
標籤用來佔位,有內容輸入後會自動刪除)。這樣之後每次回車產生的新內容都會被<p> 元素包裹起來(在可編輯狀態下,回車換行產生的新結構會默認拷貝以前的內容,包裹節點,類名等各類內容)。
咱們還須要監聽 keyUp 事件下 event.keyCode === 8 刪除鍵。當編輯器中內容全被清空後(delete鍵也會把<p>標籤刪除),要從新加入<p>
</p>標籤,並把光標定位在裏面。

插入 ul 和 ol 位置錯誤

當咱們調用 document.execCommand("insertUnorderedList", false, null) 來插入一個列表的時候,新的列表會被插入<p>標籤中。

爲此咱們須要每次調用該命令前作一次修正,參考代碼以下:

function adjustList() {

let lists = document.querySelectorAll("ol, ul");
 for (let i = 0; i < lists.length; i++) {
    let ele = lists[i]; // ol
    let parentNode = ele.parentNode;
    if (parentNode.tagName === 'P' && parentNode.lastChild === parentNode.firstChild) {
            parentNode.insertAdjacentElement('beforebegin', ele);
            parentNode.remove()
    }
}

}
這裏有個附帶的小問題,我試圖在 <li><p></p></li> 維護這樣的編輯器結構(默認是沒有<p>標籤的)。效果在 chrome 下運行很好。可是在 safari 中,回車永遠不會產生新的 <li> 標籤,這樣就是去了該有的列表效果。

插入分割線

調用 document.execCommand('insertHorizontalRule', false, null); 會插入一個


標籤。然而產生的效果倒是這樣的:

光標和


的效果一致了。爲此要判斷當前光標是否在 <li> 裏面,若是是則在
後面追加一個空的文本節點 #text 不是的話追加 <p>
</p>。而後將光標定位在裏面,可用以下方式查找。

/**

  • 查找父元素
  • @param {String} root
  • @param {String | Array} name

*/
function findParentByTagName(root, name) {

let parent = root;
if (typeof name === "string") {
    name = [name];
}
while (name.indexOf(parent.nodeName.toLowerCase()) === -1 && parent.nodeName !== "BODY" && parent.nodeName !== "HTML") {
    parent = parent.parentNode;
}
return parent.nodeName === "BODY" || parent.nodeName === "HTML" ? null : parent;

},
插入連接

調用 document.execCommand('createLink', false, url); 方法咱們能夠插入一個 url 連接,可是該方法不支持插入指定文字的連接。同時對已經有連接的位置能夠反覆插入新的連接。爲此咱們須要重寫此方法。

function insertLink(url, title) {

let selection = document.getSelection(),
    range = selection.getRangeAt(0);
if(range.collapsed) {
    let start = range.startContainer,
        parent = Util.findParentByTagName(start, 'a');
    if(parent) {
        parent.setAttribute('src', url);
    }else {
        this.insertHTML(`<a href="${url}">${title}</a>`);
    }
}else {
    document.execCommand('createLink', false, url);
}

}
設置 h1 ~ h6 標題

瀏覽器沒有現成的方法,但咱們能夠藉助 document.execCommand('formatBlock', false, tag), 來實現,代碼以下:

function setHeading(heading) {

let formatTag = heading,
    formatBlock = document.queryCommandValue("formatBlock");
if (formatBlock.length > 0 && formatBlock.toLowerCase() === formatTag) {
    document.execCommand('formatBlock', false, ``);
} else {
    document.execCommand('formatBlock', false, ``);
}

}
插入定製內容

當編輯器上傳或加載附件的時候,要插入可以展現附件的 <div> 節點卡片到編輯中。這裏咱們藉助 document.execCommand('insertHTML', false, html); 來插入內容。爲了防止div被編輯,要設置 contenteditable="false"哦。

處理 paste 粘貼

在富文本編輯器中,粘貼效果默認採用以下規則:

若是是帶有格式的文本,則保留格式(格式會被轉換成html標籤的形式)
粘貼圖文混排的內容,圖片能夠顯示,src 爲圖片真實地址。
經過複製圖片來進行粘貼的時候,不能粘入內容
粘貼其餘格式內容,不能粘入內容
爲了可以控制粘貼的內容,咱們監聽 paste 事件。該事件的 event 對象中會包含一個 clipboardData 剪切板對象。咱們能夠利用該對象的 getData 方法來得到帶有格式和不帶格式的內容,以下。

let plainText = event.clipboardData.getData('text/plain'); // 無格式文本
let plainHTML = event.clipboardData.getData('text/html'); // 有格式文本
以後調用 document.execCommand('insertText', false, plainText); 或 document.execCommand('insertHTML', false, plainHTML; 來重寫編輯上的paste效果。

然而對於規則 3 ,上述方案就沒法處理了。這裏咱們要引入 event.clipboardData.items 。這是一個數組包含了全部剪切板中的內容對象。好比你複製了一張圖片來粘貼,那麼 event.clipboardData.items 的長度就爲2:
items[0] 爲圖片的名稱,items[0].kind 爲 ‘string’, items[0].type 爲 ‘text/plain’ 或 ‘text/html’。獲取內容方式以下:

items[0].getAsString(str => {

// 處理 str 便可

})
items[1] 爲圖片的二進制數據,items[1].kind 爲’file’, items[1].type 爲圖片的格式。想要獲取裏面的內容,咱們就須要建立 FileReader 對象了。示例代碼以下:

let file = items[1].getAsFile();
// file.size 爲文件大小
let reader = new FileReader();
reader.onload = function() {

// reader.result 爲文件內容,就能夠作上傳操做了

}
if(/image/.test(item.type)) {

reader.readAsDataURL(file);   // 讀取爲 base64 格式

}
處理完圖片,那麼對於複製粘貼其餘格式內容會怎麼樣呢?在 mac 中,若是你複製一個磁盤文件,event.clipboardData.items 的長度爲 2。 items[0] 依然爲文件名,然而 items[1] 則爲圖片了,沒錯,是文件的縮略圖。

輸入法處理

當使用輸入發的時候,有時候會發生一些意想不到的事情。 好比百度輸入法能夠輸入一張本地圖片,爲此咱們須要監聽輸入法產生的內容作處理。這裏經過以下兩個事件處理:

compositionstart: 當瀏覽器有非直接的文字輸入時, compositionstart事件會以同步模式觸發
compositionend: 當瀏覽器是直接的文字輸入時, compositionend會以同步模式觸發
修復移動端的問題

在移動端,富文本編輯器的問題主要集中在光標和鍵盤上面。我這裏介紹幾個比較大的坑。

自動獲取焦點

若是想讓咱們的編輯器自動得到焦點,彈出軟鍵盤,能夠利用 focus() 方法。然而在 ios 下,死活沒有結果。這主要是由於 ios safari 中,爲了安全考慮不容許代碼得到焦點。只能經過用戶交互點擊才能夠。還好,這一限制能夠去除:

[self.appWebView setKeyboardDisplayRequiresUserAction:NO]
iOS 下回車換行,滾動條不會自動滾動

在 iOS 下,當咱們回車換行的時候,滾動條並不會隨着滾動下去。這樣光標就可能被鍵盤擋住,體驗很差。爲了解決這一問題,咱們就須要監聽 selectionchange 事件,觸發時,計算每次光標編輯器頂端距離,以後再調用 window.scroll() 便可解決。問題在於咱們要如何計算當前光標的位置,若是僅是計算光標所在父元素的位置頗有可能出現誤差(多行文本計算不許)。咱們能夠經過建立一個臨時 <span> 元素查到光標位置,計算<span>元素的位置便可。代碼以下:

function getCaretYPosition() {

let sel = window.getSelection(),
    range = sel.getRangeAt(0);
let span = document.createElement('span');
range.collapse(false);
range.insertNode(span);
var topPosition = span.offsetTop;
span.parentNode.removeChild(span);
return topPosition;

}
正當我開心的時候,安卓端反應,編輯器越編輯越卡。什麼鬼?我在 chrome 上線檢查了一下,發現 selectionchange 函數一直在運行,無論有沒有操做。
在逐一排查的時候發現了這麼一個事實。range.insertNode 函數一樣觸發 selectionchange 事件。這樣就造成了一個死循環。這個死循環在 safari 中就不會產生,只出如今 safari 中,爲此咱們就須要加上瀏覽器類型判斷了。

鍵盤彈起遮擋輸入部分

網上對於這個問題主要的方案就是,設置定時器。侷限與前端,確實只能這採用這樣笨笨的解決。最後咱們讓 iOS 同窗在鍵盤彈出的時候,將 webview 高度減去軟鍵盤高度就解決了。

CGFloat webviewY = 64.0 + self.noteSourceView.height;
self.appWebView.frame = CGRectMake(0, webviewY, BDScreenWidth, BDScreenHeight - webviewY - height);
插入圖片失敗

在移動端,經過調用 jsbridge 來喚起相冊選擇圖片。以後調用 insertImage 函數來向編輯器插入圖片。然而,插入圖片一直失敗。最後發現是由於早 safari 下,若是編輯器失去了焦點,那麼 selection 和 range 對象將銷燬。所以調用 insertImage 時,並不能得到光標所在位置,所以失敗。爲此須要增長,backupRange() 和 restoreRange() 函數。當頁面失去焦點的時候記錄 range 信息,插入圖片前恢復 range 信息。

backupRange() {

let selection = window.getSelection();
let range = selection.getRangeAt(0);
this.currentSelection = {
    "startContainer": range.startContainer,
    "startOffset": range.startOffset,
    "endContainer": range.endContainer,
    "endOffset": range.endOffset
}

}
restoreRange() {

if (this.currentSelection) {
    let selection = window.getSelection();
        selection.removeAllRanges();
    let range = document.createRange();
    range.setStart(this.currentSelection.startContainer, this.currentSelection.startOffset);
    range.setEnd(this.currentSelection.endContainer, this.currentSelection.endOffset);
    // 向選區中添加一個區域
    selection.addRange(range);
}

}
在 chrome 中,失去焦點並不會清除 seleciton 對象和 range 對象,這樣咱們輕輕鬆鬆一個 focus() 就搞定了。

重要問題就這麼多,限於篇幅限制其餘的問題省略了。整體來講,填坑花了開發的大部分時間。

其餘功能
基礎功能修修補補之後,實際項目中有可能遇到一些其餘的需求,好比當前光標所在文字內容狀態啊,圖片拖拽放大啊,待辦列表功能,附件卡片等功能啊,markdown切換等等。在瞭解了js 富文本的種種坑以後,range 對象的操做以後,相信這些問題你均可以輕鬆解決。這裏最後提幾個作擴展功能時候遇到的有去的問題。

回車換行帶格式

前面已經說過了,富文本編輯器的機制就是這樣,當你回車換行的時候新產生的內容和以前的格式如出一轍。若是咱們利用 .card 類來定義了一個卡片內容,那麼換行產生的新的段落都將含有 .card 類且結構也是直接 copy 過來的。咱們想要屏蔽這種機制,因而嘗試在 keydown 的階段作處理(若是在 keyup 階段處理用戶體驗很差)。然而,並無什麼用,由於用戶自定義的 keydown 事件要在 瀏覽器富文本的默認 keydown 事件以前觸發,這樣你就作不了任何處理。
爲此咱們爲這類特殊的個體都添加一個 property 屬性,添加在 property 上的內容是不會被copy下來的。這樣之後就能夠區分出來了,從而作對應的處理。

獲取當前光標所在處樣式

這裏主要是考慮 下劃線,刪除線之類的樣式,這些樣式都是用標籤類描述的,因此要遍歷標籤層級。直接上代碼:

function getCaretStyle() {

let selection = window.getSelection(),
    range = selection.getRangeAt(0);
    aimEle = range.commonAncestorContainer,
    tempEle = null;
let tags = ["U", "I", "B", "STRIKE"],
    result = [];
if(aimEle.nodeType === 3) {
    aimEle = aimEle.parentNode;
}
tempEle = aimEle;
while(block.indexOf(tempEle.nodeName.toLowerCase()) === -1) {
    if(tags.indexOf(tempEle.nodeName) !== -1) {
        result.push(tempEle.nodeName);
    }
    tempEle = tempEle.parentNode;
}
let viewStyle = {
    "italic": result.indexOf("I") !== -1 ? true : false,
    "underline": result.indexOf("U") !== -1 ? true : false,
    "bold": result.indexOf("B") !== -1 ? true : false,
    "strike": result.indexOf("STRIKE") !== -1 ? true : false
}
let styles = window.getComputedStyle(aimEle, null);
viewStyle.fontSize = styles["fontSize"],
viewStyle.color = styles["color"],
viewStyle.fontWeight = styles["fontWeight"],
viewStyle.fontStyle = styles["fontStyle"],
viewStyle.textDecoration = styles["textDecoration"];
viewStyle.isH1 = Util.findParentByTagName(aimEle, "h1") ? true : false;
viewStyle.isH2 = Util.findParentByTagName(aimEle, "h2") ? true : false;
viewStyle.isP = Util.findParentByTagName(aimEle, "p") ? true : false;
viewStyle.isUl = Util.findParentByTagName(aimEle, "ul") ? true : false;
viewStyle.isOl = Util.findParentByTagName(aimEle, "ol") ? true : false;
return viewStyle;

}

相關文章
相關標籤/搜索