简介

富文本编辑器(Rich Text Editor)简称 HTML 编辑器(HTML Editor),目的是让用户可以在浏览器上编辑各种格式的文本,类似于 Word “即见即所得”那种可视化的特性,HTML 编辑器能直观地编辑文本并同时产生预期的效果。不同的是 Word 最终成品是 *.doc 文件,而 HTML 编辑器的保存格式是 HTML。也许我们称“富文本编辑器”会更恰当一点,盖因这类编辑器,最终的格式不一定会是 HTML,而可以被设计保存为 MD(Markdown)或其他的格式。

富文本编辑器早在 Web 初期就得到了广泛的应用,从当年流行的 BBS 论坛留言到 CMS 后台的文章编辑,只要需要用到复杂一点的文本编辑,就可以派上用场。前者著名的例子有 UBB 编辑器,后者最显著的就是 FCKEditor 这项目了。我们可谓用的不少。当然 UBB 是比较旧的;而对新语法 Markdown 的支持则是后来的事情了。其时虽然仍未提出 AJAX 概念,但 DHTML(动态 HTML)的概念逐渐兴起,富文本编辑器就是反映其相当有说服力的一个案例:人们通过 JavaScript 编程实现了比较复杂的 DHTML 应用。

在很多开发者看来,富文本编辑器的编写是一件很神秘或者复杂的事情。神秘倒没有,复杂的话——也大可不必那样认为——它的基本原理并不复杂,可是说十分简单。网上有不少的教程和资源,参考一些也不错,至于源码,大多写得比较复杂,不利于说明原理(这里提供一个比较简单的)。于是我想在本文中逐步演示一个“羽量级”富文本编辑器的产生,来简单勾勒其实现的过程和原理。

动图演示:

Springboot 富文本编辑器 富文本编辑器 html_Springboot 富文本编辑器

在线例子:https://framework.ajaxjs.com/demo/form/html-editor.html
源码在:https://gitee.com/sp42_admin/ajaxjs/blob/master/aj-ts/src/form/html-editor.ts

使用方法

例子:

<div class="aj-form-html-editor"> 
	<aj-form-html-editor field-name="content"></aj-form-html-editor> 
</div>

属性:

属性

含义

类型

是否必填,默认值

field-name

表单 name,字段名

String

y

content

HTML内容

String

n

upload-image-action-url

图片上传路径

String

n

  • 获取内容方法:调用实例方法 getContent() 返回内容的 HTML,你可能要进一步 URIEncode 以符合汉字在表单中的编码。
  • 设置内容的三种方法:1、调用组件实例方法 setIframeBody(content);;2、通过 slot 标签中指定,如下例。
<aj-form-html-editor field-name="content"  ref="htmleditor" upload-image-action-url="uploadContentImg/">
	<textarea>Foo</textarea>
</aj-form-html-editor>

下面说说实现原理。还是 TypeScript + Vue + Less.js 的技术栈来实现。

画界面

Markup 标签

所谓写控件的过程——我推荐的第一步——就是把控件界面画出来。先看到目标的大概样子,然后再一一实现其功能。 编辑器可以分为顶部的工具条和正文编辑两大区域。工具条是一个 <ul><li> 列表结构,li 元素代表是工具条上的每一个按钮。按钮根据需求场景的不同,既可以是单纯的按钮,也可以通过该按钮来展开下级菜单。没有菜单的话会简单许多,仅仅是一个图标按钮而已,例如 <li><i title="增加链接" class="createLink fa-link"></i></li>

如下例的设置“字体”即是一个展开菜单的按钮,所以标签除了按钮 <i title="字体" class="fa-font"></i> 外,还有与之并列的 <div class="fontfamilyChoser">……<div>(菜单列表)。而且还要让父元素 li 设置 class=”dorpdown”,表示有下拉菜单。

Springboot 富文本编辑器 富文本编辑器 html_typescript_02


按钮和下拉菜单结构如下。关于下拉菜单的制作,笔者之前专门写过博客()简介,这里就不赘述了。

