图片编辑器开发实践

3,245 阅读8分钟

TNTWeb - 全称腾讯新闻中台前端团队,组内小伙伴在Web前端、NodeJS开发、UI设计、移动APP等大前端领域都有所实践和积累。

目前团队主要支持腾讯新闻各业务的前端开发,业务开发之余也积累沉淀了一些前端基础设施,赋能业务提效和产品创新。

团队倡导开源共建,拥有各种技术大牛,团队Github地址:github.com/tnfe

本文作者口袋

图片编辑器开发总结

背景:

在实际业务开发中遇到图片编辑模块, 实际上图片编辑本身是相对于业务比较解藕的模块。对于使用方需要提供原图加载接口和编辑后导出文件接口即可!

image.png

因此想到可以做提取一个功能完备,减少使用者配置的图片编辑器组件,方便后续各业务使用。虽然市面上其实已经有一些使用起来还不错的的组件。图片编辑组件的开发主要是基于一下两个方面来考虑:

1,需要能够满足常见的图片编辑需求,扩展更多的功能等

2,需要做到良好的交互

和任何其他组件或者模块开发一样,首先是先调研目前市面上已有的图片编辑器;经过比较最终选择了github.com/nhn/tui.ima…, 它的优点很明显就是api较多 支持的功能较多,但是缺点也很明显,就是很古老,react的支持在2021年的2月份才支持,并且不支持hooks. 于是我们就在此基础上采用ts+react hooks 来编写我们的自己的编辑器。如果你不经常使用canvas,很多api都需要去查找,笔者也是这样,一边看源码,一边看canvas的api,下面是实现基本原理包括canvas的常用方法总结,编辑器实战以及未来想实现的一些功能。

实现基本原理 canvas大法

在开始之前,我们先说下canvas的注意事项:

 标签不可省

与很多html标签不一样的是 元素需要结束标签(), 你不可以直接这样写

你的所有操作都是在渲染上下文中

 元素创造了一个固定大小的画布,canvas起初是空白的,我们通过其渲染上下文可以用来绘制和处理要展示的内容。

图片编辑器的总体步骤是:第一步是设置画布并获取渲染上下文,第二步:我们需要将图片绘制在canvas画布上,第三步是做各种操作;

第一步:设置画布并获取渲染上下文:

<html>
  <head>
    <title>Canvas tutorial</title>
    <script type="text/javascript">
      function draw(){
        var canvas = document.getElementById('tutorial');
        if (canvas.getContext){
          var ctx = canvas.getContext('2d');
        }
      }
    </script>
    <style type="text/css">
      canvas { border: 1px solid black; }
    </style>
  </head>
  <body onload="draw();">
    <canvas id="tutorial" width="150" height="150"></canvas>
  </body>
</html>

第二步:将图片绘制在canvas画布

截取了tui中的loadImage的源码 你一看就明白,drawImage方法在画布的原点开始绘制,同时设置了canvas画布的大小等于原图的大小, 这么做为后续的各种操作带来了极大便利 因为图片上各位置和画布一致,同时导出图片时也不需要再做特殊处理,弊端是在如处理多张图片合成带来了困难。

  loadImage(url, callback, options) {
    const img = new Image();
    if (options && options.corsenabled) {
      img.crossOrigin = 'Anonymous';
    }
    img.src = url;
    img.onload = function () {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      const context = canvas.getContext('2d');
      context.drawImage(img, 0, 0);
      callback(canvas);
    };
  }

第三步:开始操作画布

以几个常见主要操作为例,绘制图形,旋转,裁剪

绘制三角形

绘制一个三角形生成路径的第一步叫做beginPath()。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形

function draw() {
  var canvas = document.getElementById('canvas');
  if (canvas.getContext) {
    var ctx = canvas.getContext('2d');
    ctx.beginPath();
    ctx.moveTo(75, 50);
    ctx.lineTo(100, 75);
    ctx.lineTo(100, 25);
    ctx.fill();
  }
}

旋转90度

在了解旋转之前,先介绍两个在你开始绘制复杂图形时必不可少的方法。

save()保存画布(canvas)的所有状态

restore() 恢复画布的状态 save 和 restore 方法是用来保存和恢复 canvas 状态的,都没有参数。Canvas 的状态就是当前画面应用的所有样式和变形的一个快照。

