線上文字編輯器實現原理
from: https://io-meter.com/2014/09/01/contenteditable-and-selection/
最近研究了一下在瀏覽器中實現的 WYSIWYG 文字編輯器的原理, 在瞭解基本原理並瀏覽了 zenpen 這個相對簡單的線上編輯器的原始碼後, 在這方面有種豁然開朗的感覺。
說來讓人驚訝,最初在瀏覽器中使之變為可能的瀏覽器是 IE5。在那個時代, IE 的確也算是非常先進的瀏覽器了,現在廣為使用的 AJAX 技術,不也是 IE5 最早提供的麼? 不過這裡就不再討論當初 IE 那套陳舊的 API 了,而主要來討論 HTML5 之後被各個瀏覽器廣泛支援的一些技術方法。
進行 WYSIWYG 的文字編輯,需要的幾個基礎是
- 使得某一部分 DOM 可以被編輯
- 可以獲取和操作使用者選中的區域
- 可以在編輯的同時對所編輯的部分 DOM 進行修改,實現如新增樣式等功能
這些功能都非常方便實現。
使 DOM 可編輯
使一部分 HTML 的 DOM 作為容器進入編輯狀態,只需要為這個 DOM 新增一個 contentEditable
屬性。 一般來講,是使用div
或者article
元素作為這樣的容器。
被新增 contentEditable
屬性的容器元素的子元素都就可以由使用者修改了, 如果想在這個容器下面巢狀一個不可修改的子元素,需要顯式地在這個子元素中新增 contentEditable='false'
這樣的宣告。
獲取和操作使用者選擇
操作和獲取使用者選擇是一個非常有用的功能,它不但可以用來實現這裡提出的編輯器的功能, 還可以用來實現在游標位置顯示提示選單等多種功能,在後面對所編輯部分進行樣式修改的時候也常用到。
想要獲取一個 Selection 物件非常簡單:
var selection = window.getSelection();
Selection 物件有anchorNode
和focusNode
兩個屬性,可以用來獲得選中部分的開始和結束元素,
不過實用不多(一般實用 Range 代替)。此外還有一個isCollapsed
屬性值得注意,當其為true
時,代表選擇區域開始和結束在相同的點,也就是沒有選中內容時游標閃爍的模式。
selection 物件還有諸多方法可以用來修改選擇範圍,主要就是對 Range 物件的編輯。
首先可以通過
var range = selection.getRangeAt(0);
來獲取被選中的第一個區塊,以此類推還可以獲得第二個、第三個。簡單起見我們只討論選中一個區塊的情況。 獲得了 Range 物件,我們就可以方便地進行獲得選中區域內容了。 Range 物件有一對屬性分別用來獲得選擇區域的開始和結束點。
range.startContainer // 開始點所在的容器(元素)
range.endOffset // 開始點在其容器內的偏移
range.endContainer // 結束點所在的容器(元素)
range.endOffset // 結束點在其容器內的偏移
除了這兩對屬性,還有一個非常有用的屬性,那就是
range.commonAncestorContainer // 選擇範圍的共同父元素
上面這個屬性常用於檢測選擇範圍的型別/樣式,比如檢測到選中範圍的公共父元素是一個 h1
元素, 那麼可以在工具欄中將代表 h1
元素的按鈕設為啟用狀態。
需要注意的是,返回的 Range 物件是一類可變物件。簡單來說,如果使用者的選區改變了, 那麼 Range 物件的內容也會改變。因此要記錄某一時刻的 Range ,就要記錄上面提到了的兩對屬性。 此外,Range 物件的屬性都是隻讀的,需要通過對應的函式方法來修改。
Range 還定義了各種獲取內容和修改內容的函式,詳細引數和方法可以參見文件, 這裡對幾個常見的 Use Case 說明一下。
獲得選取的座標範圍
我們可以獲得游標在網頁上的精確位置,對於選區還能得到其矩形邊框的幾何表示, 這為我們顯示提示選單提供了方便。獲得游標的位置或者選區的矩形邊框可以使用
var rect = range.getBoundingClientRect();
rect
物件包括的屬性包括矩形的top
、left
、right
、bottom
座標。
如果選取是collapsed
的話,這四個屬性就可以用來計算游標的位置了。
儲存選區並恢復
如果對選擇區域內的元素進行了修改,比如新增新元素、改變元素型別等等, 那麼原來的選區會失效。因此一個比較有用的技巧就是在修改元素之前,先儲存選區 Range, 待修改完成後再恢復。
一個完整的例子如下:
var selection = window.getSelection();var range = selection.getRangeAt(0);// 儲存所有 Range 的屬性var startContainer = range.startContainer;var startOffset = range.startOffset;var endContainer = range.endContainer;var endOffset = range.endOffset;// 進行元素修改操作// ......// 構造新的 Rangevar newRange = document.createRange();// 注意,此處必須建立一個新的選區,在原來的 range 上修改無效
newRange.setStart(startContainer, startOffset);
newRange.setEnd(endContainer, endOffset);// 恢復選區
selection.removeAllRanges();
selection.addRange(newRange);
需要注意的是,有些操作可能會自動修改選區,那麼使用上面方法就不能達到恢復選區的目的了。 一個常用的技巧是為恢復選區新增一個延遲,也就是在上面將addRange
呼叫放入setTimeout
當中。
setTimeout(function(){
selection.addRange(newRange);},50);
編輯 DOM,修改樣式
文字編輯器的一個很重要的功能就是修改內容的樣式,比如將文字加粗、傾斜、加下劃線等。 還包括將段落修改為標題、塊引用等。一個比較直觀的方法是按照上述介紹的儲存和恢復選區的方法, 按照需求修改元素新增樣式即可。但是這種方法其實細想起來比較複雜,尤其是段落中, 存在混雜多種樣式,以至於存在樣式可以巢狀的情況(比如一段即是加粗又是傾斜的文字), 維護節點關係和清理空白節點會很繁瑣。
好在瀏覽器為我們提供了一個方便的介面來實現這樣的功能。那就是document.execCommand
, 這個介面將各種操作抽象成命令的形式。下面展示了實現一些基本功能的方法。
document.execCommand('bold');// 加粗所選
document.execCommand('italic');// 傾斜所選
document.execCommand('underline');// 下劃線所選
document.execCommand('createLink',true,'http://io-meter.com');// 為所選文字加連結
document.execCommand('unlink');// 取消連結
document.execCommand('formatBlock',true,'h1');// 將游標所在段落修改為一級標題
document.execCommand('formatBlock',true,'p');// 將游標所在塊修改為段落
除此之外,瀏覽器還提供了一些編輯命令,如copy
、cut
和paste
等。
完整的命令列表可以參見這個文件
需要注意的是,各種瀏覽器對這些命令的支援也有些不同,因此需要格外注意。 瞭解了這些命令,就具備實現編輯器中修改樣式等功能的基本知識了。
偵聽修改
在此還需要說一點題外話,在以往要偵聽使用者對文字的修改,一般是繫結keydown
事件, 此外考慮到使用者還可能選取並拖拽來改變內容,可能還要新增mouseup
事件,
這種方法是低效且繁瑣的,還對元素樣式的修改無能為力。
總結
OK,瞭解了這些知識,實現一個簡單的 Web 文字編輯器是不是也顯得不那麼難了呢? 雖然在這些 API 上不同瀏覽器還有些差異,但是已經被廣泛應用在實現線上文字編輯功能了。
其實如果只專注於現代瀏覽器,那麼實現如同 Medium 那樣漂亮又實用的編輯和寫作工具也不是非常困難的事!