<ul class="toolbar" @click="onToolBarClk">
    <li class="dorpdown">
        <i title="字体" class="fa-font"></i>
        <div class="fontfamilyChoser" @click="onFontfamilyChoserClk">
            <a style="font-family: '宋体'">宋体</a>
            <a style="font-family: '黑体'">黑体</a>
            <a style="font-family: '楷体'">楷体</a>
            <a style="font-family: '隶书'">隶书</a>
            <a style="font-family: '幼圆'">幼圆</a>
            <a style="font-family: 'Microsoft YaHei'">Microsoft YaHei</a>
            <a style="font-family: Arial">Arial</a>
            <a style="font-family: 'Arial Narrow'">Arial Narrow</a>
            <a style="font-family: 'Arial Black'">Arial Black</a>
            <a style="font-family: 'Comic Sans MS'">Comic Sans MS</a>
            <a style="font-family: Courier">Courier</a>
            <a style="font-family: System">System</a>
            <a style="font-family: 'Times New Roman'">Times New Roman</a>
            <a style="font-family: Verdana">Verdana</a>
        </div>
    </li>
    ……
</ul>

对于复杂的 Markup,需要 JavaScript 编程的,例如颜色拾取器(原理参见这里),我把生成部分封装为一个函数,返回 HTML 字符串就用 document.write() 直接输出。早期 document.write() 的“名声”不太好,但现在偶尔使用无妨。

图标

要说图标,之前是采用雪碧图方案的。图标也比较美观,

Springboot 富文本编辑器 富文本编辑器 html_vue.js_03


后来改为字体图标,搜索一下,原来 Font Awesome 早就准备好,直接换上,

Springboot 富文本编辑器 富文本编辑器 html_typescript_04


采用字体图标好处是可以摆脱了背景图,但缺点也不是没有,就是只有单色很单调。

iframe 预览

之所以能够达到“即见即所得”的那种可视化效果,完全拜 <iframe /> 元素所赐,也就是“网页呈现网页”的缘故了。光有呈现还不够,还要让用户可以编辑里面的内容,这就是 HTML 规范里的 designMode 属性来指定整个页面是否可编辑。当页面可编辑时候页面上任何支持 contentEditable 属性的元素都变成可编辑状态。

所以可以说,HTML 编辑器的核心原理正在于此,只是一两行代码的事情,一点也不复杂,也可以说浏览器实现了背后复杂的机制,通过简单的 API 接口提供给用户,相等于为我们屏蔽了那些复杂的逻辑——我们开发者是不是很轻松呢?

HTML 编辑器组件的模板包含下面关键的 iframe 引入,注意使用了 srcdoc 属性。

Springboot 富文本编辑器 富文本编辑器 html_typescript_05


注释部分展示了旧的方法,说明 iframe 仍要指向一页面(使用 src 属性),这页面的内容如下。

<%@page pageEncoding="UTF-8"%>
<html>
	<head>
		<meta http-equiv="content-type" content="text/html; charset=utf-8">
		<title>New Document</title>
		<style type="text/css">
			body {
				padding-top: 3px;
				padding-left: 3px;
				padding-right: 0px;
				margin: 0px
			}
			
			p {
				margin: 0 0 .6% 0;
				padding:.5%;
				line-height: 160%;
				font-size: 1rem;
				letter-spacing: 1px;
				text-align: justify;
			}
			
			img{
				max-width:90%;
			}
		</style>
		<base href="${param.basePath}/" />
	</head>
	<body></body>
</html>

对于组件化来说,分离多出来一个页面文件比较麻烦,于是我们找到有 iframe 的 srcdoc 属性,允许我接在该属性上直接定义 HTML 内容。微信浏览器该属性似乎有 bug,参考这里。

与 iframe 元素“相伴”的还有 <textarea> 元素。我们知道在页面里面包含正确显示 HTML 字符串要进行转义,但在 <textarea> 里面的则无须转义,统统转为字符串而不是渲染 HTML。这些 HTML 哪里来?那是编辑要编辑和显示的输入内容呀~对 Vue 组件输入参数我们一般用组件标签的属性(props),但是 HTML 又长又要转义,并不适合用 props 输入,故另外找方法,就是使用 Vue 的 slot 插槽输入大量的标签。

下面是一个使用的例子:

<div class="htmlEditor-row">
	<div class="label">正 文<span class="required-note">*</span>:</div>
	<div>
		<!-- HTML 在线编辑器,需要 textarea 包裹着内容 -->
		<aj-form-html-editor field-name="content" base-path="${ctx}" ref="htmleditor" upload-image-action-url="uploadContentImg/">
			<textarea>${info.content}</textarea>
		</aj-form-html-editor>
	</div>
</div>

组件 field-name 属性是表单的名称,可自定义。其他更多的配置属性,参见其他小节的介绍。

组件中间的 <textarea>${info.content}</textarea> 就是定义 <slot> 具体内容,其中 ${info.content} 是服务端提供的 HTML 标签变量。所以请注意,我们在使用 HTML 編輯器的时候必须提供一个 <slot> 包含有 <textarea>……</textarea>放置内容,即使是空内容的情况下。

