如何使用 cropperjs-v2 进行自定义形状裁剪图片

1,191 阅读6分钟

在上篇文章,我介绍了 cropperjs-v2 的基本功能,以及几个demo,如果有不清楚的同学可以点击去查看 前端裁剪图片 cropperjs-v2 的使用介绍

主题

这里主要分享一下我是如何利用 cropperjs-v2 实现ps中 钢笔功能的裁剪逻辑,它可以在图片上通过点与点之前形成一个闭合空间,进行裁剪

功能演示

recording.gif

思路简介

因为cropperjs没有提供自定义裁剪的功能,所以这个功能是我通过 canvas 完成的,逻辑如下:

1. 先开始一个最简单的布局

<template>
  <div class="basic_container">
    <div class="tool"><button @click="handlePenClick">钢笔</button></div>
    <div class="dialog_wrap">
      <div class="image_wrap">
        <cropper-canvas ref="croppercanvas" background>
          <cropper-image
            :src="fileObj.fileShow"
            alt="Picture"
            ref="cropperimage"
            rotatable
            scalable
            skewable
            translatable
          ></cropper-image>
        </cropper-canvas>
      </div>
      <div class="info_wrap">
        <div class="cropper_preview">
          <div>实际效果:img/canvas</div>
          <img :src="realShow" style="width: 200px" />
          <canvas ref="resultCanvas"></canvas>
        </div>
        <div class="btn_wrap">
          <input type="file" ref="input_form" @change="handleUploadSuccess" />
          <button type="primary" @click="handleConfirm">确认裁剪</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import 'cropperjs';
import { computed, nextTick, ref, onMounted } from 'vue';

const fileObj = ref({});

const croppercanvas = ref();
const cropperimage = ref();

/**
 * 钢笔逻辑
 */
function handlePenClick() {}

/**
 * 确认裁剪
 */
const emit = defineEmits(['success']);
const realShow = ref();
const resultCanvas = ref();
async function handleConfirm() {}

/**
 * 文件上传
 */
const input_form = ref();
async function handleUploadSuccess() {
  const files = input_form.value.files;

  if (files.length) {
    fileObj.value = {
      name: files[0].name,
      file: files[0],
      fileShow: URL.createObjectURL(files[0]),
    };
  }
}
</script>

<style scoped>
.dialog_wrap {
  display: flex;
  .image_wrap {
    width: 400px;
    height: 300px;
    flex-shrink: 0;

    cropper-canvas {
      width: 100%;
      height: 100%;
    }
  }
  .info_wrap {
    margin-left: 20px;
  }
}
button + button {
  margin-left: 20px;
}
</style>

在以上代码,fileObj作为文件的变量,然后 realShow 和 <canvas ref="resultCanvas"></canvas>为显示效果的查看,因为我们是自己裁剪,所以只需要用到最基本的 cropper-canvascropper-image即可

2. 添加钢笔点击

点击钢笔后,启动钢笔逻辑,此时我们需要覆盖一层 canvas 在 cropper-canvas 上面,并且加一个变量 isDrawing,判断是否正在钢笔状态

<cropper-canvas ref="croppercanvas" background>
          <cropper-image
            :src="fileObj.fileShow"
            alt="Picture"
            ref="cropperimage"
            rotatable
            scalable
            skewable
            translatable
          ></cropper-image>
          <!-- 这里新增 -->
          <canvas
            v-if="isDrawing"
            ref="drawingcanvas"
            class="drawing_canvas"
            width="400"
            height="300"
            @click="startDrawing"
            @mousemove="draw"
          ></canvas>
        </cropper-canvas>
/**
 * 钢笔逻辑
 */
 const isDrawing = ref(false);
 const drawingcanvas = ref();
function handlePenClick() {
  isDrawing.value = true;
}
.drawing_canvas {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  background-color: rgba(0, 0, 0, 0.68);
}

3. 添加点击产生圆点的逻辑

因为我们需要记录这些点,所以新增一个变量保存这些点的位置

<!-- 为canvas添加点击事件 -->
<canvas xxx  @click="startDrawing"></canvas>
let points = [];
// canvas点击事件
function startDrawing(e) {
  if (!isDrawing.value) return;
  const canvas = e.target;
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  const ctx = canvas.getContext('2d');

  // 判断是否和已有点重合
  const pointIndex = isMouseOnPoint(x, y);
  if (pointIndex !== -1) {
    // 鼠标点击在已有点上
    if (pointIndex === 0 && points.length > 2) {
    }
  } else {
    // 绘制圆点
    ctx.fillStyle = '#409EFF';
    ctx.beginPath();
    ctx.arc(x, y, 5, 0, Math.PI * 2);
    ctx.fill();
    points.push({ x, y });
  }
}
// 判断是否鼠标和点重合
function isMouseOnPoint(x, y) {
  for (let i = 0; i < points.length; i++) {
    const point = points[i];
    const dx = point.x - x;
    const dy = point.y - y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    if (distance <= 5) {
      return i; // 返回点的索引
    }
  }
  return -1;
}

