阅读 1238

基于canvas完成图片裁剪工具

前言

本文是基于canvas去实现图片裁剪工具。因为canvas代码还是比较长的,尽量写思路,完整代码已放在github上。

canvas模糊问题

这个是写canvas必定接触的问题,网上关于这个的答案也到处都是,就不详细介绍了。

因为canvas不是矢量图,在Retina屏下,浏览器用多个像素点去渲染一个像素,导致canvas最后呈现出模糊问题。

解决方案:

  • 获取window.devicePixelRatio设备的物理像素分辨率与CSS像素分辨率的比值。
  • canvas context有个属性backingStorePixelRatio表示渲染canvas之前会用几个像素来存储画布信息。不过这个只在某些浏览器上有,例如safari
  • 通过设置canvas.width/heightcanvas.style.width/heightcanvas进行缩放处理,比例为devicePixelRatio/backingStorePixelRatio(ratio)。(canvas.width/height表示画布实际大小,而canvas.style.width/height表示在浏览器上渲染结果大小)
  • 最后再通过context.scale(ratio, ratio)canvas进行处理,修复他的呈现效果

如果用typescript的话,会报backingStorePixelRatio不存在错误,加上一个类型定义文件解决。

export const getPixelRatio = (context: CanvasRenderingContext2D) => {
  const backingStore =
    context.backingStorePixelRatio ||
    context.webkitBackingStorePixelRatio ||
    context.mozBackingStorePixelRatio ||
    context.msBackingStorePixelRatio ||
    context.oBackingStorePixelRatio || 1;
  return (window.devicePixelRatio || 1) / backingStore;
};
const calcCanvasSize = () => {
    //...dosth.
    canvasRef.current.style.width = `${canvasWidth}px`;
    canvasRef.current.style.height = `${canvasHeight}px`;
    canvasRef.current.width = canvasWidth * ratio;
    canvasRef.current.height = canvasHeight * ratio;  
    ctx.scale(ratio, ratio);
};
//省略不必要代码
复制代码

给canvas画上img

这个其实就是,通过input获取到本地图片文件,通过window.URL.createObjectURL获取到DOMString,将其作为imgsrc。通过ctx.drawImage将图片绘画到canvas上。

因为对于图片裁剪工具而言,img是应该绘画在最底层,所以需要通过globalCompositeOperation,将其绘画在底层。(globalCompositeOperation表示如何将一个源(新的)图像绘制到目标(已有)的图像上。)

const handleChoiseImg = () => {
    if (createURL) {
      window.URL.revokeObjectURL(createURL);
    };

    createURL = window.URL.createObjectURL(inputRef.current!.files![0]);
    img = new Image();
    img.onload = () => {
      //initImageCanvas(img); 这个函数我是去获取img应该缩小比例和缩小宽高
      // calcCanvasSize(); 这个我是去获取canvas应该呈现的size
      drawImage();  //绘画img
    };
    img.src = createURL;
};

const drawImage = () => {
    // todo sth.
    ctx.save();
    ctx.globalCompositeOperation = 'destination-over';
    // ctx.translate(canvasWidth / 2, canvasHeight / 2);
    // ctx.rotate(Math.PI / 180 * rotate);
    // if (rotate % 180 !== 0) {
    //   [canvasWidth, canvasHeight] = [canvasHeight, canvasWidth];
    // };
    // ctx.translate(-canvasWidth / 2, - canvasHeight / 2);
    ctx.drawImage(
      img,
      (canvasWidth - scaleImgWidth) / 2, (canvasHeight - scaleImgHeight) / 2,
      scaleImgWidth, scaleImgHeight
    );
    // canvasWidth/Height表示canvas的宽高(style),scaleImgWidth/Height表示图片缩放后的宽高
    ctx.restore();
};
复制代码

蒙层&选中框

蒙层绘制

还是利用globalCompositeOperation将其绘画在已有图像的上方。

const drawCover = () => {
    ctx.save();
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
    ctx.globalCompositeOperation = 'source-atop';
    ctx.restore();
};
复制代码

选中框绘制

其实选中框,就是通过clearRect清除某个区域的蒙层,然后绘画自己的框框style,最后将img绘画在底层。

canvas的动画都是一帧一帧绘画出来的,选中框的拖动过程,其实就是不断去clearRect整个canvas,然后重新走上面的流程,即重新绘画的过程。

const drawSelect = (x: number, y: number, w: number, h: number) => {
    ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
    //清空整个canvas
    drawCover();
    //绘画蒙层
    ctx.save();
    ctx.clearRect(x, y, w, h);
    //清空选中区域
    ctx.strokeStyle = '#5696f8';
    ctx.strokeRect(x, y, w, h);
    // 画选中框
    // todo sth. 给选中框加一些style
    ctx.restore();
    drawImage();
    // 绘画图片
};
复制代码

选中框拖拽拉伸&边界处理

