从零开始实现一个图片裁剪工具

2021-10-29 15:49:09 +08:00
 cyrbuzz

不久前做了一个图片裁剪的需求,开源组件裁剪部分完全够用,框的自定义有些不足,于是自己参考着从 0 实现了一下,发现并不需要过多的 Canvas 知识,也并没有想象中的难= =。

从一个刮刮乐说起

在实现裁剪工具之前先来看一个刮刮乐的实现,实现刮刮乐的思路很简单,底层一张图上面是一个灰色蒙版,鼠标(点击后)经过的地方将部分灰色蒙版去除。

去除用 CSS 其实并不困难,难得是任意坐标下连贯的去除,这点对于 CSS 来说并不好实现。这里会自然而然的想到用 Canvas 去做:

一些前置的 Canvas 理论知识:

save 和 restore 用来存储画笔状态和还原画笔状态,因为我们操作的同一支画笔,不存储和还原可能会出现难以调试的 BUG 。

fillStyle 可以设置当前画笔的颜色,支持透明度。

fillRect 可以绘制一个矩形,x,y,width,height 。

clearRect 可以以一个矩形擦除某块区域,x,y,width,height 。
  1. 绘制底图:
<style>
  #canvas {
    background: url(./gouzi.jpeg);
  }
</style>
<canvas id="canvas"></canvas>
<script>
  let canvas = document.querySelector("#canvas");
  let ctx = canvas.getContext("2d");
  canvas.width = 500;
  canvas.height = 500;
</script>
  1. 绘制一层蒙版:
const drawModal = () => {
  ctx.save();
  ctx.fillStyle = "rgba(0,0,0,0.5)";
  ctx.fillRect(0, 0, 500, 500);
  ctx.restore();
};

  1. 鼠标移动时我们将对应的块擦除:
const clearRect = (x, y, w, h) => {
  ctx.save();
  ctx.clearRect(x, y, w, h);
  ctx.restore();
};
canvas.addEventListener("mousemove", (e) => {
  const { offsetX, offsetY } = e;
  clearRect(offsetX, offsetY, 30, 30);
});

这样便可以实现一个超简易版的刮刮乐。

刮刮乐进阶

蒙版,鼠标擦除我们都实现了,美中不足的是擦除的块只能是以矩形的方式展示,无法支持任意形状。

而 Canvas 本身没有提供其他擦除的方法,一种有趣(和有用)的曲线救国的方式是用混合(CSS 中的 mix-blend-mode 也支持设置混合),在所有的混合模式中,会发现destination-out刚好符合我们的需求,在此种混合模式下,已存在的图像只保留没有重叠的部分,刚好是一个擦除的效果。

写起来并不困难,只需要将上面 3.中的 clearRect 换成 fillRect:

const clearRect = (x, y, w, h) => {
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.fillStyle = "rgba(0,0,0)";
  ctx.fillRect(x, y, w, h);
  ctx.restore();
};

这里 fillStyle 并不重要,新的图像只会留下形状,不会留下内容。

这样用 fill 实现了一个和 clear 一致的擦除效果,现在只需要将 fillRect 换成 drawImage ,就可以实现任意图形的擦除。

const clearRect = (x, y, w, h) => {
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.drawImage(img, x, y, w, h);
  ctx.restore();
};

可以看到绘制出了猛犸状的擦除,不要问我为什么选猛犸,因为猛犸它不上BAN

实现图片裁剪

裁剪工具的实现思路

实现之前整理一波需求:

  1. 绘制一层黑白透明底。
  2. 用一个 Canvas 来绘制将要裁剪的图片,这里需要注意的点是图片可能会超过画板的大小,需要做一个缩放处理。
  3. 用另一个 Canvas 绘制灰色蒙版以及挖洞,这里用一个 Canvas 加载底图和绘制蒙版这些也是可以的,用上面提到的混合,我还是分成两个了更加符合直觉。
  4. 用 HTML 来绘制裁剪框的边框以及处理交互事件。

黑白透明底

透明的底一般都会用灰白相间的格子表示,可以直接放一张图,不过这并不能凸显出我们的逼格,用 CSS 实现也并不困难,这里用到 background 的相关属性。

我们知道 background 可以写渐变色,而且可以写很多个,background-size 和 background-position 也都可以单独指定每一个 background 的状态。

首先指定两个同样的渐变色:

background: linear-gradient(
    45deg,
    #ccc 25%,
    transparent 25%,
    transparent 75%,
    #ccc 25%
  ), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 25%);

linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 25%)这一段渐变可以生成一个在右上角和左下角是灰色,中间透明的渐变。

