资讯专栏INFORMATION COLUMN

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

OldPanda / 2443人阅读

摘要:项目中文字由进行渲染。待触发时,取消中文输入标记,将文字渲染到上。而其中一些有趣的细节实现如文本渲染,对中文笔画分割实现有趣的动画等并没有描写。

导言

目前富文本编辑器的实现主要有两种技术方案:一个是利用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文本渲染,对中文笔画分割实现有趣的动画等)并没有描写。这些将来会单开博文来写。

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/82194.html

相关文章

  • Canvas + WebGL中文艺术渲染

    摘要:将所有关联对合并即并查集的过程,得到每个连通域的唯一标记。此时每个连通域轮廓可以看做是一个多边形,此时可以用经典算法将其剖分成若干个三角形。若值为则说明该像素处于当前连通域中。二维数组,表示每个像素是否是图像边缘。 笔者另一篇文章 https://segmentfault.com/a/11... 讲了基于Canvas的文本编辑器简诗的实现,其中文字由WebGL渲染艺术效果,这篇文章主要...

    baihe 评论0 收藏0
  • SegmentFault 技术周刊 Vol.35 - WebGL:打开网页看大片

    摘要:在文末,我会附上一个可加载的模型方便学习中文艺术字渲染用原生可以很容易地绘制文字,但是原生提供的文字效果美化功能十分有限。 showImg(https://segmentfault.com/img/bVWYnb?w=900&h=385); WebGL 可以说是 HTML5 技术生态链中最为令人振奋的标准之一,它把 Web 带入了 3D 的时代。 初识 WebGL 先通过几个使用 Web...

    objc94 评论0 收藏0
  • canvas入门里,你没注意到的那些知识

    摘要:但需要注意的是,需在使用前调用。当然,你愿意的话也可以两者结合着用。绘制图像相信很多入门的,都看不到这个地方,不就是绘制图像的嘛,啊不准确,是绘制图形的。明确的说,是指围绕原点图像旋转弧度。 前言 本文写在七月底,进来不加班就整理了一下,一些非常基础的知识,对于canvas刚入门的人来说,值得阅读一下。 来个气势如虹的开头 与看各种文章相比,我更喜欢数学里的逻辑;与学习各种日新月异的框...

    tuniutech 评论0 收藏0
  • QQ音乐的动效歌词是如何实践的?

    摘要:最终方案也确定采用序列帧动画方案。所以,要想在电影或者视频上显示效果,首先要做的是编写特效文件,然后再将特效文件解析成序列帧动画的位图,最后将这些位图按照特定的顺序和一定的帧率进行播放,就能看到各种特效的动画。 本文由云+社区发表作者:QQ音乐技术团队 一、 背景 1. 现状 歌词浏览已经成为音乐app的标配,展示和动画效果也基本上大同小异,主要是单行的逐字染色的卡拉OK效果和多行的...

    Edison 评论0 收藏0
  • QQ音乐的动效歌词是如何实践的?

    摘要:最终方案也确定采用序列帧动画方案。所以,要想在电影或者视频上显示效果,首先要做的是编写特效文件,然后再将特效文件解析成序列帧动画的位图,最后将这些位图按照特定的顺序和一定的帧率进行播放,就能看到各种特效的动画。 本文由云+社区发表作者:QQ音乐技术团队 一、 背景 1. 现状 歌词浏览已经成为音乐app的标配,展示和动画效果也基本上大同小异,主要是单行的逐字染色的卡拉OK效果和多行的...

    Scholer 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<