富文本编辑器初探

13,645 阅读8分钟

长期以来,作为用户我是富文本编辑器的使用者,作为前端开发,我也只是富文本插件的使用者,对内部实现细节不甚了解,使用上也只停留在调用插件提供的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