将 background 的大小设置为想要的块大小,这里我设置为 8px:

background-size: 8px 8px;

因为 background 默认铺不满会重复的特性,会看到一张满是三角的格子。

最后我们将第二个(第一个也行,只要符合预期即可)background 的 x 和 y 都移动4px,上一个三角与下一个三角会组合为一整个正方形,不断重复就生成了一个透明格子板。

background-position: 0 0, 4px 4px;

缩放和绘制图片

上面提到绘制图片时我们需要将图片缩放至合适的画板大小,canvas 里并没有类似object-fit: contain的方便属性给我们用,只能自己写了,基本思路也很简单,图片的宽和高都必须小于画板的宽高,如果其中有一个不符合就继续缩小,直到都符合:

const shrinkImage = ({ imageWidth, imageHeight, width, height, base = 1 }) => {
  // 根据 height 和 width 返回一个合适的 imageWidth 和 imageHeight 。

  if (imageWidth / base < width && imageHeight / base < height) {
    return {
      imageWidth: imageWidth / base,
      imageHeight: imageHeight / base,
    };
  }

  return shrinkImage({
    imageWidth,
    imageHeight,
    width,
    height,
    base: base + 0.1,
  });
};

一个简单的尾递归版本,除数的步幅可以看需求增减。

drawImage 有很多参数,这里我们全都用一遍:

let baseWidth = 500;
let baseHeight = 500;
let img = new Image();
let canvasOne = document.querySelector("#canvas-one");
let basePaintParams = {
  baseOffsetX: 0,
  baseOffsetY: 0,
  w: 0,
  h: 0,
};
img.src = "./gouzi.jpeg";
img.onload = () => {
  let { imageWidth, imageHeight } = shrinkImage({
    imageWidth: img.width,
    imageHeight: img.height,
    width: baseWidth,
    height: baseHeight,
  });
  ctxOne.drawImage(
    img,
    0,
    0,
    img.width,
    img.height,
    (baseWidth - imageWidth) / 2,
    (baseHeight - imageHeight) / 2,
    imageWidth,
    imageHeight
  );
  basePaintParams = {
    baseOffsetX: (baseWidth - imageWidth) / 2,
    baseOffsetY: (baseHeight - imageHeight) / 2,
    w: imageWidth,
    h: imageHeight,
  };
};

drawImage 中,第一个参数是原图像,二三个参数标识原图的剪裁 xy,四五个参数表示原图的剪裁宽高,六七个参数表示绘制到 Canvas 中的 xy 这里用 canvas 的宽高减去图像绘制的宽高再除以 2 得到的 xy 是居中的 xy ,八和九则是绘制到 Canvas 中的宽高。

这里声明了一个basePaintParams的变量,现在用不到,后面绘制裁剪框时会用到,这里做一个初始化,默认和绘制的图像一致的 xy 宽高。

MDN 参考:drawImage

绘制蒙版和挖空

蒙版与挖空的具体实现可以用刮刮乐里的实现,这里实现一个矩形裁剪框,混合和 clearRect 都可以。

蒙版:

let canvasTwo = document.querySelector("#canvas-two");

const paintModal = () => {
  ctxTwo.clearRect(0, 0, baseWidth, baseHeight);
  ctxTwo.fillStyle = "rgba(0,0,0,0.5)";
  ctxTwo.fillRect(0, 0, baseWidth, baseHeight);
};

paintModal();

挖空:

const clipModal = () => {
  ctxTwo.save();
  ctxTwo.clearRect(
    basePaintParams.baseOffsetX,
    basePaintParams.baseOffsetY,
    basePaintParams.w,
    basePaintParams.h
  );
  ctxTwo.restore();
};

// 要等到 img onload 之后再绘制,或者提供一个初始化的参数。
clipModal();

这里用 HTML+CSS 实现起来也并不困难,不必非要用 Canvas 。

绘制裁剪框

具体的裁剪框我们用 HTML 来实现,绑定事件这些还是 HTML 来的舒服,常规形状的裁剪框 HTML 可以很轻松的编写,奇奇怪怪的形状也可以用clip-path来实现,暂时先不趟 Canvas 的浑水了。

