经常在做企业网站的管理系统的时候需要用到富文本编辑器,之前基本上都是直接去 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 对象请看下图

android h5富文本视频播放失败 h5富文本编辑器_android h5富文本视频播放失败

android h5富文本视频播放失败 h5富文本编辑器_编辑器_02

上面说到光标也是一个特殊的选区,当 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>

android h5富文本视频播放失败 h5富文本编辑器_js_03

注意点

  • 执行的是原生的 document.execCommand 方法,浏览器自身会对 contenteditable 这个可编辑区维护一个 undo 栈和一个 redo 栈,所以我们才能执行前进和后退的操作,如果我们改写了原生方法,就会破坏原有的栈结构,这时就需要自己去维护,代价很大
  • 如果是 Vue style 里面如果加上 scope 的话,里面的样式对编辑区的内容是不生效的,因为编辑区里面是后来才创建的元素,所以要么删了 scope,要么用 /deep/ 解决(Vue 是这样)。React 的 styled-components 也有类似问题。