两种编辑模式

HTML 编辑器允许用户两种编辑模式:除了上文提到的预览模式(iframe),还有直接编辑的代码模式(code),故设定组件属性有 mode 如下。

mode: 'iframe' | 'textarea' = "iframe";

默认是 iframe 模式就是初始化看到的样子。

Springboot 富文本编辑器 富文本编辑器 html_html_06

按下工具条最后一个按钮切换到 code 模式。

Springboot 富文本编辑器 富文本编辑器 html_typescript_07

最后请记住:两处都可以修改,但结果只有一个。

TypeScript 实现

关联 iframe 与 textarea

对相关的 DOM 元素稍加整理,于是可以得到这么的一个结构,我们用 TypeScript 的接口定义。

/**
 * HTML 编辑器的 DOM 元素引用
 */
interface IHtmlEditorDomRef {
    /**
     * 工具条元素
     */
    toolbarEl: HTMLElement;

    /**
     * iframe 元素对象
     */
    iframeEl: HTMLIFrameElement;

    /**
     * iframe 下的 contentWindow.document 对象
     */
    iframeDoc: Document;

    /**
     * textrea 控件对象,保存着 HTML 代码,表单提交时就读取这个 textarea.value
     */
    sourceEditor: HTMLTextAreaElement;
}

其实还有两个变量没有定义,如 iframeEl.contentWindowiframeDoc.body(即 iframeEl.contentWindow.document.body),用的时候我们用 . 访问器逐个访问出来就可以了。

ifame 对象渲染了 HTML 内容,而且允许用户在它身上直接进行编辑。下面是 iframe 的初始化工作,在 Vue 组件的 mounted 事件中完成。

Springboot 富文本编辑器 富文本编辑器 html_Springboot 富文本编辑器_08


关键 iframeDoc.designMode = 'on'; 一句使之可以进行编辑。之后通过 MutationObserver API 监视 DOM 变化,通知修改 textarea 内容。这里的联动也非常关键。笔者旧的方法没有使用 MutationObserver,而是手动同步 HTML 编辑内容,比较麻烦,最不能接受的是提交表单时候要手动获取编辑器内容,非常麻烦。

IHtmlEditorDomRef.sourceEditor 为 textrea 控件对象,保存着 HTML 代码,表单提交时就读取这个 textarea.value。textrea 有以下作用:

  • 承托输入 HTML 代码,作为表单控件元素的一员,其 name 属性就是提交的属性名称。
  • 允许用户直接编辑 HTML 代码的控件。初始化的时候这个 textarea 是隐藏的,只有用户切换到“代码模式 (this.mode = "code")”的时候才显示(如下图所示)。

反过来,textarea 的内容也要同步给 iframe 显示,所以我们要做的是 iframe 与 textarea 两者之间关联的问题。之前的 iframe 采用了 MutationObserver API,textarea 这里我也想如法炮制,不料发现不成功。于是搜索一番,有高人介绍简单地用 oninput 事件即可,故有了下面的初始化过程。

Springboot 富文本编辑器 富文本编辑器 html_HTML_09


下面就是同步内容比较重要的函数。

/**
* 输入 HTML 内容
* 
* @param html 
*/
setIframeBody(html: string): void {
    this.iframeDoc.body.innerHTML = html;
}

/**
 * 获取内容的 HTML
 * 
 * @param cleanWord 
 * @param encode 
 */
getValue(cleanWord: boolean, encode: boolean): string {
    let result: string = this.iframeDoc.body.innerHTML;

    if (cleanWord)
        result = cleanPaste(result);

    if (encode)
        result = encodeURIComponent(result);

    return result;
}

/**
 * 切換 HTML 編輯 or 可視化編輯
 * 
 */
setMode(): void {
    if (this.mode == 'iframe') {
        this.iframeEl.classList.add('hide');
        this.sourceEditor.classList.remove('hide');
        this.mode = 'textarea';
        grayImg.call(this, true);
    } else {
        this.iframeEl.classList.remove('hide');
        this.sourceEditor.classList.add('hide');
        this.mode = 'iframe';
        grayImg.call(this, false);
    }
}

执行内容编辑

到目前为止,我们的编辑已经可以编辑文章和代码了,那么接下来如何为文字进行格式化的工作呢(字体、字号、加粗、对齐等)?答案依然还是浏览器提供的 API: document.execCommand(),它强大而且功能丰富,成为实现 HTML 编辑器不可或缺的关键因素。幸运的是,早在 IE5/6 上古时代就提供支持了。

