制作一个画板来学习canvas

2,784 阅读7分钟

效果展示

功能

github 代码地址

项目地址中针对画板中的功能都单独做了抽离。如果想了解某个功能的话可以直接打开对应的文件查看效果。

如何给 canvas 设置背景

首先我们会想到

  • 直接给 canvas 元素设置 css 背景颜色。
  • 使用 fill / fillRect 给画板填充颜色。

canvas 设置 css 背景

这种方式有一些局限性

  1. 当我们使用在画板上使用 fillRect 之后,是无法再在通过 css 改变颜色。
    • 相当于 canvas 本身是透明的可以通过 css 来设置底色。当 canvas 上加了一层有色图层,那么就无法看到原先的底色了。
  2. 我们 css 无法局部使用印花的效果。类似下图效果

canvas 使用 fillRect

使用 css 有这些局限,我们就使用 canvas 来绘制一个真正的背景层。

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.fillRect(0, 0, 150, 100);

上面代码确实可以在 canvas 填充成对应的颜色。

问题1

但是如果画板上要是本身就已经绘制了内容,如果你使用 fillRect 会将之前绘制的内容覆盖。
这是由于 CanvasRenderingContext2D.globalCompositeOperation 默认是 source-over

  • source-over 解释为: setting and draws new shapes on top of the existing canvas content
  • destination-over 解释为:New shapes are drawn behind the existing canvas content. 我们绘制的背景应该在已有图层的后面。所以我们可以将设置为 ctx.globalCompositeOperation = 'destination-over'

两种背景的更新如下图。source-over 是覆盖在已经绘制的内容之上。 destination-over 是绘制在之后。

问题2

使用上面的办法确实是可以在现有内容后绘制一层背景层。但是当我们想要再次改变背景颜色时候怎么办?
我们再次更新背景时候还是绘制在已有内容之后,所以是看不到我们绘制的内容的。

解决方案

首先我们了解一个 api

The CanvasRenderingContext2D.drawImage() method of the Canvas 2D API provides different ways to draw an image onto the canvas.

  • 这里的 image 包括 HTMLImageElement 、HTMLCanvasElement 、SVGImageElement 、HTMLVideoElement 等元素。

这样我们使用两个 canvas 来完成更换背景的操作。他们各自分工,一个作为内容层、一个作为背景层。背景层使用 CanvasRenderingContext2D.drawImage() 将内容层渲染到背景层上。

画板导入图片进行编辑

如何将本地图片导入绘制到画板上

首先来介绍 FileRender api,通过这个 FileReader 来读取本地的图片文件。

FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

其中 File 对象可以是来自用户在一个<input>元素上选择文件后返回的 FileList 对象,也可以来自拖放操作生成的 DataTransfer 对象。

  1. 使用 FileReader 读取 blob 文件转为 base64的地址,
  2. 在 load 事件中拿到地址赋值给 img 元素,得到一个 img 对象。(这里可以定义图片的宽高等操作)
  3. 得到图片后我们可以使用 drawImage 将图片绘制到 canvas 上。
/**
 * 读取 input 元素选择的文件转化成 Base64 的地址,并根据该地址创建 img 元素
 * @param blob File 对象
 * @param width 指定图片的宽度
 */
export function blobToImg (blob:Blob) {
  return new Promise((resolve, reject) => {
    let reader = new FileReader()
    reader.addEventListener('load', () => {
      let img = new Image()
      img.src = reader.result as string
      img.addEventListener('load', () => {
          resolve(img)
        }
      )
    })
    reader.readAsDataURL(blob)
  })
}

对图片进行拖拽、拉伸

操作框

操作框是用于判断当前要进行什么操作的。

  • 当落在八个不同方向的小矩形中的时候进行各个方向的拉伸。
  • 当落在图片上进行移动操作

使用 new Path2D() 创建控制框路径(矩形框和边角的控制框),并且将这些路径存储起来,之后通过 ctx.isPointInPath(x,y) 来判断当前落点是否在对于的框内。

移动图片

首先我们知道一开始图片是通过 ctx.drawImage(img,x,y,w,h) 来将本地图片绘制到 canvas 上的。 当我们移动图片的时候我们需要考虑的只有 x,y 坐标位置。 w,h是不需要改变的,所以我们移动时候 mousemove 事件中监听坐标位置变换值,然后将图片重新绘制到新的坐标即可。

拉伸图片

