富文字編輯器初探

考拉海購前端團隊發表於2018-01-26

長期以來,作為使用者我是富文字編輯器的使用者,作為前端開發,我也只是富文字外掛的使用者,對內部實現細節不甚瞭解,使用上也只停留在呼叫外掛提供的API,實現一些業務邏輯。最近的專案,需要開發一個簡易富文字編輯器,也算是讓我有機會對其一窺究竟。

可編輯富文字的方式

我們知道form表單中的input、textarea之類標籤是支援內容可編輯的,但並不支援富文字,如果在這些標籤裡貼上帶格式的內容,會被去格式,只保留文字內容。如果想設定可編輯富文字,有兩種方式:

  • 嵌入空頁面的iframe,並設定designMode屬性值為“on”,這樣整個文件就變得可以編輯。
<iframe
 name="richtext" src="blank.html"></iframe>

window.addEventListener("load"function (){
 frames("richtext").document.designMode = "on"
});
複製程式碼

需要在嵌入頁面載入之後,動態設定iframe文件的designMode屬性。

  • 使用contenteditable屬性

該屬性最早是由IE實現,且可以作用於頁面中的任何標籤,只需要在文件裡給標籤設定以上屬性即可,無需嵌入iframe、設定js屬性,所以這種方式也是目前富文字編輯器外掛中更多采用的方式;

 <div class="editbox" id="richtext" contenteditable>
    <p></p>
    <p contenteditable="false"></p>   
 </>
複製程式碼

這樣,此div元素中包含的內容就可以編輯了,當然也可以設定子元素(如第二個P元素)為不可編輯。通過js設定元素的該屬性,也可以改變編輯模式:

var elm = document.getElementById('richtext');
elm.contentEditable = 'true';

複製程式碼

contenteditable屬性有三個可能的值:'true'表示開啟編輯模式,'false'表示關閉,'inherit'表示從父元素繼承此屬性值。contenteditable屬性相容性較好,在主流瀏覽器包括IE以及目前大部分的移動端瀏覽器上,都得到支援。

操作富文字

image

常見的富文字編輯器外掛,如wangEditor、百度的UEditor,都有各種豐富的選單區域來設定編輯內容及格式,如常規的設定標題、文字加粗、超連結等,更勝者插入圖片、視訊及自定義的內容結構等,而實現這些功能的API就是document.execCommand(),這個方法是與富文字編輯器進行互動的主要方式。

語法

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
複製程式碼
  • 返回值:布林型,false表示操作不支援或未被啟用
  • aCommandName,命令名稱,如“bold”
  • aShowDefaultUI,是否為該命令提供使用者介面,一般設為false,主流瀏覽器沒實現該功能
  • aValueArgument,某些命令的額外引數(insertImage命令需要提供插入的圖片的url)

所有支援的commands,可查閱MDN;其中,與剪貼簿相關的命令(copy、cut、paste)各瀏覽器實現差異較大,使用時需關注瀏覽器差異。

常用的命令舉例:

// 加粗
document.execCommand('bold', false, null);
// 超連結
document.execCommand('createlink', false, 'https://www.kaola.com');
// 格式化為h1標題
document.execCommand('formatblock', false, '<h1>');

複製程式碼

【注意】雖然所有瀏覽器都支援以上命令,但這些命令生成的html結構仍有差別。如bold命令,IE和Opera會使用<strong>標籤包裹文字,而Safari和Chrome則使用<b>標籤,firefox使用<span>

與命令相關的方法:
  • queryCommandEnabled 返回布林值,用於檢測是否可以針對當前選擇的文字或當前游標位置執行某個命令;
var canBold = document.queryCommandEnabled("bold");
複製程式碼
  • queryCommandState 返回布林值,用於判斷當前選擇的文字是否已經應用了指定的命令;
var isBold = document.queryCommandState("bold");
複製程式碼

可以使用這個方法,來設定編輯器中加粗、斜體等按鈕的狀態。

  • queryCommandValue 用於獲取執行某個命令時,傳入的值(即execCommand()方法的第三方引數)

富文字選區 Seletion

Seletion物件是指使用者選中的文字範圍或滑鼠的當前位置,通過window.getSelection()來獲取該物件。

image

Seletion物件的屬性如下:

  • anchorNode:選區起點所在節點;
  • anchorOffset:anchorNode中包含在選區內的字元數;
  • focusNode:選區終點所在節點;
  • focusOffset:focusNode中包含在選區內的字元數;
  • isCollapsed:boolean,選區的起點與終點是否重合,如果是,可以認為當前沒有內容選中;
  • rangeCount:選區中包含的DOM範圍的數量;
  • type:描述當前選區的型別

Selection物件的方法參閱MDN。這些方法在富文字編輯器外掛裡都是很有用的方法,比如控制游標的方法collapse()、collapseToEnd()、collapseToStart(),可以設定插入內容之後游標的位置; 獲取選區包含的文字的方法toString()getRangeAt(index)方法返回索引對應的選區中的DOM範圍,即range物件

來看一個例子:

// 獲取選區內容的位置
function  getSelPos () {
    let sel = window.getSelection()
    let rg = sel.getRangeAt(0)
    let elmRect = rg.getClientRects()[0]
    let editorRect = $('.j-editor')[0].getBoundingClientRect() // 編輯器容器
    let pos = {}
    if (elmRect) {
      // 選區內容居中位置距容器的左距離
      pos.x = elmRect.left - editorRect.left + elmRect.width / 2 
      pos.y = elmRect.top - editorRect.top
    }
    return pos
}

複製程式碼

上述方法,可以獲取當前選區相對於編輯器容器的位置,可以用來設定在選區附近出現的工具條等。 想實時監測選區的變化,可以監聽onselectionchange事件

// 高頻事件,做好節流
document.onselectionchange = _.debounce(this.onSelect, 100)
複製程式碼

處理paste內容

如果往富文字編輯器裡貼上內容,是會把內容的樣式也貼上進來的,瀏覽器自動會把應用到某個標籤的樣式內聯到此標籤的style屬性。但更多的時候我們只是需要保留裡面的部分格式,需要針對剪貼簿中的內容進行過濾、格式化以及特定內容保留等。


    editorElem.on('paste', event => {
        event.preventDefault();
        let clipboardData = event.clipboardData || event.originalEvent && event.originalEvent.clipboardData || {};
        let text = clipboardData.getData('text/plain');
        let html = clipboardData.getData('text/html');
    })

複製程式碼

通過偵聽paste事件,能獲取到事件物件上的clipboardData物件,獲取貼上的內容,可以通過getData方法獲取剪下版上的純文字或html結構。 有了html結構,就可以轉成dom物件,針對處理了。預設情況下,剪貼簿中的

下面重點說下對剪貼簿中圖片的處理。如果剪貼簿中的網頁元素包含圖片,即img標籤,如果直接貼上到編輯器,該圖片的連結地址是原網頁所在的圖片地址,這裡就要考慮到,如果外鏈別人網站的圖片,就有可能有朝一日這個圖片不可用,所以還是要放到自家的伺服器上才放心。這裡就涉及到,已知一張圖片的可訪問的連結地址,如何把該圖片上傳到自己的伺服器上呢?

不考慮相容性,給出一種可行的方案:通過canvas畫布獲取圖片的資料,並將資料轉為blob物件並進行上傳。步驟如下:

  1. new一個Image物件,設定src屬性為已知圖片的url;
  2. 在圖片物件的onload事件裡,建立canvas畫布,通過其toDataURL方法獲取圖片資料;
  3. 將圖片資料轉為一個blob物件,並呼叫圖片上傳介面上傳該blob物件;
// 根據圖片的url,上傳圖片
export function uploadImgWithUrl(imgUrl,  editor) {
    /**
     * 資料轉blob物件
     */
    function dataToBlob(data) {
        var bytes = void 0;
        bytes = data.split(",")[0].indexOf("base64") >= 0 ? window.atob(data.split(",")[1]) : unescape(data.split(",")[1]);
        var paramType = data.split(",")[0].split(":")[1].split(";")[0];
        var uArr = new Uint8Array(bytes.length);
        for (let i=0; i < bytes.length; i++) {
            uArr[i] = bytes.charCodeAt(i);
        }
        return new Blob([uArr], {
            type: paramType,
            name: 'blob.png'
        });
    }

    var options = this;
    return new Promise(function(resolve, reject) {
        var img = new Image;
        img.setAttribute("crossOrigin", "anonymous");

        img.onload = function() {
            var canvas = document.createElement("canvas");
            canvas.width = img.width;
            canvas.height = img.height;
            canvas.getContext("2d").drawImage(img, 0, 0);
            var data = canvas.toDataURL("image/png");
            var blob = dataToBlob(data);
            
            blob.name = 'blob.jpg'
            // 呼叫編輯器的上傳圖片介面 or 也可自行實現一個圖片上傳方法
            editor.uploadImg.uploadImg([blob], function(result){
                if (result && result.body) {
                    var link = result.body.imageUrlList || [];
                    var item = {
                        resourceType: 'image',
                        imageUrl: link[0]
                    }
                    resolve(item);
                }
            })
        };
        img.onerror = function() {
            reject();
            Message.error('圖片不允許跨域訪問,請手動下載後新增')
        };
        imgUrl = -1 !== imgUrl.indexOf("?") ? imgUrl + "&time=" + (new Date).getTime() : imgUrl + "?time=" + (new Date).getTime();
        img.src = imgUrl;
    });
}

複製程式碼

以上是將線上的圖片上傳到伺服器的一種解決方法,也在專案中進行了實踐。對於剪貼簿記憶體中的圖片內容,可以通過getAsFile()方法來獲取進而上傳:

// 處理記憶體中的圖片
if (clipboardData.items[0]) {
    let item = clipboardData.items[0]
    let type = item.type;
    let regResult = type.match(/image\/(.+)/)
    if (regResult) {
        let blob = item.getAsFile();
        // 呼叫編輯器的通用上傳介面
        editor.uploadImg.uploadImg([blob])
    }
}

複製程式碼

最後

以上,只算上對富文字編輯器的基本知識點進行了初步的梳理,如果想自己造輪子,擼一個編輯器出來,需要解決的問題還有很多,可以看下知乎上的討論為什麼都說富文字編輯器是天坑?,裡面提到實現一個令人滿意的編輯器需要各種填坑,以及良好的設計模式,路漫漫其修遠兮……


參考文獻

by lzf

儘量關注網易考拉前端團隊微信公眾號

image

相關文章