用Canvas实现文本编辑器(支持艺术字渲染与动画)

导言

目前富文本编辑器的实现主要有两种技术方案:一个是利用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编辑器核心的实现。

实现效果

用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中对应位置

  • 清空输入框,继续监听

然而,当输入中文时,一些输入法会出现这种现象:
用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));
  }
}

程序架构

用Canvas实现文本编辑器(支持艺术字渲染与动画)

程序的整体架构如上图所示,在入口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;
}

下图描述了项目核心结构、流程:
用Canvas实现文本编辑器(支持艺术字渲染与动画)

其中,样式切换是一个关键流程。项目中将样式配置统一保存在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文本渲染,对中文笔画分割实现有趣的动画等)并没有描写。这些将来会单开博文来写。

同时项目还有许多常用功能没有实现,比如光标位置切换不支持上下键,无法选择文本等,这些留作以后完善吧。

相关推荐