<style>
  .cropper-clip {
    position: absolute;
    cursor: all-scroll;
    border: 1px solid rgb(30, 158, 251);
  }
  .dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: #1e9efb;
    position: absolute;
  }

  .topleft {
    top: -5px;
    left: -5px;
    cursor: nwse-resize;
  }

  .topright {
    top: -5px;
    right: -5px;
    cursor: nesw-resize;
  }

  .topcenter {
    top: -5px;
    left: 50%;
    transform: translateX(-50%);
    cursor: ns-resize;
  }

  .bottomcenter {
    bottom: -5px;
    left: 50%;
    transform: translateX(-50%);
    cursor: ns-resize;
  }

  .bottomleft {
    bottom: -5px;
    left: -5px;
    cursor: nesw-resize;
  }

  .bottomright {
    bottom: -5px;
    right: -5px;
    cursor: nwse-resize;
  }

  .leftcenter {
    top: 50%;
    transform: translateY(-50%);
    left: -5px;
    cursor: ew-resize;
  }

  .rightcenter {
    top: 50%;
    transform: translateY(-50%);
    right: -5px;
    cursor: ew-resize;
  }
</style>

<div class="cropper-clip">
  <div class="dot topleft"></div>
  <div class="dot topright"></div>
  <div class="dot topcenter"></div>
  <div class="dot bottomleft"></div>
  <div class="dot bottomcenter"></div>
  <div class="dot bottomright"></div>
  <div class="dot leftcenter"></div>
  <div class="dot rightcenter"></div>
</div>

一组很容易写的框,一共八个点,分布在四周,框的位置和大小在设置蒙版和挖空时一起给上:

const drawClipDiv = () => {
  let cropperClip = document.querySelector(".cropper-clip");
  cropperClip.style.width = `${Math.abs(basePaintParams.w)}px`;
  cropperClip.style.height = `${Math.abs(basePaintParams.h)}px`;
  cropperClip.style.left = `${basePaintParams.baseOffsetX}px`;
  cropperClip.style.top = `${basePaintParams.baseOffsetY}px`;
};

Math.abs用来适配改变裁剪框大小时越过线的情况,如果不做可以不加(本文没有实现这个)。

现在已经将裁剪的骨骼描绘完整了,只要后续改变basePaintParams中的坐标和大小参数,重新绘制蒙版挖空以及裁剪框即可。

裁剪框的移动与伸缩

上面我们已经绘制出了基本骨骼,最后只要让裁剪框可以跟随鼠标动起来就可以了。

首先我们现在的 HTML 结构是这样的:

<div class="cropper">
  <div class="cropper-clip">
    <div class="dot topleft"></div>
    <div class="dot topright"></div>
    <div class="dot topcenter"></div>
    <div class="dot bottomleft"></div>
    <div class="dot bottomcenter"></div>
    <div class="dot bottomright"></div>
    <div class="dot leftcenter"></div>
    <div class="dot rightcenter"></div>
  </div>
</div>

我们给cropper注册一个mousemove事件,这样比较容易处理。

裁剪框体和保存某个点按下,需要额外的变量标识。

let dotType = "";
let dotDown = false;
let clipDown = false;

const registerEvents = () => {
  const register = (_class) => {
    let node = document.querySelector(`.${_class}`);
    node.addEventListener("mousedown", (e) => {
      dotDown = true;
      dotType = _class;
    });

    node.addEventListener("mouseup", () => {
      dotDown = false;
    });
  };

  register("topcenter");
  register("bottomcenter");
  register("leftcenter");
  register("rightcenter");
  register("bottomright");
  register("bottomleft");
  register("topright");
  register("topleft");

  let clip = document.querySelector(".cropper-clip");
  clip.addEventListener("mousedown", () => {
    clipDown = true;
  });

  clip.addEventListener("mouseup", () => {
    clipDown = false;
  });
};

核心mousemove的事件整体思路从一条边出发,只要确定了上下和左右的运动策略,其他点进行组合即可:

const cardMouseMove = (e) => {
  if (!dotDown && !clipDown) {
    return;
  }

  const { offsetX, offsetY } = e;

  const topYChange = () => {
    if (e.target === canvasTwo) {
      if (offsetY < basePaintParams.baseOffsetY) {
        basePaintParams.h += basePaintParams.baseOffsetY - offsetY;
        basePaintParams.baseOffsetY = offsetY;
      }
    } else if (e.target === clip) {
      basePaintParams.h -= offsetY;
      basePaintParams.baseOffsetY += offsetY;
    }
  };

  const bottomYChange = () => {
    if (e.target === canvasTwo) {
      if (offsetY > basePaintParams.baseOffsetY) {
        basePaintParams.h = offsetY - basePaintParams.baseOffsetY;
      }
    } else if (e.target === clip) {
      basePaintParams.h = offsetY;
    }
  };

  const leftXChange = () => {
    if (e.target === canvasTwo) {
      if (offsetX < basePaintParams.baseOffsetX) {
        basePaintParams.w += basePaintParams.baseOffsetX - offsetX;
        basePaintParams.baseOffsetX = offsetX;
      }
    } else if (e.target === clip) {
      basePaintParams.w -= offsetX;
      basePaintParams.baseOffsetX += offsetX;
    }
  };

  const rightXChange = () => {
    if (e.target === canvasTwo) {
      if (offsetX > basePaintParams.baseOffsetX) {
        basePaintParams.w = offsetX - basePaintParams.baseOffsetX;
      }
    } else if (e.target === clip) {
      basePaintParams.w = offsetX;
    }
  };
};

