富文本編輯器原理探索

經常在做企業網站的管理系統的時候需要用到富文本編輯器,之前基本上都是直接去 npm 或者 github 上面搜找一些排名考前或者 readme 寫的好的庫,直接拿來用。萬變不離其宗,是時候探索下本質了。

contenteditable

要想實現富文本需要開啓“編輯”的能力,系統提供了一個 api:contenteditable 允許我們對內容進行編輯。下面是來自 MDN 的官方解釋。

The contenteditable global attribute is an enumerated attribute indicating if the element should be editable by the user. If so, the browser modifies its widget to allow editing.
The attribute must take one of the following values:

  • true or the empty string, which indicates that the element must be editable;
  • false, which indicates that the element must not be editable.
    If this attribute is not set, its default value is inherited from its parent element.

contenteditable 的值設置爲 true 或者空字符串"" 允許內容被編輯,false 則代表不可被編輯。
它不僅可以作用在 textarea、div、甚至是網頁所見的都可以進行編輯。所以利用這點兒你可以做一些😈壞事情 ,比如修改教務處網頁上的成績單和績點分數,修改天氣預報的溫度走勢情況,反手修改某一天的溫度爲 66度。

document.execCommand

想想看,你在輸入框裏面輸入了一段文字,你點擊上面的加粗按鈕如何實現?MDN 告訴我們有個 api 可以滿足需求。當元素進入編輯模式的時候,document 對象暴露出一個 execCommand 方法去操縱當前的可編輯區域 。看看下方 MDN 給出的解釋。

When an HTML document has been switched to designMode, its document object exposes an execCommand method to run commands that manipulate the current editable region, such as form inputs or contentEditable elements.
document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
A Boolean that is false if the command is unsupported or disabled.

aCommandName:命令名稱。比如加粗、下劃線、無序列表、段落、H1等等
aShowDefaultUI:布爾值。是否展示默認的樣式,一般爲 false
aValueArgument:一些命令所需要的額外參數,比如 insertImage 插入圖片所需要的圖片 url

完整的命令和各個瀏覽器的支持情況可以查看 MDN。

Selection 和 Range 對象

在執行 document.execCommand 的時候需要知道對誰在什麼範圍內執行命令。這裏有一個選區的概念,也就是 Selection,用來表示用戶選擇的範圍。(說明:用戶不選中任何內容,也就是隻有一閃一閃的光標的情況也算是一種特殊的選中)。

一個頁面包含多個選中區域(Firefox) 支持。所以 Selection 可以看作是 Range 對象的集合。通常情況下我們一般只存在一個選中的區域,所以 document.getSelection().getRangeAt(0) 就可以拿到當前選區的信息。

Range 對象請看下圖

選區Range對象1

選區Range對象2

上面說到光標也是一個特殊的選區,當 endOffset 和 startOffset 相等的時候,collapsed 屬性就爲 true。

通過 document.getSelection().getRangeAt(0) 就可以獲取到選區的信息,那麼可以將當前選區保存下來,等到需要的時候再拿出來並展示。Selection 對象還有幾個開放的方法(addRange、collapse、collapseToEnd、collapseToStart)可以操縱光標(比如插入文字後光標的位置)。

理論聯繫實際、一切從實際出發

動手做一個簡易的富文本編輯器吧。(不想寫一個 Vue 或者 React 工程,拿最簡單的 html 擼一個吧)

思路:

  • 新建 html 文件
  • 設置2個大的 div,一個展示功能按鈕,一個展示編輯區域(編輯區域需要設置允許可編輯)
  • 樣式佈局
  • 各個功能按鈕添加點擊事件監聽
  • 因爲點擊各個按鈕都是執行 document.execCommand,唯一不同的就是命名名稱和參數不一樣,所以簡單封裝函數
  • 調用封裝的函數,傳遞參數

下面貼出代碼

<html>
<head>
    <title>富文本</title>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <style>
        .commandZone {
            margin: 20px;
            margin-bottom: 0px;
            background: burlywood;
        }
        .editor {
            border: 1px solid gray;
            margin: 0px 20px 20px 20px;
            height: 300px;
        }
        .btn {
            margin: 10px 20px;
            color: black;
            font-size: 20px;
            line-height: 20px;
            display: inline;
        }
    </style>
</head>
<body>
    <div class="commandZone">
        <button id="paragraphBtn" class="btn">段落</button>
        <select name="hstyle" id="hstyle">
            <option value="1">h1</option>
            <option value="2">h2</option>
            <option value="3">h3</option>
            <option value="4">h4</option>
            <option value="5">h5</option>
            <option value="h6">h6</option>
        </select>
        <button id="boldBtn" class="btn">加粗</button>
        <button id="undoBtn" class="btn">後退</button>
        <button id="redoBtn" class="btn">前進</button>
        <button id="insertHorizontalRuleBtn" class="btn">水平線</button>
        <button id="insertUnorderedListBtn" class="btn">無序列表</button>
        <button id="createLinkBtn" class="btn">插入鏈接</button>
        <button id="insertImageBtn" class="btn">插入圖片</button>
    </div>
    <div class="editor" contenteditable="true"></div>
</body>
<script>
    var hStyle = '<h1>';
    document.getElementById('hstyle').onchange = function () {
        var optionSelectedIndex = document.getElementsByTagName('option');
        hStyle = optionSelectedIndex[document.getElementById('hstyle').selectedIndex].innerHTML;
        execEditorCommand('formatBlock', hStyle);
    }

    function execEditorCommand(name, args = null) {
        document.execCommand(name, false, args);
    }

    document.getElementById('boldBtn').onclick = function () {
        execEditorCommand('bold', null);
    }
    document.getElementById('insertHorizontalRuleBtn').onclick = function () {
        execEditorCommand('insertHorizontalRule', null);
    }
    document.getElementById('insertUnorderedListBtn').onclick = function () {
        execEditorCommand('insertUnorderedList', null);
    }
    document.getElementById('undoBtn').onclick = function () {
        execEditorCommand('undo', null);
    }
    document.getElementById('redoBtn').onclick = function () {
        execEditorCommand('redo', null);
    }
    document.getElementById('paragraphBtn').onclick = function () {
        execEditorCommand('formatBlock', '<p>');
    }
    document.getElementById('createLinkBtn').onclick = function () {
        let link = window.prompt('請輸入鏈接地址');
        execEditorCommand('createLink', link);
    }
    document.getElementById('insertImageBtn').onclick = function () {
        let image = window.prompt('請輸入圖片地址');
        execEditorCommand('insertImage', image);
    }
</script>
</html>

簡易富文本編輯器

注意點

  • 執行的是原生的 document.execCommand 方法,瀏覽器自身會對 contenteditable 這個可編輯區維護一個 undo 棧和一個 redo 棧,所以我們才能執行前進和後退的操作,如果我們改寫了原生方法,就會破壞原有的棧結構,這時就需要自己去維護,代價很大
  • 如果是 Vue style 裏面如果加上 scope 的話,裏面的樣式對編輯區的內容是不生效的,因爲編輯區裏面是後來才創建的元素,所以要麼刪了 scope,要麼用 /deep/ 解決(Vue 是這樣)。React 的 styled-components 也有類似問題。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章