我当时在看到这两个api的时候 小心脏蹦蹦的跳,嗯哼, 用这个可以实现撤销和恢复操作啊啊啊, 同时历史记录的功能也实现了哦,然而,并不完全是!!! 这里就涉及到canvas中一个非常重要的名词:状态。状态是前端同学经常听到的一个词,状态管理,有状态组件,无状态组件等等,在canvas中也是存在着状态,比如我们在调用fillRect画距形时,矩形边的宽度,边的颜色等等这些都是我们提前设置好的状态,Canvas状态存储在栈中,每当save()方法被调用后,当前的状态就被推送到栈中保存,那么哪些属性和操作可以被保存在状态中呢?(😭,有些属性和操作不会被保存在状态栈中的,这就是上面说的撤销和历史记录有些部分记录缺失的原因),有以下三种:

  • 当前应用的变形(即移动,旋转和缩放,见下)
  • 以及下面这些属性:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled
  • 当前的裁切路径(clipping path)

你可以调用任意多次 save方法。每一次调用 restore 方法,上一个保存的状态就从栈中弹出,所有设定都恢复。

旋转比较特殊的是需要注意旋转的起点是在画布的左上角,所有你如果想围绕画布的中心来旋转,你需要先ctx.translate(x, y) --x 是左右偏移量,y 是上下偏移量, 如下图:

旋转90度 ctx.rotate(Math.PI);

裁剪 clip

裁剪的本质是将裁剪路径以外的部分隐藏起来

下面是clip前后的图片和代码

ctx.fillRect(0,0,150,150);  //初始的样式(绘制状态)并绘制矩形
ctx.translate(75,75);
ctx.beginPath();
ctx.arc(0,0,60,0,Math.PI*2,true);
ctx.clip();  // 此行代码注释后 就是clip之前

var lingrad = ctx.createLinearGradient(0,-75,0,75); // 此时坐标的原点在中心位置 一个沿参数坐标指定的直线的渐变 ctx.createLinearGradient(x0, y0, x1, y1);
lingrad.addColorStop(0, 'red');
lingrad.addColorStop(1, '#143778');

ctx.fillStyle = lingrad;
ctx.fillRect(-75,-75,150,150);

clip之前:

image.png

clip之后:

image.png 在实际开发中,如果你按照这种方式来进行的往往会走进误区,原因在于虽然路径以外的部分会被隐藏,但是导出时隐藏的部分也是图片的一部分, 而不是只是clip里面的部分,所以实际开发中还是利用drawImage方法:drawImage(img,sx,sy,swidth,sheight,x,y,width,height) 在裁剪时获得裁剪部分在canvas的起始位置,以及裁剪后的宽高,这样就实现了裁剪的功能。

实战

在上一部分中我们总结了绘制,旋转,裁剪操作,看起来整体实现起来还挺简单,但是在实际开发中却比较复杂,因为涉及到的状态比较多,如文章开头所说我们在基于tui.image-editor基础上做一层封装实现

使用ts+hooks实现react组件 我们希望这是一个整体单独的模块 尽可能使用方便,ui交互的考虑 需要找到最佳的方案,在菜单等按钮的位置和整体交互会存在大的改动,基于此:采用react的context api 解决组件属性层层传递的耦合,便于后续交互或者样式的更改不会影响功能

<ImageEditorContext.Provider
   value={{
     imageEditor: imageEditorInst,
     rootEl: rootEl.current,
     imageEditorInfo: imageEditorInfo,
     undoStackLength: undoStackLength,
     redoStackLength: redoStackLength,
   }}
>
        
  {props.imageEditorHeader}
  <Controls controls={props.controls} />
  <div className={styles.contain}>
  	<div ref={rootEl} className={styles.tuiImageEditorContain} />
  </div>
  <Footer controls={props.controls} />
</ImageEditorContext.Provider>

如文章开头所说我们需要做到良好的交互,良好的交互前提需要考虑用户的使用场景:

  • 图片表达更清晰的需求,→ 如贴文字,贴图等 产出一个更生动或者是对方更懂得的需求,
  • 不适合让外人看到的内容的需求,→  裁剪
  • 图片大小对于系统等不符合要求 如宽高、大小等 ,裁剪的需求 →  基于场景除了给出裁剪功能外更是贴心的在图片下方实时展示当前图片的基本信息

拼图的处理也是开发此组件的目的之一,对于拼图的处理采用每次只可以编辑一张图片 编辑完成后生成图片,canvas加载新的图片进行编辑处理,之所以采用这种方式的原因是:1,如本文开头所说基于tui之上开发,在tui中,始终让图片充满画布,这样画布就没有拼图的空间;2,如果采用多个canvas,即加载一张新的图片就重新绘制一个新的canvas,需要切换各个canvas上下文操作,而每个canvas有又自己的状态,交互又变得难以处理。最终呈现的效果大概就是下面这个样子:

image.png 将所有图片处理完 进入拼图模式,可以任意拖拽,然后将页面重新绘制生成新的画布对象,进入单图编辑模式 最后可以导出; 当然这个开发目前还在进行中,如果你有更好的图片编辑 拼图解决方案 欢迎来一起交流啊!