4. 产生细线逻辑,以及判断是否闭合(难点)

再点击圆点后,鼠标在canvas上移动,应该实时显示一条线到鼠标位置,方便我们观察裁剪区域,所以还需要监听鼠标的移动事件。每次点击下去,判断是否点击已有的点,如果是,那么判断是否闭合ctx.closePath,闭合就绘制闭合区域,方便用户查看

由于鼠标在移动的时候,呈现的线条是临时线条,点击下去后才是实际上的线条,此处我为了方便,每次鼠标移动的时候都清除掉原来的内容,重新绘制,所以在高性能的场合不适用,此处可以改成在新增一个canvas,通过这个去显示临时线条,效率会高一点

<!-- 为canvas添加点击事件 -->
<canvas xxx  @click="startDrawing" @mousemove="draw"></canvas>
// 是否正在使用钢笔绘画线条,只有点击第一个点后,已经完成最后一个点需要绘画线条
let isPanDrawingLine = false;

function startDrawing(e) {
    ...xxx(这里代表之前的代码没改动),下面有 ++ 的代表新增代码
    if (pointIndex !== -1) {
    // 鼠标点击在已有点上
    if (pointIndex === 0 && points.length > 2) {
      isPanDrawingLine = false; // ++
      // 绘制闭合区域
      drawStaticElements(ctx, canvas, true); // ++
    }
  } else {
    // 绘制圆点
    ctx.fillStyle = '#409EFF';
    ctx.beginPath();
    ctx.arc(x, y, 5, 0, Math.PI * 2);
    ctx.fill();
    points.push({ x, y });

    isPanDrawingLine = true; // ++
  }
}

// 新增鼠标移动事件 ++
function draw(e) {
  if (!isDrawing.value || !isPanDrawingLine || points.length === 0) return;

  const canvas = e.target;
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  const ctx = canvas.getContext('2d');

  // 检测是否有鼠标悬停的点
  const pointIndex = isMouseOnPoint(x, y);

  drawStaticElements(ctx, canvas, false, pointIndex);

  // 绘制动态线条从最后一个点到鼠标当前位置
  const lastPoint = points[points.length - 1];
  ctx.beginPath();
  ctx.moveTo(lastPoint.x, lastPoint.y);
  ctx.lineTo(x, y);
  ctx.stroke();
}
// 绘制静态的线条和点,并且判断是否闭合,绘制闭合区域 ++
function drawStaticElements(ctx, canvas, isClosed = false, hoverIndex = -1) {
  // 清除之前的动态线条
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#409EFF';
  ctx.strokeStyle = '#409EFF';
  ctx.lineWidth = 3;

  // 填充闭合区域
  if (isClosed && points.length > 2) {
    ctx.fillStyle = 'rgba(255, 255, 255, .6)';
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      ctx.lineTo(points[i].x, points[i].y);
    }
    ctx.closePath();
    ctx.fill();
  }

  // 重绘所有点和固定的线条
  for (let i = 0; i < points.length; i++) {
    const point = points[i];
    const radius = i === hoverIndex ? 8 : 5;
    // 重新绘制点
    ctx.beginPath();
    ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
    ctx.fill();

    // 重新绘制固定的线条
    if (i > 0) {
      const prevPoint = points[i - 1];
      ctx.beginPath();
      ctx.moveTo(prevPoint.x, prevPoint.y);
      ctx.lineTo(point.x, point.y);
      ctx.stroke();
    }
  }

  // 如果闭合了,绘制最后的线条连接第一个点和最后一个点
  if (isClosed && points.length > 1) {
    ctx.beginPath();
    ctx.moveTo(points[points.length - 1].x, points[points.length - 1].y);
    ctx.lineTo(points[0].x, points[0].y);
    ctx.stroke();
  }
}

5. 裁剪对应形状的图片(难点)

通过canvas的自定义路径绘画 + clip裁剪,即可将图片裁剪出来

本来这里我想直接利用canvas的 clip 直接裁剪出来的,当时不知道为什么怎么试都不行,图片位置和画布位置不大对,于是我看了cropperjs的源码,再结合我做裁剪圆形图片的经验,才形成下面的代码示例,如果大佬有更好的方法,欢迎放在评论区