我们看看我们编辑器的封装例子。

/**
 * 通过 document.execCommand() 来操纵可编辑内容区域的元素
 * 
 * @param type 命令的名称
 * @param para 一些命令(例如 insertImage)需要额外的参数(insertImage 需要提供插入 image 的 url),默认为 null
 */
private format(type: string, para?: string): void {
    if (para)
        this.iframeDoc.execCommand(type, false, para);
    else
        this.iframeDoc.execCommand(type, false);

    (<Window>this.iframeEl.contentWindow).focus();
}

/**
 * 选择字号大小
 * 
 * @param ev
 */
private onFontsizeChoserClk(ev: Event): void {
    let el: HTMLElement = <HTMLElement>ev.target,
        els = (<HTMLElement>ev.currentTarget).children;

    for (var i = 0, j = els.length; i < j; i++)
        if (el == els[i])
            break;

    this.format('fontsize', i + "");
}

private onFontColorPicker(ev: Event): void {
    this.format('foreColor', (<HTMLElement>ev.target).title);
}

private onFontBgColorPicker(ev: Event): void {
    this.format('backColor', (<HTMLElement>ev.target).title);
}

更完整的 API 文档参见这里

如何插入图片

本地上传图片

HTML 里面混合的图片如何保存跟显示,取决于后台的方案,并不单纯是前端的事情。例如最初把图片“就地”保存在 Web 项目的文件夹中,共用一个 tomcat 服务。那么这时需要明确图片的位置是相对路径,Vue 组件有 props 属性 basePath 对应这个路径:

props = {
    fieldName: { type: String, required: true },    // 表单 name,字段名
    basePath: { type: String, required: false, default: '' },// iframe 的 <base href="${param.basePath}/" />路徑
    uploadImageActionUrl: String                    // 图片上传路径
};

这个就是 iframe 里面头部所在的 <base href="${param.basePath}/" /> 路徑。如果不用 basePath,直接指定图片路径也是可以的。另外属性 uploadImageActionUrl 就是图片上传的后台路径。

后来分离了文件存储,图片远程上传到云空间,就无须保存在本地了,属性 basePath 也取消了。

上传图片 UI

一键存图

用户使用 HTML 编辑输入内容的时候,可能会包含图片,图片可能是粘贴过来的,那么这图片应该就是在远程的(以 http 开头的地址)。一般情况下可以正常显示,——万一远程的服务器挂了,不能显示图片(只有文字)那就不好了。

于是有了个需求,希望把远程的图片下载到本地,相当于一个小型的“爬虫”,把图片抓取回来。

这个问题不大,解决思路就是先获取正文里面(iframe 里面的)的所有图片对象,凡是以 http 开头的都收集到一个数组之中,然后提交给后端,让后端去抓取这些图片,然后返回给前端,更新图片对象数组的 src 地址,这样就完毕了。值得注意的是服务端返回数组之索引顺序要维持不变,才能对得上前端的图片数组。

前端代码如下 saveRemoteImage2Local()

/**
 * 一键存图
 * 
 * @param this 
 */
function saveRemoteImage2Local(this: HtmlEditor): void {
    let arr: NodeListOf<HTMLImageElement> = this.iframeDoc.querySelectorAll('img'),
        remotePicArr: HTMLImageElement[] = new Array<HTMLImageElement>(),
        srcs: string[] = [];

    for (var i = 0, j = arr.length; i < j; i++) {
        let imgEl: HTMLImageElement = arr[i],
            src: string = <string>imgEl.getAttribute('src');

        if (/^http/.test(src)) {
            remotePicArr.push(imgEl);
            srcs.push(src);
        }
    }

    if (srcs.length)
        xhr.post('../downAllPics/', (json: ImgUploadRepsonseResult) => {
            let _arr: string[] = json.pics;

            for (var i = 0, j = _arr.length; i < j; i++)
                remotePicArr[i].src = "images/" + _arr[i]; // 改变 DOM 的旧图片地址为新的

            aj.alert('所有图片下载完成。');
        }, { pics: srcs.join('|') });
    else
        aj.alert('未发现有远程图片');
}

因为后端要下载图片列表,所以耗时可能会比较久。

另外一种思路是在前端完成,见下面转换的函数。

/**
 * 输入图片远程地址,获取成功之后将其转换为 Canvas。
 * 在回调中得到 Canvas Base64 结果
 * 
 * @param imgUrl 
 * @param cb 
 * @param format 
 * @param quality 
 */
