利用 javascript 實現富文字編輯器
近期專案中需要開發一個相容PC和移動端的富文字編輯器,其中包含了一些特殊的定製功能。考察了下現有的js富文字編輯器,桌面端的很多,移動端的幾乎沒有。桌面端以UEditor為代表。但是我們並不打算考慮相容性,所以沒有必要採用UEditor這麼重的外掛。為此決定自研一個富文字編輯器。本文,主要介紹如何實現富文字編輯器,和解決一些不同瀏覽器和裝置之間的bug。
準備階段
在現代瀏覽器中已經為我們準備好了許多API來讓 html 支援富文字編輯功能,我們沒有必要自己完成全部內容。
contenteditable=”true”
首先我們需要讓一個 div 成為可編輯狀態,加入 contenteditable="true" 屬性即可。
<div contenteditable="true" id="rich-editor"></div>
在這樣的 <div> 中插入任何節點都將預設是可編輯狀態的。如果想插入不可編輯的節點,我們就需要指定插入節點的屬性為 contenteditable="false" 。
游標操作
作為富文字編輯器,開發者需要有能力控制游標的各種狀態資訊,位置資訊等。瀏覽器提供了 selection 物件和 range 物件來操作游標。
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><br></p> 元素( <br> 標籤用來佔位,有內容輸入後會自動刪除)。這樣以後每次回車產生的新內容都會被 <p> 元素包裹起來(在可編輯狀態下,回車換行產生的新結構會預設拷貝之前的內容,包裹節點,類名等各種內容)。
我們還需要監聽 keyUp 事件下 event.keyCode === 8 刪除鍵。當編輯器中內容全被清空後(delete鍵也會把 <p> 標籤刪除),要重新加入 <p><br></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); 會插入一個 <hr> 標籤。然而產生的效果卻是這樣的:
游標和 <hr> 的效果一致了。為此要判斷當前游標是否在 <li> 裡面,如果是則在 <hr> 後面追加一個空的文字節點 #text 不是的話追加 <p><br></p> 。然後將游標定位在裡面,可用如下方式查詢。
/**
* 查詢父元素
* @param root
* @param 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="$">$</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;
}
最後說一句
該專案目前提測中,所以呢,一但發現有意思的坑,我會及時補充的。