思路:先新建一个canvas将点位形成的一个矩形(通过最边边的点,形成的一个矩形)变成一个新的矩形,这样裁剪区域的x 和 y就都是0,在调整点位的x、y,通过 canvas自定义路径绘画裁剪出来

// 确认裁剪点击事件
async function handleConfirm() {
  const pointRect = getBoundingBox(points);
  // 此处是模仿croppjs,将点位的矩形变成一个新的canvas导出来,后续的操作在此canvas操作
  const res = await getCanvasFromPoints(pointRect);

  resultCanvas.value.width = res.width;
  resultCanvas.value.height = res.height;
  const ctx = resultCanvas.value.getContext('2d');

  // 由于此时生成的canvas是已我们的点位开始的,所以这里调整一下点位的x和y,变成新canvas的点位
  const points1 = points.map((v) => {
    return {
      x: v.x - pointRect.x,
      y: v.y - pointRect.y,
    };
  });
  ctx.fillStyle = 'transparent';
  ctx.beginPath();

  ctx.moveTo(points1[0].x, points1[0].y);
  for (let i = 1; i < points1.length; i++) {
    ctx.lineTo(points1[i].x, points1[i].y);
  }
  ctx.closePath();
  ctx.save();
  ctx.clip(); // 剪切区域
  ctx.drawImage(res, 0, 0, res.width, res.height);

  // 导出圆形图片数据
  const dataImage = resultCanvas.value.toDataURL('image/png');
  realShow.value = dataImage;
  const file = dataURLtoFile(dataImage, fileObj.value.name);
  emit('success', {
    ...fileObj.value,
    file: file,
    fileShow: dataImage,
  });
}
// 根据点位,获取闭合空间的宽高
function getBoundingBox(points) {
  if (points.length === 0) {
    return { width: 0, height: 0, x: 0, y: 0 };
  }

  // 初始化最小值和最大值为第一个点的坐标
  let minX = points[0].x;
  let maxX = points[0].x;
  let minY = points[0].y;
  let maxY = points[0].y;

  // 遍历所有点,更新最小值和最大值
  for (let point of points) {
    if (point.x < minX) minX = point.x;
    if (point.x > maxX) maxX = point.x;
    if (point.y < minY) minY = point.y;
    if (point.y > maxY) maxY = point.y;
  }

  // 计算宽度和高度
  const width = maxX - minX;
  const height = maxY - minY;

  return { width, height, x: minX, y: minY };
}
// 先将点位形成的矩形,转成一个新的canvas,类似与 $toCanvas()
function getCanvasFromPoints(pointRect) {
  return new Promise((resolve) => {
    const canvas = document.createElement('canvas');
    canvas.width = pointRect.width;
    canvas.height = pointRect.height;

    cropperimage.value.$ready().then((image) => {
      const context = canvas.getContext('2d');
      const [a, b, c, d, e, f] = cropperimage.value.$getTransform();

      const offsetX = -pointRect.x;
      const offsetY = -pointRect.y;
      const translateX = (offsetX * d - c * offsetY) / (a * d - c * b);
      const translateY = (offsetY * a - b * offsetX) / (a * d - c * b);
      let newE = a * translateX + c * translateY + e;
      let newF = b * translateX + d * translateY + f;
      let destWidth = image.naturalWidth;
      let destHeight = image.naturalHeight;

      const centerX = destWidth / 2;
      const centerY = destHeight / 2;

      context.fillStyle = 'transparent';
      context.fillRect(0, 0, pointRect.width, pointRect.height);
      context.save();
      context.translate(centerX, centerY);
      context.transform(a, b, c, d, newE, newF);

      // Move the transform origin to the top-left of the image.
      context.translate(-centerX, -centerY);
      context.drawImage(image, 0, 0, destWidth, destHeight);
      context.restore();
      resolve(canvas);
    });
  });
}
// 将data:image转成新的file
function dataURLtoFile(dataurl, filename) {
  var arr = dataurl.split(','),
    mime = arr[0].match(/:(.*?);/)[1],
    bstr = atob(arr[1]),
    n = bstr.length,
    u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  const blob = new Blob([u8arr], { type: mime });
  const file = new File([blob], filename, { type: mime });
  return file;
}

代码仓库

以上就是所有的思路代码,也可以点击链接,去代码仓库下载全部代码cropper-v2案例/src/components/pan.vue · Duck/EmpiricalCase - 码云 - 开源中国 (gitee.com)