八个方向的拉伸考虑的会有一些不一样。例如

  • 右上角拉伸的时候,我们需要考虑的只有 w,h 的变换。因为起点位置是不变的。
  • 左上角的拉伸的时候,x,y,w,h 都会有变换。
  • ...... 总的来说拉伸图片也是去改变 x,y,w,h的值,然后根据这些值重新去渲染图片。

文字输入效果

模拟光标

模拟光标就是在鼠标点击点位置绘制一个类似光标的矩形,然后让这个矩形消失 -> 出现 -> 消失的过程。来看两个api。

CanvasRenderingContext2D.getImageData() 返回一个 ImageData 对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为(sx, sy)、宽为 sw、高为 sh。

CanvasRenderingContext2D.putImageData() 是 Canvas 2D API 将数据从已有的 ImageData 对象绘制到位图的方法。 如果提供了一个绘制过的矩形,则只绘制该矩形的像素。此方法不受画布转换矩阵的影响。

  1. 通过 getImageData 来将当前画板上的像素都存储起来。(相当于我们保存当前 canvas 的快照)。
  2. 绘制光标矩形
  3. 使用 putImageData 将之前保存的快照应用到 canvas 上。(相当于返回到第一步的画面)
  4. 重复 2,3步骤。实现光标闪烁效果。

文字自动换行

CanvasRenderingContext2D.fillText(text, x, y [, maxWidth]);

fillText 可以将文字绘制指定的位置,但是不会自动换行。如果想要实现自动换行需要了解另外一个知识点。

SVG forginObject元素与文本自动换行

在 svg 中使用 foreignObject 标签可以直接在SVG内部嵌入XHTML元素。当超过设定当宽度后,XHML 元素本身是自带换行的。

<svg xmlns="http://www.w3.org/2000/svg">
  <foreignObject width="120" height="50">
      <body xmlns="http://www.w3.org/1999/xhtml">
        <p style="font-size:12px;margin:0;">一段需要word wrap的文字。</p>
      </body>
    </foreignObject>
</svg>

SVG forginObject元素生成图片

  1. 将html标签拼接到body之中:<svg xmlns="http://www.w3.org/2000/svg"><body><p style="width:100px"></p></body><foreignObject><body></body></foreignObject></svg> 格式中。
  2. 创建 img 元素将步骤1的地址赋值给 img 元素。
  3. 使用 drawImage 将图片位置到 canvas 上
  4. 如果你要保存图片可以使用 canvas.toDataURL()canvas.toBlob() 将图片重新导出。

canvas输入文字自动换行。

export async function autoWrapText(width:number,height:number,value:string,font="16px sans-serif"):Promise<HTMLImageElement>{
  const { fontColor } = store.state
 //  这里用的color不能使用 hex 如:#000,无效
  const path = 'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="'+ width +'" height="'+ height +'"><body xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font:'+ font +';word-break: break-word;color:'+fontColor+'">'+value+'</body></foreignObject></svg>';
  return  await loadImgSize(path,width,height)
}
function loadImgSize(path:string,width:number,height:number):Promise<HTMLImageElement> {
  return new Promise(res=>{
    const img = new Image()
    img.width = width 
    img.height = height
    img.onload = function () {
      res(img)
    }
    img.src = path
  })
}
  1. 监听键盘事件,判断当前输入内容或者功能键(如果要做特殊功能的话)。
  2. 在 mousedown 中记录当前鼠标的位置,计算出距离 canvas 画板右侧的距离记为 remain 变量。将 svg foreignObject 标签的宽度设置为 remain。
  3. 创建 img 标签。将地址拼接成 data:image/svg+xml... 格式
  4. 使用 drawImage 将img绘制到 canvas上。

前端图片上传前压缩

前端压缩的好处

  • 由于上传图片尺寸比较小,因此上传速度会比较快,交互会更加流畅,同时大大降低了网络异常导致上传失败风险。
  • 最最重要的体验改进点:省略了图片的再加工成本。

流程

图片(本地选取或路径) -> canvas 压缩(使用 ctx.drawImage(img,0,0,w,h)来实现) -> 图片(使用 canvas.toDataURL() 、 canvas.toBlob())方法重新导出图片。

使用 ctx.drawImage 来指定 w,h 按比例重新渲染到 canvas,然后导出图片实现压缩。

更多功能查看 github 代码

参考

SVG 简介与截图等应用
mdn