export function imageToCanvas(imgUrl: string, cb: Function, format: string = 'image/jpeg', quality: number = .9): void {
    let img: HTMLImageElement = new Image();
    img.onload = () => {
        let canvas: HTMLCanvasElement = imgObj2Canvas(img);
        cb(canvas.toDataURL(format, quality)); // 使用了同步,不是最优
    }

    img.src = imgUrl;
}

/**
 * 输入图片远程地址,获取成功之后将其转换为 Blob。
 * 在回调中得到 Blob 结果
 * 
 * @param imgUrl 
 * @param cb 
 */
export function imageElToBlob(imgUrl: string, cb: Function): void {
    imageToCanvas(imgUrl, (canvas: string) => cb(dataURLtoBlob(canvas)));
}

图片地址下载到浏览器本地(Image 对象),通过 Canvas 转换为 base64 字符串,然后转换其为 Blob 类型二进制数据,之后就可以上传给后端了。

粘贴图片

编辑器中粘贴图片时,浏览器可以从 ev.clipBoardData.items 获取图片的二进制数据,以便上传到后台,实现粘贴图片的功能。参考这里

首先监听 iframe 的 paste 事件。

iframeDoc.addEventListener('paste', onImagePaste.bind(this));// 直接剪切板粘贴上传图片

当触发 paste 事件,如果发现图片类型,送到 aj.img.changeBlobImageQuality() 压缩图片。原始的图片数据是 bitmap 位图,一般体积比较大,故进行压缩变为 jpg 再上传。这里实际是 File 类型转为 Image 类型,然后通过 canvas 压缩 Image,最后输出为 Blob 类型(FileBlob 的子类)

/**
 * 粘贴图片
 * 
 * @param this 
 * @param ev 
 */
function onImagePaste(this: HtmlEditor, ev: ClipboardEvent): void {
    if (!this.uploadImageActionUrl) {
        aj.alert('未提供图片上传地址');
        return;
    }

    let items: DataTransferItemList | null = ev.clipboardData && ev.clipboardData.items,
        file: File | null = null; // file 就是剪切板中的图片文件

    if (items && items.length) {// 检索剪切板 items
        for (let i = 0; i < items.length; i++) {
            let item: DataTransferItem = items[i];

            if (item.type.indexOf('image') !== -1) {
                // @ts-ignore
                if (window.isCreate) { // 有图片
                    aj.alert('请保存记录后再上传图片。');
                    return;
                }

                file = item.getAsFile();
                break;
            }
        }
    }

    if (file) {
        ev.preventDefault();

        img.changeBlobImageQuality(file, (newBlob: Blob): void => {
            // 复用上传的方法
            Vue.options.components["aj-xhr-upload"].extendOptions.methods.doUpload.call({
                action: this.uploadImageActionUrl,
                progress: 0,
                uploadOk_callback(j: ImgUploadRepsonseResult) {
                    if (j.isOk)
                        this.format("insertImage", this.ajResources.imgPerfix + j.imgUrl);
                },
                $blob: newBlob,
                $fileName: 'foo.jpg' // 文件名不重要,反正上传到云空间会重命名
            });
        });
    }
}

这里有个硬编码的地方:if (window.isCreate)……,暂时还没想到如何解决:(。目的是防止用户在未创建实体之前,没有所属的实体来上传图片。

清理冗余标签

HTML 编辑往往会粘贴来自 Word 格式的内容。我们知道那包含了很多冗余的标签,需要进行清理。下面是一个简单的函数。

/**
 * Remove additional MS Word content
 * MSWordHtmlCleaners.js https://gist.github.com/ronanguilloux/2915995
 * 
 * @param html 
 */
function cleanPaste(html: string): string {
    html = html.replace(/<(\/)*(\\?xml:|meta|link|span|font|del|ins|st1:|[ovwxp]:)((.|\s)*?)>/gi, ''); // Unwanted tags
    html = html.replace(/(class|style|type|start)=("(.*?)"|(\w*))/gi, ''); // Unwanted sttributes
    html = html.replace(/<style(.*?)style>/gi, '');   // Style tags
    html = html.replace(/<script(.*?)script>/gi, ''); // Script tags
    html = html.replace(/<!--(.*?)-->/gi, '');        // HTML comments

    return html;
}

完善一点的可以参考这个 HtmlSanitizer

结语

HTML 编辑器到这里算是完成了。稍微感觉遗憾的是,没有太大发挥 Vue 的 MVVM 特性,例如内容 content 是否可以数据驱动呢?主要这是旧的传统方式改写过来的。能是能用,就不过多的折腾了。