这里需要注意的是,我们注册的事件在父节点上,所以事件的 target 有可能是我们绘制的蒙版(在放大的时候),也有可能是裁剪框(缩小的时候),这时候的offset会有所不同。

之后我们只需要根据 8 个点所期望改变的 xy 调用不同的方法即可:

const dotRunMap = {
  topcenter: () => {
    topYChange();
  },
  bottomcenter: () => {
    bottomYChange();
  },
  leftcenter: () => {
    leftXChange();
  },
  rightcenter: () => {
    rightXChange();
  },
  bottomright: () => {
    rightXChange();
    bottomYChange();
  },
  bottomleft: () => {
    leftXChange();
    bottomYChange();
  },
  topright: () => {
    rightXChange();
    topYChange();
  },
  topleft: () => {
    leftXChange();
    topYChange();
  },
};

if (dotDown) {
  dotRunMap[dotType]();
}

// 如果没有点按下,说明是在移动,这里没有做边缘限制,会出圈。
if (clipDown && !dotDown) {
  basePaintParams.baseOffsetX = basePaintParams.baseOffsetX + e.movementX;
  basePaintParams.baseOffsetY = basePaintParams.baseOffsetY + e.movementY;
}

drawClipDiv();
paintModal();
clipModal();

不要忘了重新绘制裁剪框,蒙版和挖空。

预览与保存

预览需要用到另一个 Canvas 来接盘,这里主要用到getImageDataputImageData,参数大部分都一样,需要注意的坑点是如果是个跨域图片,Canvas 默认会认为受到污染,这时需要设置一下原来 image 请求时的跨域设置,更具体可以参考一下这篇文章

<canvas id="clipImage" />;
const save = () => {
  let bgCtx = canvasOne;

  let imageData = bgCtx.getImageData(
    basePaintParams.w < 0
      ? basePaintParams.baseOffsetX + basePaintParams.w
      : basePaintParams.baseOffsetX,
    basePaintParams.h < 0
      ? basePaintParams.baseOffsetY + basePaintParams.h
      : basePaintParams.baseOffsetY,
    Math.abs(basePaintParams.w),
    Math.abs(basePaintParams.h)
  );

  let targetCanvas = clipImage.getContext("2d");
  clipImage.width = basePaintParams.w;
  clipImage.height = basePaintParams.h;
  targetCanvas.putImageData(
    imageData,
    0,
    0,
    0,
    0,
    basePaintParams.w,
    basePaintParams.h
  );
};

保存上是一个比较常规的做法,把 Canvas 先转成 dataURL 的 base64 形式,然后再转换为 Blob ,下载这个 Blob 即可。

const dataURItoBlob = async (url) => await (await fetch(url)).blob();
const saveData = (function () {
  const a = document.createElement("a");
  document.body.appendChild(a);
  return function (blob, fileName) {
    let url = window.URL.createObjectURL(blob);
    a.href = url;
    a.download = fileName;
    a.click();
    window.URL.revokeObjectURL(url);
  };
})();

const exportImg = async () => {
  let MIME_TYPE = "image/png";
  let imgURL = clipImage.toDataURL(MIME_TYPE);
  let res = await dataURItoBlob(imgURL);
  saveData(res, "data.png");
};

总结

这样一个基础功能完备的裁剪工具就完成啦,核心裁剪部分代码并不多,用到的 Canvas 内容也不多,整体很适合来熟悉一波 Canvas ,有了骨骼之后其他的功能都可以沿着这个脉络进行扩展。

这里封装了一个 Vue3 的版本,有需要可以在Github上找到~。

2140 次点击
所在节点    分享创造
3 条回复
pigzzz
2021-10-30 10:27:43 +08:00
👍强
24match
2021-10-30 14:00:49 +08:00
后端人看了表示脑壳疼
cyrbuzz
2021-11-01 10:16:48 +08:00
@24match

捏一下小萌柴就不疼了,哈哈。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/811549

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX