导言
目前富文本编辑器的实现主要有两种技术方案:一个是利用contenteditable属性直接对html元素进行编辑,如draft.js;另一种是代理textarea + 自定义div + 模拟光标实现。对于类似"word"的经典富文本编辑器,一般会采用以上两种技术方案之一,而不会考虑用canvas实现。
事实上,官方最佳实践中已经特别声明了不推荐用canvas实现编辑器,详见https://www.w3.org/TR/2dconte...
不推荐的原因包括光标位置维护、键盘移动的实现、以及没有原生文本输入处理等等。
既然如此,为何还要用canvas制作文本编辑器呢?这是因为对一些特殊的创作来说,canvas能更好的实现展示需求。比如艺术字效果的渲染,以及文本、背景动画等。
基于这点想法,便有了“简诗”这个自娱自乐的小项目。
简诗是为短诗文创作而开发的文本编辑器,主要面向中文写作。中文最特别之处便在于其笔画,所以在开发之初,我便想对文字进行处理之时,一定要把汉字进行笔画分割,以便实现更多有趣的效果的。
项目中文字由WebGL进行渲染。基本思路是先根据用户选择的字体,将文字写在离屏canvas上,然后利用getImageData api获取文字像素数据,进行连通域查询、分割、边缘查找及三角化后,由WebGL进行渲染。
(注:这种处理方式的好处是对任意系统支持的字体都可以实现艺术效果,而无需额外的字体开发。目前项目中没有引入字体文件,用到的字体都是Mac内置的字体,Mac用户如发现其中有的字体系统没有默认安装,只需到“字体册”中安装一下即可)
这一系列过程会单开一篇文章来写,本文主要描述canvas编辑器核心的实现。
实现效果
预览地址:https://moyuer1992.github.io/...
源码地址:https://github.com/moyuer1992...
技术关键点
文字键入(代理输入框)
用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 = $('');
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文本渲染,对中文笔画分割实现有趣的动画等)并没有描写。这些将来会单开博文来写。
同时项目还有许多常用功能没有实现,比如光标位置切换不支持上下键,无法选择文本等,这些留作以后完善吧。