选中框拖拽拉伸就是,对mouse事件的处理,在mouseDown的时候,给其一个标志符,在mouseMove进行选中框不断刷新绘制,在mouseUp取消标志符(这个事件可以给外面容器)。

边界处理,就是对mouseMove处理过的选中框位置进行处理判断,若超出边界,则修复他。 就是对offsetXoffsetY进行处理,然后在不同方向上去判断如何修改选中框,由于代码量比较大,完整可去github上看。

效果图:

图片旋转处理

canvas旋转中心是以左上角为中心,如果直接调用rotate,那么结果肯定不是我们想要的结果。那么就利用到了translate去移动canvas到中心点,然后再调用rotate旋转,旋转结束后再利用translatecanvas移回他的位置。

唯一的问题就是,弄清rotate后,你再translate平移canvas这个时候的x、y的值。

我这边对于图片裁剪工具的处理是,旋转后,去修改canvaswidth/height&style width/height。这个时候,canvas是旋转了,但是image重新绘画的时候,也要绘画旋转后的图,那么就利用上方讲的方法去旋转绘画。

还有就是别忘记通过save & restore去保存和恢复绘图状态。

const drawImage = () => {
    // todo sth.
    ctx.save();
    ctx.globalCompositeOperation = 'destination-over';
    ctx.translate(canvasWidth / 2, canvasHeight / 2);
    ctx.rotate(Math.PI / 180 * rotate);
    if (rotate % 180 !== 0) {
      [canvasWidth, canvasHeight] = [canvasHeight, canvasWidth];
    };
    ctx.translate(-canvasWidth / 2, - canvasHeight / 2); 
    ctx.drawImage(
      img,
      (canvasWidth - scaleImgWidth) / 2, (canvasHeight - scaleImgHeight) / 2,
      scaleImgWidth, scaleImgHeight
    );
    ctx.restore();
};
复制代码

效果图:

图片缩放处理

scale也是以左上角为缩放中心,然后如果缩放的话也需要save & restore,不然会对后续操作进行影响。

不过,我这里没有采用scale,而是手动修改图片缩放比例,然后重新得到scaleImgWidthscaleImgHeight,在去调用drawImage。因为代码上是将其显示在中心,所以就可以直接修改后调用。

// 修改 scaleImg 得到scaleImgWidth & scaleImgHeight
ctx.drawImage(
  img,
  (canvasWidth - scaleImgWidth) / 2, (canvasHeight - scaleImgHeight) / 2,
  scaleImgWidth, scaleImgHeight
);
复制代码

效果图:

图片灰度处理

灰度处理就是通过getImageData获取canvasImageData即像素数据,可以对像素数据进行处理。然后再将这个处理后的像素数据,重新通过putImageData放回到canvas上。

像素数据,对于每个像素都有四个方面的信息,分别是RedGreenBlueAlpha

灰度处理公式还是挺多的,我这边就采用(R + 2G + B) >> 2

const imgData = ctx.getImageData(0, 0, canvasSize.width * ratio, canvasSize.height * ratio);
getGrayscaleData(imgData);
ctx.putImageData(imgData, 0, 0);
复制代码

除此之外,还可以做很多类似的处理,比如,对比色处理,颜色选择器等等。

效果图:

实时显示截选的图片

如果仅仅是去截选canvas目前显示的部分,是不太友好的。应该是对应到原始图片的相应位置,去截选这个位置的图片才是比较友好的。

处理思路:

  • 新创建一个canvas,将img完整绘画在上面,并且完成旋转问题
  • 通过选中框的x y w h的值,还有img width/heightcanvas width/height的值,得到对应原始图片的截选部分的x y
  • 通过getImageData得到ImageData,并判断是否需要灰度处理
  • 然后重新修改上面创建的canvaswidth/height为选中图片部分的putW putH
  • ImageData通过putImageData放入canvas中 通过toBlob获取到blob
  • 最后通过window.URL.createObjectURL获取到DOMString
export const getPhotoData = () => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // todo canvas处理

    ctx.drawImage(img, 0, 0, imgWidth, imgHeight);
    // 处理获得putX putY putW putH
    const imgData = ctx.getImageData(putX, putY, putW, putH);
    if (grayscale) {    //灰度处理
        getGrayscaleData(imgData);
    };
    canvas.width = putW;
    canvas.height = putH;

    ctx.putImageData(imgData, 0, 0);
    return new Promise(res => {
        canvas.toBlob(e => res(e));
    });
};

const cancelChangeSelect = async () => {
    // todo sth.
    dataUrl && (window.URL.revokeObjectURL(dataUrl));
    const blob = await getPhotoData() as Blob;
    const newDataUrl = window.URL.createObjectURL(blob);
    setDataUrl(newDataUrl);
    // todo sth.
};
// 省去不关键代码
复制代码

效果图:

下载截选图片

这个其实上面已经写的差不多了,获取到了dataUrl后,将其作为a标签的href,下载就完事儿了。(当然还有很多其他下载方式,就不一一列举了)

完整代码

已上传github