目前富文本编辑器的实现主要有两种技术方案:一个是利用contenteditable属性直接对html元素进行编辑,如draft.js;另外一种是代理textarea + 自定义div + 模拟光标实现。对于相似"word"的经典富文本编辑器,通常会采用以上两种技术方案之一,而不会考虑用canvas实现。css
事实上,官方最佳实践中已经特别声明了不推荐用canvas实现编辑器,详见https://www.w3.org/TR/2dconte...
不推荐的缘由包括光标位置维护、键盘移动的实现、以及没有原生文本输入处理等等。html
既然如此,为什么还要用canvas制做文本编辑器呢?这是由于对一些特殊的创做来讲,canvas能更好的实现展现需求。好比艺术字效果的渲染,以及文本、背景动画等。git
基于这点想法,便有了“简诗”这个自娱自乐的小项目。github
简诗是为短诗文创做而开发的文本编辑器,主要面向中文写做。中文最特别之处便在于其笔画,因此在开发之初,我便想对文字进行处理之时,必定要把汉字进行笔画分割,以便实现更多有趣的效果的。canvas
项目中文字由WebGL进行渲染。基本思路是先根据用户选择的字体,将文字写在离屏canvas上,而后利用getImageData api获取文字像素数据,进行连通域查询、分割、边缘查找及三角化后,由WebGL进行渲染。api
(注:这种处理方式的好处是对任意系统支持的字体均可以实现艺术效果,而无需额外的字体开发。目前项目中没有引入字体文件,用到的字体都是Mac内置的字体,Mac用户如发现其中有的字体系统没有默认安装,只需到“字体册”中安装一下便可)数组
这一系列过程会单开一篇文章来写,本文主要描述canvas编辑器核心的实现。架构
预览地址:https://moyuer1992.github.io/...
源码地址:https://github.com/moyuer1992...app
用canvas实现编辑器最关键的一点就是如何监听键盘文字输入,若是经过键盘事件本身处理,英文尚可,中文确定是不可行的。因此仍是须要使用原生textarea作一层代理。编辑器
代理textarea输入框是不可见的。这里需特别注意下,若用display: none隐藏输入框,则没法触发focus事件,因此输入框须要利用z-index来作隐藏。
当用户点击canvas时,程序控制触发textarea的focus事件,继而用户输入时,也天然触发了textarea的input事件:
var pos = this._convertWindowPosToCanvas(e.clientX, e.clientY); if (pos.x !== -1 && pos.y !== -1) { this.focus(pos.x, pos.y); } else { this.blur(); }
focus (x, y) { var pos = this.findPosfromMap(x, y); this.selection.update(pos.row, pos.col); this.updateCursor(); this.$input.focus(); this.$cursor.css('visibility', 'visible'); this.onFocus = true; }
按照上述方法,很容易想处处理文本输入的流程:
监听隐藏输入框的input事件
触发input事件时,将输入框value取出,渲染到canvas中对应位置
清空输入框,继续监听
然而,当输入中文时,一些输入法会出现这种现象:
显然,当使用中文输入法键入拼音时,拼音字母已经写入输入框中,触发了input事件,但事实上用户并无键入完毕。这就致使了最终拼音字母和汉字所有被写到了canvas上,这并不是咱们想要的结果。
如何解决呢?这里须要用到input元素的onCompStart和onCompEnd事件。
当中文输入开始时,会触发onCompStart事件,此时作一个标记,告知程序用户正在中文输入,input事件触发时,判断当前是否正在键入中文,如果,则不做任何操做。待onCompEnd触发时,取消中文输入标记,将文字渲染到canvas上。
this.$input.on('compositionstart', this.onCompStart.bind(this)); this.$input.on('compositionend', this.onCompEnd.bind(this)); this.$input.on('input', this.onInputChar.bind(this));
onCompStart (e) { this.inputStatus = 'CHINESE_TYPING'; } onCompEnd (e) { var that = this; setTimeout(function () { that.input(); that.inputStatus = 'CHINESE_TYPE_END'; }, 100) } onInputChar (e) { if (this.inputStatus === 'CHINESE_TYPING') { return; } this.inputStatus = 'CHAR_TYPING'; this.input(); }
用canvas实现编辑器须要模拟光标,这里用一个div来实现,设置position为absolute,用top、left来定位光标位置。
this.$cursor = $('<div class="cursor"></div>'); this.cursorNode = this.$cursor.get(0); this.$cursor.css('width', '1px'); this.$cursor.css('height', this.style.lineHeight() + 'px'); this.$cursor.css('position', 'absolute'); this.$cursor.css('top', this.selection.rowIndex * this.style.lineHeight()); this.$cursor.css('left', this.selection.colIndex * this.fontSize); this.$cursor.css('background-color', 'black');
用css动画实现光标1s闪动一次。
@keyframes cursor { from { opacity: 0; } 50% { opacity: 1; } to { opacity: 0; } } .cursor { animation: cursor 1s ease infinite; }
原理虽然简单,可是随着文字、排版、用户操做变动,如何维护光标位置,是一件较为繁琐的事。
这里定义了Selection类以存储用户选择区域。未选择任何文本的状况下,selection位置及为光标所在位置。(目前此项目还没有支持选择文本功能,但Selection类的设计方式对之后此功能的添加是支持的。)
selection对象中,位置存储彻底是针对文本矩阵的,而非对应屏幕上真正的坐标。项目中另外定义了map矩阵存储文本位置数据。map的具体设计下面一节会详细讲到。
更新光标函数以下:
updateCursor () { var pos = this.selection.getSelEndPosition(); this.$cursor.css('height', this.style.lineHeight() + 'px'); this.$cursor.css('left', this.map[pos.rowIndex][pos.colIndex].cursorX + 'px'); this.$cursor.css('top', this.map[pos.rowIndex][pos.colIndex].cursorY + 'px'); }
上一节中已经提到,项目中定义了map矩阵存储文本位置信息。每次渲染文字时,会依据当前样式(版式、文字大小等)更新map数据。
目前项目支持居中和左对齐两个版式,map更新时,这两个版式的位置计算有所不一样。
对于左对齐版式,逻辑比较简单,只要从左边边距处开始,逐个写入文字,直至换行便可。
而对于居中版式,逻辑要稍微复杂一些,处理每段文字时,要先根据每段文字总长度、canvas宽度、边距大小来肯定文字位置。若是此段文字不足一行,则直接居中显示,若超过一行,将每行填满后,对不足一行的部分居中显示。
每一个map元素结构以下:
{ char: 对应字符/文字, x: 文字起始x坐标, y: 文字起始y坐标, cursorX: 对应光标x坐标, cursorY: 对应光标y坐标 }
之因此用canvas实现文本编辑器,即是为了艺术效果的渲染以及文字、背景动画。项目但愿实现文字、背景样式的自由切换,为了下降耦合度,为每种文字、背景样式单独定义精灵。
文本精灵基类:https://github.com/moyuer1992...
文本精灵文件夹:https://github.com/moyuer1992...
背景精灵基类:https://github.com/moyuer1992...
背景精灵文件夹:https://github.com/moyuer1992...
精灵类中的核心是drawStatic、drawFrame、advance三个方法。
advance函数中,对进入下一帧时须要改变的参数进行定义。
drawStatic用于静态效果的渲染。Editor类中,每次须要从新渲染静态文字时,都会调用此方法。
_fillText () { if (this.map.length === 1 && this.map[0].length === 1) { this.clearText(); } else { $('.render-tip').addClass('show'); setTimeout(this.textSprite.drawStatic.bind(this.textSprite), 0); } }
drawFrame用于动画效果每一帧的渲染,当动画播放时,会逐帧调用此方法。
play () { this.animating = true; this.animationInfo = { textStop: false, bgStop: false }; this.startTime = Date.now(); this.textSprite.update(); this.bgSprite.update(); window.requestAnimationFrame(this.tick.bind(this)); }
tick () { if (!this.animating) { return; } var t = Date.now() - this.startTime; !this.animationInfo.textStop && (this.animationInfo.textStop = this.textSprite.advance(t)); !this.animationInfo.bgStop && (this.animationInfo.bgStop = this.bgSprite.advance(t)); if (this.animationInfo.textStop && this.animationInfo.bgStop) { this.stopPlay(); } else { this.animationInfo.bgStop ? this.bgSprite.drawStatic() : this.bgSprite.drawFrame(); this.animationInfo.textStop ? this.textSprite.drawStatic() : this.textSprite.drawFrame(); window.requestAnimationFrame(this.tick.bind(this)); } }
程序的总体架构如上图所示,在入口main.js中,直接新建Editor类实例,并初始化UI组件。
项目中最核心的部分就是Editor类。
Editor包含的数据:
data对象,用于存储文本数据
selection对象,用于存储选择信息
style对象,用于存储当前样式信息
map矩阵,用于存储当前文本对应位置
Editor包含的渲染精灵
bgSprite, 当前渲染背景的精灵
textSprite, 当前渲染文字的精灵
Editor包含的节点元素:
$input, 隐藏输入框
$canvas, 用于渲染普通canvas文本
$glcanvas, 用于渲染WebGL文本
$bgCanvas, 用于渲染普通背景
$bgGlcanvas, 用于渲染WebGL背景
这里须要解释一下为什么将文本、背景进行解耦分层。
首先, 每一个canvas一旦调用getContext('2d')方法,再调用getContext('WebGL')方法则会返回null。也就是说,同一个canvas只能获取普通2d context和WebGL context中的一个,这意味着咱们没法同时调用WebGL api和原生canvas api。因此对于文字或背景的渲染,都分红WebGL和原生canvas两种。
另外,因为项目中文本、背景样式均可以自由切换,若都用同一个canvas进行渲染,保持文本样式不变,而对背景样式进行切换时,则整个canvas都要重绘。为避免这样的开销,项目中将文本、背景进行分层绘制。
此处或许有人会考虑到最终图像保存的问题。是的,进行分层后,图像保存须要另外作一些处理,但并不太复杂,只需将每层canvas图像逐层绘制到一个离屏canvas上便可。
例如,导出png格式图片代码以下:
generatePng () { var canvas = document.createElement('canvas'); canvas.width = this.canvasNode.width; canvas.height = this.canvasNode.height; var ctx = canvas.getContext('2d'); ctx.drawImage(this.bgCanvasNode, 0, 0); ctx.drawImage(this.bgGlcanvasNode, 0, 0); ctx.drawImage(this.canvasNode, 0, 0); ctx.drawImage(this.glcanvasNode, 0, 0); var imgData = canvas.toDataURL("image/png"); return imgData; }
下图描述了项目核心结构、流程:
其中,样式切换是一个关键流程。项目中将样式配置统一保存在config.js文件中。
其中样式索引保存在config.state对象中:
state: { fontIndex: 0, fontSizeIndex: 0, fontColorIndex: 0, textStyleIndex: 0, textAlignIndex: 0, backgroundIndex: 0, animationIndex: 1, bgColorIndex: 0 }
而对应可切换的样式定义保存在相应map数组中。举个例子,对背景样式的配置以下:
backgroundMap: [ { Klass: 'PureBgSprite', label: '纯色', value: 0, colors: ['rgb(235, 235, 235)', '#FEFEFE', '#3a3a3a'] }, { Klass: 'TreeBgSprite', label: '月下林间', value: 1, colors: ['rgb(235, 235, 235)', '#b1a69b', '#3a3a3a'] } ]
backgroundMap数组中每项对应一个样式选择,Klass描述了定义该样式的精灵类名,label定义了工具栏中显示的样式名称,value即对应的样式索引,colors定义了该背景支持的切换颜色。
每次切换背景样式时,程序会根据Klass获取相应精灵实例,并将editor对象中的bgSprite指向该精灵实例。这里特别注意一下,为保证每一个精灵对象从始至终都只有一个实例,这里应用了单例模式。
根据类名获取对象实例的方法定义以下:
getSpriteEntity: function () { var entities = []; return function (className, editor) { var Klass = eval(className); return entities[className] ? entities[className] : entities[className] = new Klass(editor); }; }()
每次样式切换时,会把map中定义的具体参数赋给style对象,渲染时根据样式参数进行不一样处理。
到此为止,本文主要描述了编辑器的架构以及实现。而其中一些有趣的细节实现(如WebGL文本渲染,对中文笔画分割实现有趣的动画等)并无描写。这些未来会单开博文来写。
同时项目还有许多经常使用功能没有实现,好比光标位置切换不支持上下键,没法选择文本等,这些留做之后完善吧。